diff --git a/.gitignore b/.gitignore index 961eb32..dea8a47 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ $RECYCLE.BIN/ /.cursor/ .env benchmarks/ +**/.nemoflow/ # Local third-party upstream checkouts. These are bootstrapped from # third_party/sources.lock and are intentionally excluded from the tracked diff --git a/docs/index.md b/docs/index.md index adf5cd6..72401a4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -172,6 +172,7 @@ Advanced Guide: Handle Non-Serializable Data Advanced Guide: Provider Codecs Advanced Guide: Provider Response Codecs +OpenCode Plugin Code Examples ``` diff --git a/docs/integrate-frameworks/about.md b/docs/integrate-frameworks/about.md index 9ae886d..140e41f 100644 --- a/docs/integrate-frameworks/about.md +++ b/docs/integrate-frameworks/about.md @@ -49,6 +49,7 @@ Use these guide links to move from the overview into task-specific instructions. - [Advanced Guide: Using Codecs](using-codecs.md) explains typed value codecs for framework-facing wrappers. - [Advanced Guide: Provider Codecs](provider-codecs.md) explains provider request and response codecs for normalized middleware and event annotations. - [Advanced Guide: Provider Response Codecs](provider-response-codecs.md) focuses on response-only annotations for subscribers and exporters. +- [OpenCode Plugin](opencode.md) explains how to install and configure the standalone OpenCode observability plugin. - [Code Examples](code-examples.md) collects fallback APIs, mark events, and repository patch workflow examples. Start by identifying the framework's stable tool and LLM boundaries. Prefer diff --git a/docs/integrate-frameworks/opencode.md b/docs/integrate-frameworks/opencode.md new file mode 100644 index 0000000..19790aa --- /dev/null +++ b/docs/integrate-frameworks/opencode.md @@ -0,0 +1,146 @@ + + +# OpenCode Plugin + +NeMo Flow integrates with OpenCode through the `nemo-flow-opencode` server +plugin. The plugin uses OpenCode's public plugin hooks and does not require a +patched OpenCode checkout. + +Use this plugin when you want NeMo Flow observability for OpenCode sessions, +LLM calls, successful tool calls, and session errors. The plugin maps OpenCode +hook payloads into NeMo Flow session, LLM, and tool spans. The generic NeMo +Flow `observability` component controls ATOF, ATIF, OpenTelemetry, and +OpenInference export. + +## Requirements + +- OpenCode with server plugin support. +- Node.js 20 or newer. +- A NeMo Flow Node.js binding package compatible with `nemo-flow-opencode`. +- Provider credentials configured in OpenCode. + +## Install + +Install the plugin with the OpenCode CLI: + +```bash +opencode plugin nemo-flow-opencode +``` + +You can also install the package in the Node.js environment where OpenCode +loads plugins: + +```bash +npm install nemo-flow-opencode +``` + +OpenCode uses the package name `nemo-flow-opencode` in the `plugin` array. + +## Enable and Configure the Plugin + +Create or update `opencode.json` in the OpenCode project directory: + +```json +{ + "plugin": [ + [ + "nemo-flow-opencode", + { + "enabled": true, + "logPath": "./.nemoflow/opencode-plugin.log", + "plugins": { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": true, + "config": { + "version": 1, + "atof": { + "enabled": true, + "output_directory": "./.nemoflow", + "filename": "opencode.atof.jsonl", + "mode": "overwrite" + }, + "atif": { + "enabled": true, + "agent_name": "opencode", + "output_directory": "./.nemoflow", + "filename_template": "opencode-{session_id}.atif.json" + }, + "opentelemetry": { + "enabled": false, + "transport": "http_binary", + "endpoint": "http://localhost:4318/v1/traces", + "service_name": "opencode-nemo-flow" + }, + "openinference": { + "enabled": false, + "transport": "http_binary", + "endpoint": "http://localhost:6006/v1/traces", + "service_name": "opencode-nemo-flow" + } + } + } + ] + } + } + ] + ] +} +``` + +This example enables filesystem ATOF and ATIF export and leaves OTLP exporters +disabled until you point them at a collector or Phoenix endpoint. Remove +exporter sections you do not use, or set their `enabled` fields to `false`. + +- `plugin[][0]` is the OpenCode plugin package name. Use + `nemo-flow-opencode`. +- `enabled` disables or enables the NeMo Flow OpenCode wrapper without removing + the plugin entry. +- `logPath` writes JSONL diagnostics for plugin initialization and + pass-through behavior. +- `plugins` is the generic NeMo Flow plugin configuration document. Use this + object to configure built-in components such as `observability`. +- `plugins.components[].config.atof` writes raw ATOF JSONL lifecycle events. +- `plugins.components[].config.atif` writes ATIF trajectory JSON files. +- `plugins.components[].config.opentelemetry` sends generic OTLP spans to an + OpenTelemetry collector when `enabled` is `true`. +- `plugins.components[].config.openinference` sends OpenInference OTLP spans to + Phoenix or another OpenInference-compatible collector when `enabled` is + `true`. + +## Configuration Key Names + +OpenCode wrapper fields use JavaScript-style names, such as `logPath`. + +The top-level `plugins` object inside the wrapper is the generic NeMo Flow +plugin config. Fields inside this object use NeMo Flow generic plugin names, so +they are `snake_case` in every binding. + +Missing observability sections are disabled. Plugin-host validation or +initialization failures leave OpenCode in pass-through mode and write a warning +to `logPath`. + +The ATIF filename placeholder `{session_id}` is the NeMo Flow top-level agent +scope UUID. The OpenCode session ID is recorded in event metadata. + +See [Configure the Observability Plugin](../export-observability-data/observability-plugin.md) +for the complete `observability` component schema and exporter-specific fields. + +The plugin is passive. It records observability output but does not rewrite +prompts, tool arguments, model requests, or OpenCode execution behavior. + +OpenCode streaming message events are used internally to reconstruct concise +LLM responses. They are not exported as individual ATIF steps. + +## Known Limitations + +The current OpenCode plugin API is enough for passive observability. It is not +enough for NeMo Flow request intercepts, execution intercepts, conditional +blocking, or complete tool error spans because OpenCode does not yet expose +around-style LLM or tool hooks. Future work should add generic OpenCode plugin +hooks upstream before enabling those behaviors. diff --git a/integrations/opencode/README.md b/integrations/opencode/README.md new file mode 100644 index 0000000..f1204eb --- /dev/null +++ b/integrations/opencode/README.md @@ -0,0 +1,94 @@ + + +# NeMo Flow OpenCode Plugin + +`nemo-flow-opencode` is a standalone OpenCode server plugin for NeMo Flow +observability. It uses OpenCode's public plugin API and does not require +patching OpenCode. It maps OpenCode activity into NeMo Flow session, LLM, and +tool spans for the generic observability plugin. + +For the full guide, see `docs/integrate-frameworks/opencode.md` in the NeMo +Flow documentation. + +## Install + +Install the plugin with the OpenCode CLI: + +```bash +opencode plugin nemo-flow-opencode +``` + +You can also install the package in the Node.js environment where OpenCode +loads plugins: + +```bash +npm install nemo-flow-opencode +``` + +## Configure + +Use the package name in `opencode.json`: + +```json +{ + "plugin": [ + [ + "nemo-flow-opencode", + { + "enabled": true, + "logPath": "./.nemoflow/opencode-plugin.log", + "plugins": { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": true, + "config": { + "version": 1, + "atof": { + "enabled": true, + "output_directory": "./.nemoflow", + "filename": "opencode.atof.jsonl" + }, + "atif": { + "enabled": true, + "agent_name": "opencode", + "output_directory": "./.nemoflow", + "filename_template": "opencode-{session_id}.atif.json" + } + } + } + ] + } + } + ] + ] +} +``` + +Fields inside `plugins` are NeMo Flow generic plugin configuration, so they use +`snake_case`. The OpenCode wrapper fields use JavaScript-style names, such as +`logPath`. + +## Output + +Configure the built-in `observability` component to write: + +- ATOF JSONL events with `plugins.components[].config.atof`. +- ATIF trajectory files with `plugins.components[].config.atif`. +- Optional OpenTelemetry or OpenInference traces with + `plugins.components[].config.opentelemetry` or `openinference`. +- JSONL plugin diagnostics with the OpenCode wrapper `logPath` field. + +OpenCode streaming message events are used internally to reconstruct concise +LLM responses. They are not exported as individual ATIF steps. + +## Current Limitations + +This plugin uses only existing OpenCode hooks. OpenCode does not yet expose an +around-style LLM stream hook or tool execution hook, so the plugin cannot record +exact LLM stream duration, tool error spans for every failure path, request +intercepts, execution intercepts, or conditional guardrail blocking. diff --git a/integrations/opencode/package.json b/integrations/opencode/package.json new file mode 100644 index 0000000..7a911f6 --- /dev/null +++ b/integrations/opencode/package.json @@ -0,0 +1,52 @@ +{ + "name": "nemo-flow-opencode", + "version": "0.2.0", + "description": "OpenCode server plugin that maps OpenCode activity into NeMo Flow observability.", + "type": "module", + "main": "./server.js", + "exports": { + ".": { + "default": "./server.js" + }, + "./server": { + "default": "./server.js" + } + }, + "files": [ + "README.md", + "server.js" + ], + "scripts": { + "test": "node --test test/*.mjs" + }, + "keywords": [ + "opencode", + "nemo-flow", + "observability", + "atif", + "atof", + "plugin" + ], + "homepage": "https://github.com/NVIDIA/NeMo-Flow/blob/main/docs/integrate-frameworks/opencode.md", + "bugs": { + "url": "https://github.com/NVIDIA/NeMo-Flow/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NVIDIA/NeMo-Flow.git", + "directory": "integrations/opencode" + }, + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "nemo-flow-node": ">0.1.0 <1.0.0" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.14.40" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.40" + } +} diff --git a/integrations/opencode/server.js b/integrations/opencode/server.js new file mode 100644 index 0000000..ed1c41d --- /dev/null +++ b/integrations/opencode/server.js @@ -0,0 +1,1114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs/promises" +import path from "node:path" + +const PLUGIN_ID = "nemo-flow-opencode" +const RECENT_FLUSH_TTL_MS = 2000 +const OBSERVED_EVENTS = new Set([ + "session.created", + "session.updated", + "session.deleted", + "session.error", + "session.status", + "session.idle", + "message.updated", + "message.removed", + "message.part.updated", + "message.part.delta", + "message.part.removed", +]) +const INTERNAL_LLM_AGENTS = new Set(["title"]) + +/** + * Create the plugin logger. + */ +function createLogger(logPath) { + const seen = new Set() + + /** + * Write one diagnostic record to the configured log destination. + */ + async function write(level, message, extra) { + const record = { + timestamp: new Date().toISOString(), + level, + plugin: PLUGIN_ID, + message, + ...(extra === undefined ? {} : { extra: toJsonSafe(extra) }), + } + const line = JSON.stringify(record) + "\n" + if (logPath) { + await ensureParentDir(logPath) + await fs.appendFile(logPath, line) + return + } + const text = `[${PLUGIN_ID}] ${message}` + const loggedExtra = record.extra ?? "" + if (level === "error") console.error(text, loggedExtra) + else if (level === "warn") console.warn(text, loggedExtra) + else console.info(text, loggedExtra) + } + + return { + info: (message, extra) => write("info", message, extra), + warn: (message, extra) => write("warn", message, extra), + error: (message, extra) => write("error", message, extra), + warnOnce: (key, message, extra) => { + if (seen.has(key)) return Promise.resolve() + seen.add(key) + return write("warn", message, extra) + }, + } +} + +/** + * Ensure the parent directory for an output file exists. + */ +async function ensureParentDir(filePath) { + await fs.mkdir(path.dirname(filePath), { recursive: true }) +} + +/** + * Resolve a plugin output path relative to the OpenCode project directory. + */ +function resolveOutputPath(baseDir, value) { + if (typeof value !== "string" || value.trim() === "") return undefined + if (path.isAbsolute(value)) return value + return path.resolve(baseDir, value) +} + +/** + * Resolve an output directory inside generic observability plugin config. + */ +function resolveOutputDirectory(baseDir, value) { + if (typeof value !== "string" || value.trim() === "") return value + if (path.isAbsolute(value)) return value + return path.resolve(baseDir, value) +} + +/** + * Normalize OpenCode plugin options into concrete runtime settings. + */ +function normalizeOptions(input, options = {}) { + const baseDir = input?.directory ?? process.cwd() + const rawOptions = options ?? {} + rejectRemovedOption(rawOptions, "atofPath", "configure plugins.components[].config.atof instead") + rejectRemovedOption(rawOptions, "atifPath", "configure plugins.components[].config.atif instead") + + return { + enabled: rawOptions.enabled !== false, + plugins: normalizePluginHostConfig(baseDir, rawOptions.plugins), + logPath: resolveOutputPath(baseDir, rawOptions.logPath ?? "./.nemoflow/opencode-plugin.log"), + } +} + +/** + * Normalize the embedded generic NeMo Flow plugin-host configuration. + */ +function normalizePluginHostConfig(baseDir, value) { + if (value === undefined) { + return defaultPluginHostConfig(baseDir) + } + + const raw = asRecord(value, "plugins", false) + const version = optionalNumber(raw.version, "plugins.version") ?? 1 + const components = raw.components === undefined ? [] : raw.components + + if (!Array.isArray(components)) { + throw new Error("plugins.components must be an array") + } + + const normalizedComponents = components.map((component, index) => + normalizePluginComponent(baseDir, component, `plugins.components[${index}]`), + ) + + return { + ...raw, + version, + components: withDefaultObservabilityComponent(baseDir, normalizedComponents), + } +} + +/** + * Build the default generic plugin config used by the OpenCode wrapper. + */ +function defaultPluginHostConfig(baseDir) { + return { + version: 1, + components: [defaultObservabilityComponent(baseDir)], + } +} + +/** + * Add a default observability sink unless the caller configured one explicitly. + */ +function withDefaultObservabilityComponent(baseDir, components) { + if (components.some((component) => component?.kind === "observability")) return components + return [...components, defaultObservabilityComponent(baseDir)] +} + +/** + * Create the OpenCode filesystem observability defaults. + */ +function defaultObservabilityComponent(baseDir) { + return normalizePluginComponent( + baseDir, + { + kind: "observability", + enabled: true, + config: { + version: 1, + atof: { + enabled: true, + output_directory: "./.nemoflow", + filename: "opencode.atof.jsonl", + }, + atif: { + enabled: true, + agent_name: "opencode", + output_directory: "./.nemoflow", + filename_template: "opencode-{session_id}.atif.json", + }, + }, + }, + "plugins.components[0]", + ) +} + +/** + * Normalize path-bearing sections on the built-in observability component. + */ +function normalizePluginComponent(baseDir, value, fieldPath) { + const component = asRecord(value, fieldPath, false) + const normalized = { ...component } + + if (component.kind !== "observability" || component.config === undefined) { + return normalized + } + + normalized.config = normalizeObservabilityConfig(baseDir, asRecord(component.config, `${fieldPath}.config`, false)) + return normalized +} + +/** + * Keep OpenCode project-relative paths ergonomic while preserving NeMo Flow's + * generic plugin config shape. + */ +function normalizeObservabilityConfig(baseDir, config) { + const normalized = { ...config } + for (const sectionName of ["atof", "atif"]) { + if (normalized[sectionName] === undefined) continue + const section = asRecord(normalized[sectionName], `observability.${sectionName}`, false) + normalized[sectionName] = { + ...section, + output_directory: resolveOutputDirectory(baseDir, section.output_directory), + } + } + return normalized +} + +/** + * Reject exporter options that were replaced by the generic plugin config. + */ +function rejectRemovedOption(options, name, hint) { + if (Object.prototype.hasOwnProperty.call(options, name)) { + throw new Error(`${name} was removed; ${hint}`) + } +} + +/** + * Require an object config section, optionally treating undefined as empty. + */ +function asRecord(value, fieldPath, optional) { + if (value === undefined && optional) return {} + if (value !== null && typeof value === "object" && !Array.isArray(value)) return value + throw new Error(`${fieldPath} must be an object`) +} + +/** + * Parse an optional finite number. + */ +function optionalNumber(value, fieldPath) { + if (value === undefined) return undefined + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${fieldPath} must be a finite number`) + } + return value +} + +/** + * Convert arbitrary OpenCode hook payloads into JSON-safe data. + */ +function toJsonSafe(value) { + if (value === undefined) return null + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + } + } + + const seen = new WeakSet() + try { + return JSON.parse( + JSON.stringify(value, (key, nested) => { + if (/^(api[-_]?key|authorization|password|secret|access[-_]?token|refresh[-_]?token|id[-_]?token|token)$/i.test(key)) { + return "[Redacted]" + } + if (typeof nested === "bigint") return nested.toString() + if (typeof nested === "function") return `[Function ${nested.name || "anonymous"}]` + if (nested instanceof Error) return toJsonSafe(nested) + if (nested && typeof nested === "object") { + if (seen.has(nested)) return "[Circular]" + seen.add(nested) + } + return nested + }), + ) + } catch { + return null + } +} + +/** + * Format OpenCode model metadata as a stable provider/model string. + */ +function modelName(model) { + if (!model) return undefined + const provider = model.providerID ?? model.provider?.id + const id = model.modelID ?? model.id + if (provider && id) return `${provider}/${id}` + if (id) return String(id) + return undefined +} + +/** + * Read the OpenCode agent name from hook input or event metadata. + */ +function agentName(input, fallback = "opencode") { + if (typeof input?.agent === "string" && input.agent) return input.agent + if (typeof input?.message?.agent === "string" && input.message.agent) return input.message.agent + if (typeof input?.info?.agent === "string" && input.info.agent) return input.info.agent + return fallback +} + +/** + * Keep provider config out of telemetry while preserving useful identity. + */ +function compactProvider(provider) { + if (!provider || typeof provider !== "object") return undefined + return toJsonSafe({ + id: provider.id, + source: provider.source, + env: provider.env, + }) +} + +/** + * Keep model config out of telemetry while preserving useful identity. + */ +function compactModel(model) { + if (!model || typeof model !== "object") return undefined + return toJsonSafe({ + id: model.id ?? model.modelID, + modelID: model.modelID, + providerID: model.providerID ?? model.provider?.id, + name: model.name, + family: model.family, + }) +} + +/** + * Keep only LLM parameter fields that describe the current call. + */ +function compactParams(params) { + if (!params || typeof params !== "object") return {} + return toJsonSafe({ + temperature: params.temperature, + topP: params.topP, + topK: params.topK, + maxOutputTokens: params.maxOutputTokens, + options: params.options, + }) +} + +/** + * Read the OpenCode session ID from a bus event payload. + */ +function eventSessionID(event) { + const props = event?.properties + return props?.sessionID ?? props?.info?.id +} + +/** + * Return true for OpenCode helper calls that should not appear in the agent trajectory. + */ +function shouldSkipLlm(input) { + return INTERNAL_LLM_AGENTS.has(input?.agent) +} + +/** + * Build metadata attached to the NeMo Flow session scope. + */ +function inputSessionMetadata(sessionID, state) { + return { + source: "opencode", + sessionID, + agent: state.agent, + model: state.model, + } +} + +/** + * Build common metadata for OpenCode-derived NeMo Flow marks. + */ +function eventMetadata(session, extra = {}) { + return { + agent: session?.agent, + model: session?.model, + ...extra, + } +} + +/** + * Decide whether an OpenCode event should flush the ATIF trajectory. + */ +function shouldFlushEvent(event) { + if (!event) return false + if (event.type === "session.deleted" || event.type === "session.idle") return true + if (event.type !== "session.status") return false + return event.properties?.status?.type === "idle" +} + +/** + * Log plugin-host validation or activation diagnostics. + */ +async function logDiagnostics(logger, diagnostics = []) { + for (const diagnostic of diagnostics) { + const prefix = diagnostic.component ? `${diagnostic.component}: ` : "" + const message = `${prefix}${diagnostic.code}: ${diagnostic.message}` + if (diagnostic.level === "error") { + await logger.warn(message, diagnostic) + } else { + await logger.info(message, diagnostic) + } + } +} + +/** + * Return true when a plugin-host report contains error diagnostics. + */ +function hasErrorDiagnostics(report) { + return report?.diagnostics?.some((diagnostic) => diagnostic.level === "error") === true +} + +/** + * Validate and activate NeMo Flow's generic plugin-host config. + */ +async function initializePluginHost(pluginHost, config, logger) { + const validationReport = pluginHost.validate(config) + await logDiagnostics(logger, validationReport.diagnostics) + if (hasErrorDiagnostics(validationReport)) { + throw new Error("NeMo Flow plugin host config validation failed") + } + + await ensureObservabilityOutputDirectories(config) + const activationReport = await pluginHost.initialize(config) + await logDiagnostics(logger, activationReport.diagnostics) + if (hasErrorDiagnostics(activationReport)) { + await logger.warn("NeMo Flow plugin host initialized with error diagnostics") + } +} + +/** + * Create filesystem output directories before exporter registration opens files. + */ +async function ensureObservabilityOutputDirectories(config) { + const components = Array.isArray(config?.components) ? config.components : [] + for (const component of components) { + if (component?.kind !== "observability" || component.enabled === false) continue + const observabilityConfig = component.config + for (const sectionName of ["atof", "atif"]) { + const section = observabilityConfig?.[sectionName] + if (section?.enabled !== true) continue + const outputDirectory = section.output_directory + if (typeof outputDirectory === "string" && outputDirectory.trim() !== "") { + await fs.mkdir(outputDirectory, { recursive: true }) + } + } + } +} + +/** + * Summarize the active generic plugin config for diagnostics. + */ +function pluginConfigSummary(config) { + const components = Array.isArray(config?.components) ? config.components : [] + return { + version: config?.version, + components: components.map((component) => ({ + kind: component?.kind, + enabled: component?.enabled !== false, + })), + } +} + +/** + * Convert thrown values into stable log records. + */ +function toMessage(error) { + return error instanceof Error ? error.message : String(error) +} + +/** + * Create the NeMo Flow adapter behind the OpenCode plugin hooks. + */ +function createNemoFlowAdapter(lib, pluginHost, logger) { + const sessions = new Map() + const recentFlushes = new Map() + let closed = false + + /** + * Prune duplicate-flush suppression state so it cannot grow indefinitely. + */ + function pruneRecentFlushes(now = Date.now()) { + for (const [sessionID, timestamp] of recentFlushes) { + if (now - timestamp > RECENT_FLUSH_TTL_MS) recentFlushes.delete(sessionID) + } + } + + /** + * Return true when a just-closed session receives a duplicate idle/delete event. + */ + function wasRecentlyFlushed(sessionID) { + pruneRecentFlushes() + const recentFlushAt = recentFlushes.get(sessionID) + return recentFlushAt !== undefined && Date.now() - recentFlushAt <= RECENT_FLUSH_TTL_MS + } + + /** + * Run a callback with the session scope stack active when supported. + */ + function withStack(session, callback) { + if (!session.stack || typeof lib.setThreadScopeStack !== "function") return callback() + const previous = typeof lib.currentScopeStack === "function" ? lib.currentScopeStack() : undefined + lib.setThreadScopeStack(session.stack) + try { + return callback() + } finally { + if (previous !== undefined) lib.setThreadScopeStack(previous) + } + } + + /** + * Keep NeMo Flow observability failures from changing OpenCode hook behavior. + */ + function runtimeCall(warnKey, message, callback) { + try { + return callback() + } catch (error) { + void logger.warnOnce(warnKey, message, error).catch(() => {}) + return undefined + } + } + + /** + * Create or update the NeMo Flow session state for an OpenCode session. + */ + function ensureSession(sessionID, metadata = {}) { + if (!sessionID) return undefined + + let session = sessions.get(sessionID) + if (session) { + if (metadata.agent) session.agent = metadata.agent + if (metadata.model) session.model = metadata.model + return session + } + + session = { + id: sessionID, + agent: metadata.agent ?? "opencode", + model: metadata.model, + stack: typeof lib.createScopeStack === "function" ? lib.createScopeStack() : undefined, + scope: undefined, + pendingTools: new Map(), + toolCalls: new Map(), + pendingLlm: undefined, + messages: new Map(), + lastUserMessage: undefined, + sequence: 0, + } + + const scope = runtimeCall(`scope-push:${sessionID}`, "failed to start OpenCode session scope", () => + withStack(session, () => + lib.pushScope( + "opencode.session", + lib.ScopeType?.Agent ?? 0, + null, + null, + { sessionID }, + inputSessionMetadata(sessionID, session), + { sessionID, source: "opencode" }, + ), + ), + ) + if (!scope) return undefined + session.scope = scope + sessions.set(sessionID, session) + return session + } + + /** + * Emit an OpenCode milestone as a NeMo Flow mark event. + */ + function emitMark(session, name, data, metadata = {}) { + if (!session?.scope) return + runtimeCall(`mark:${name}`, "failed to record OpenCode mark event", () => + withStack(session, () => + lib.event( + name, + session.scope, + toJsonSafe(data), + { + source: "opencode", + sessionID: session.id, + ...toJsonSafe(metadata), + }, + null, + ), + ), + ) + } + + /** + * Record message and part bus events for later LLM response reconstruction. + */ + function recordMessageEvent(session, event) { + const props = event?.properties ?? {} + session.sequence += 1 + + if (event.type === "message.updated" && props.info) { + const info = toJsonSafe(props.info) + const messageID = info?.id + if (!messageID) return + const message = session.messages.get(messageID) ?? { id: messageID, parts: new Map(), firstSequence: session.sequence } + message.info = info + message.role = info.role + message.agent = info.agent + message.model = info.model + message.tokens = info.tokens + message.cost = info.cost + session.messages.set(messageID, message) + return + } + + if (event.type === "message.part.updated" && props.part) { + const part = toJsonSafe(props.part) + const messageID = part?.messageID + const partID = part?.id + if (!messageID || !partID) return + const message = session.messages.get(messageID) ?? { id: messageID, parts: new Map(), firstSequence: session.sequence } + const existing = message.parts.get(partID) ?? { id: partID, firstSequence: session.sequence } + message.parts.set(partID, { + ...existing, + ...part, + firstSequence: existing.firstSequence, + updatedSequence: session.sequence, + }) + session.messages.set(messageID, message) + return + } + + if (event.type === "message.part.delta") { + const partID = props.partID + const messageID = props.messageID + if (!partID || !messageID || props.field !== "text") return + const message = session.messages.get(messageID) ?? { id: messageID, parts: new Map(), firstSequence: session.sequence } + const existing = message.parts.get(partID) ?? { + id: partID, + messageID, + sessionID: session.id, + type: "text", + firstSequence: session.sequence, + } + message.parts.set(partID, { + ...existing, + text: `${existing.text ?? ""}${props.delta ?? ""}`, + updatedSequence: session.sequence, + }) + session.messages.set(messageID, message) + return + } + + if (event.type === "message.part.removed") { + const partID = props.partID ?? props.part?.id + const messageID = props.messageID ?? props.part?.messageID + if (partID && messageID) session.messages.get(messageID)?.parts.delete(partID) + } + } + + /** + * Build a compact NeMo Flow LLM request from OpenCode chat params. + */ + function buildLlmRequest(session, input, output) { + const userMessage = session.lastUserMessage + const promptText = textFromParts(userMessage?.parts) + return toJsonSafe({ + headers: {}, + content: { + source: "opencode.chat.params", + agent: input.agent, + messageID: input.message?.id ?? userMessage?.message?.id ?? userMessage?.input?.messageID, + provider: compactProvider(input.provider), + model: compactModel(input.model), + params: compactParams(output), + messages: promptText + ? [ + { + role: userMessage?.message?.role ?? input.message?.role ?? "user", + content: promptText, + }, + ] + : [], + }, + }) + } + + /** + * Build a compact NeMo Flow LLM response from OpenCode message bus state. + */ + function buildLlmResponse(session, pending, reason) { + const messages = assistantMessagesSince(session, pending.sequenceStart) + const content = messages.flatMap((message) => messageParts(message, "text")).map((part) => part.text).join("\n") + const toolCalls = messages.flatMap((message) => toolCallsFromMessage(session, message, pending.sequenceStart)) + const usage = usageFromMessages(messages) + + return toJsonSafe({ + role: "assistant", + content: content || undefined, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + usage, + opencode: { + close_reason: reason, + agent: pending.agent, + messageID: pending.messageID, + }, + }) + } + + /** + * Start a semantic LLM span for a user-visible OpenCode model call. + */ + function startLlm(session, input, output) { + if (typeof lib.llmCall !== "function" || shouldSkipLlm(input)) return + closeActiveLlm(session, "next_llm_request") + + const model = modelName(input.model) ?? session.model + const metadata = toJsonSafe({ + source: "opencode.chat.params", + sessionID: session.id, + agent: input.agent, + messageID: input.message?.id, + model, + }) + const handle = runtimeCall("llm-start", "failed to start OpenCode LLM span", () => + withStack(session, () => + lib.llmCall( + input.provider?.id ?? input.model?.providerID ?? "opencode", + buildLlmRequest(session, input, output), + session.scope, + null, + null, + metadata, + model ?? null, + null, + ), + ), + ) + if (!handle) return + session.pendingLlm = { + handle, + agent: input.agent, + messageID: input.message?.id, + model, + sequenceStart: session.sequence + 1, + metadata, + } + } + + /** + * Finish the active LLM span before a tool starts, another LLM starts, or the session closes. + */ + function closeActiveLlm(session, reason) { + const pending = session?.pendingLlm + if (!pending || typeof lib.llmCallEnd !== "function") return + const response = buildLlmResponse(session, pending, reason) + runtimeCall("llm-end", "failed to close OpenCode LLM span", () => + withStack(session, () => lib.llmCallEnd(pending.handle, response, null, pending.metadata, null)), + ) + session.pendingLlm = undefined + } + + /** + * Extract text from OpenCode message parts. + */ + function textFromParts(parts) { + if (!Array.isArray(parts)) return "" + return parts + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n") + } + + function assistantMessagesSince(session, sequenceStart) { + return [...session.messages.values()] + .filter((message) => message.role === "assistant") + .filter((message) => [...message.parts.values()].some((part) => part.firstSequence >= sequenceStart)) + } + + function messageParts(message, type) { + return [...message.parts.values()].filter((part) => part.type === type && typeof part.text === "string") + } + + function toolCallsFromMessage(session, message, sequenceStart) { + return [...message.parts.values()] + .filter((part) => part.type === "tool" && part.firstSequence >= sequenceStart) + .filter((part) => part.callID || part.tool) + .map((part) => { + const observed = part.callID ? session.toolCalls.get(part.callID) : undefined + return { + id: part.callID ?? "", + type: "function", + function: { + name: observed?.tool ?? part.tool ?? "", + arguments: JSON.stringify(observed?.args ?? part.state?.input ?? {}), + }, + } + }) + } + + function usageFromMessages(messages) { + const message = [...messages].reverse().find((item) => item.tokens || item.cost !== undefined) + if (!message) return undefined + const input = Number.isFinite(message.tokens?.input) ? message.tokens.input : undefined + const output = Number.isFinite(message.tokens?.output) ? message.tokens.output : undefined + const cacheRead = Number.isFinite(message.tokens?.cache?.read) ? message.tokens.cache.read : 0 + const cacheWrite = Number.isFinite(message.tokens?.cache?.write) ? message.tokens.cache.write : 0 + return toJsonSafe({ + input_tokens: input, + output_tokens: output, + cached_tokens: cacheRead + cacheWrite, + reasoning_tokens: message.tokens?.reasoning, + cost_usd: message.cost, + }) + } + + /** + * Close an OpenCode session scope so generic observability plugins can flush. + */ + function flushSession(sessionID, reason) { + const session = sessions.get(sessionID) + if (!session) return + recentFlushes.set(sessionID, Date.now()) + pruneRecentFlushes() + closeActiveLlm(session, reason) + for (const [key, tool] of session.pendingTools) { + runtimeCall(`tool-close:${key}`, "failed to close pending OpenCode tool span", () => + withStack(session, () => + lib.toolCallEnd( + tool.handle, + { status: "unknown", reason: "session flushed before tool.execute.after" }, + null, + { source: "opencode", sessionID, callID: tool.callID }, + ), + ), + ) + } + session.pendingTools.clear() + + if (session.scope) { + runtimeCall(`scope-pop:${sessionID}`, "failed to close OpenCode session scope", () => + withStack(session, () => lib.popScope(session.scope, { sessionID, reason }, null)), + ) + } + + sessions.delete(sessionID) + } + + return { + /** + * Record OpenCode configuration context for diagnostics. + */ + async recordConfig(config) { + if (closed) return + await logger.info("observed OpenCode config", { + model: config?.model, + agents: config?.agent ? Object.keys(config.agent) : undefined, + }) + }, + + /** + * Observe OpenCode bus events for response reconstruction and session closure. + */ + async recordEvent(event) { + if (closed || !OBSERVED_EVENTS.has(event?.type)) return + const sessionID = eventSessionID(event) + if (!sessionID) return + if (shouldFlushEvent(event) && wasRecentlyFlushed(sessionID)) return + const props = event.properties ?? {} + const session = ensureSession(sessionID, { + agent: typeof props.info?.agent === "string" ? props.info.agent : undefined, + model: modelName(props.info?.model), + }) + if (!session) return + + if (event.type.startsWith("message.")) { + recordMessageEvent(session, event) + } else if (event.type === "session.error") { + emitMark( + session, + "opencode.session.error", + { + id: event.id, + error: props.error, + }, + eventMetadata(session, { eventType: event.type }), + ) + } + + if (shouldFlushEvent(event)) { + flushSession(sessionID, event.type) + } + }, + + /** + * Record user message metadata for the current OpenCode turn. + */ + async recordChatMessage(input, output) { + if (closed) return + const session = ensureSession(input.sessionID, { + agent: agentName(input, agentName(output)), + model: modelName(input.model ?? output?.message?.model), + }) + if (!session) return + session.lastUserMessage = toJsonSafe({ + input, + message: output?.message, + parts: output?.parts, + }) + }, + + /** + * Record model and provider metadata near the LLM request boundary. + */ + async recordChatParams(input, output) { + if (closed) return + const session = ensureSession(input.sessionID, { + agent: agentName(input), + model: modelName(input.model), + }) + if (!session) return + startLlm(session, input, output) + }, + + /** + * Start a NeMo Flow tool span for an OpenCode tool call. + */ + async recordToolBefore(input, output) { + if (closed) return + const session = ensureSession(input.sessionID) + if (!session) return + const args = toJsonSafe(output?.args) + if (input.callID) { + session.toolCalls.set(input.callID, { tool: input.tool, args }) + } + closeActiveLlm(session, "tool_start") + const handle = runtimeCall(`tool-start:${input.callID ?? input.tool}`, "failed to start OpenCode tool span", () => + withStack(session, () => + lib.toolCall( + input.tool, + args, + session.scope, + null, + { sessionID: input.sessionID, callID: input.callID }, + { source: "opencode", sessionID: input.sessionID, callID: input.callID }, + input.callID, + null, + ), + ), + ) + if (!handle) return + session.pendingTools.set(input.callID, { handle, callID: input.callID, tool: input.tool, args }) + }, + + /** + * Finish a successful NeMo Flow tool span for an OpenCode tool call. + */ + async recordToolAfter(input, output) { + if (closed) return + const session = ensureSession(input.sessionID) + if (!session) return + let pending = session.pendingTools.get(input.callID) + if (!pending) { + const args = toJsonSafe(input.args) + if (input.callID) { + session.toolCalls.set(input.callID, { tool: input.tool, args }) + } + const handle = runtimeCall(`tool-start:${input.callID ?? input.tool}`, "failed to start OpenCode tool span", () => + withStack(session, () => + lib.toolCall( + input.tool, + args, + session.scope, + null, + { sessionID: input.sessionID, callID: input.callID }, + { source: "opencode", sessionID: input.sessionID, callID: input.callID, recovered: true }, + input.callID, + null, + ), + ), + ) + if (!handle) return + pending = { handle, callID: input.callID, tool: input.tool, args } + } + runtimeCall(`tool-end:${input.callID ?? input.tool}`, "failed to close OpenCode tool span", () => + withStack(session, () => + lib.toolCallEnd( + pending.handle, + toJsonSafe({ + title: output?.title, + output: output?.output, + metadata: output?.metadata, + }), + null, + { source: "opencode", sessionID: input.sessionID, callID: input.callID }, + null, + ), + ), + ) + session.pendingTools.delete(input.callID) + }, + + /** + * Flush open sessions and unregister exporters during plugin shutdown. + */ + async close() { + if (closed) return + closed = true + for (const sessionID of [...sessions.keys()]) { + flushSession(sessionID, "plugin-close") + } + try { + pluginHost.clear() + } catch (error) { + await logger.warnOnce("plugin-host-clear", "failed to clear NeMo Flow plugin host", error) + } + }, + } +} + +/** + * Load the default NeMo Flow Node.js runtime and plugin host. + */ +async function loadDefaultModules() { + if (process.env.NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE === "1") { + throw new Error("forced initialization failure") + } + const [runtimeModule, pluginHostModule] = await Promise.all([ + import("nemo-flow-node"), + import("nemo-flow-node/plugin"), + ]) + return { + lib: runtimeModule.default ?? runtimeModule, + pluginHost: pluginHostModule.default ?? pluginHostModule, + } +} + +/** + * Register process cleanup for OpenCode runs without an explicit close hook. + */ +function registerBeforeExitCleanup(close, logger) { + let started = false + const listener = () => { + if (started) return + started = true + void close().catch((error) => { + void logger.warnOnce("before-exit-cleanup", "failed to clean up NeMo Flow OpenCode plugin", error) + }) + } + process.on("beforeExit", listener) + process.on("exit", listener) + return () => { + process.removeListener("beforeExit", listener) + process.removeListener("exit", listener) + } +} + +/** + * Create the OpenCode server plugin entrypoint. + */ +export function createServerPlugin({ + loadModules = loadDefaultModules, + registerCleanup = registerBeforeExitCleanup, +} = {}) { + return async function server(input, options) { + let normalized + let logger + + try { + normalized = normalizeOptions(input, options) + logger = createLogger(normalized.logPath) + } catch (error) { + const baseDir = input?.directory ?? process.cwd() + logger = createLogger(resolveOutputPath(baseDir, options?.logPath ?? "./.nemoflow/opencode-plugin.log")) + await logger.warnOnce("config-invalid", "NeMo Flow OpenCode plugin config invalid; running pass-through", error) + return {} + } + + if (!normalized.enabled) { + await logger.warnOnce("disabled", "NeMo Flow OpenCode plugin disabled by configuration") + return {} + } + + let adapter + try { + const { lib, pluginHost } = await loadModules() + await initializePluginHost(pluginHost, normalized.plugins, logger) + adapter = createNemoFlowAdapter(lib, pluginHost, logger) + let unregisterCleanup + unregisterCleanup = registerCleanup(async () => { + unregisterCleanup?.() + await adapter.close() + }, logger) + await logger.info("initialized NeMo Flow OpenCode plugin", { + plugins: pluginConfigSummary(normalized.plugins), + }) + } catch (error) { + await logger.warnOnce( + "init-failed", + `NeMo Flow runtime unavailable or misconfigured; OpenCode plugin is running pass-through: ${toMessage(error)}`, + error, + ) + return {} + } + + return { + config: async (config) => adapter.recordConfig(config), + event: async ({ event }) => adapter.recordEvent(event), + "chat.message": async (hookInput, output) => adapter.recordChatMessage(hookInput, output), + "chat.params": async (hookInput, output) => adapter.recordChatParams(hookInput, output), + "tool.execute.before": async (hookInput, output) => adapter.recordToolBefore(hookInput, output), + "tool.execute.after": async (hookInput, output) => adapter.recordToolAfter(hookInput, output), + } + } +} + +export const server = createServerPlugin() + +export default { + id: PLUGIN_ID, + server, +} diff --git a/integrations/opencode/test/server.test.mjs b/integrations/opencode/test/server.test.mjs new file mode 100644 index 0000000..8766901 --- /dev/null +++ b/integrations/opencode/test/server.test.mjs @@ -0,0 +1,730 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" + +import { createServerPlugin } from "../server.js" + +function createFakeRuntime() { + const events = [] + let counter = 0 + let activeStack = { id: "current" } + + return { + events, + ScopeType: { Agent: 0 }, + createScopeStack() { + return { id: `stack-${++counter}` } + }, + currentScopeStack() { + return activeStack + }, + setThreadScopeStack(stack) { + activeStack = stack + }, + pushScope(name, scopeType, _parent, attributes, data, metadata, input) { + const handle = { + uuid: randomUUID(), + name, + scopeType, + attributes, + } + events.push({ + kind: "scope", + category: "agent", + scope_category: "start", + stack: activeStack.id, + uuid: handle.uuid, + name, + data, + metadata, + input, + }) + return handle + }, + popScope(handle, output) { + events.push({ + kind: "scope", + category: "agent", + scope_category: "end", + stack: activeStack.id, + uuid: handle.uuid, + name: handle.name, + data: output, + }) + }, + event(name, handle, data, metadata) { + events.push({ + kind: "mark", + stack: activeStack.id, + uuid: randomUUID(), + parent_uuid: handle?.uuid, + name, + data, + metadata, + }) + }, + toolCall(name, args, handle, _attributes, data, metadata, toolCallID) { + const tool = { + uuid: randomUUID(), + name, + parentUuid: handle?.uuid, + toolCallID, + } + events.push({ + kind: "scope", + category: "tool", + scope_category: "start", + stack: activeStack.id, + uuid: tool.uuid, + name, + parent_uuid: handle?.uuid, + data: args, + metadata, + }) + return tool + }, + toolCallEnd(handle, result, data, metadata) { + events.push({ + kind: "scope", + category: "tool", + scope_category: "end", + stack: activeStack.id, + uuid: handle.uuid, + name: handle.name, + data: result ?? data, + metadata, + }) + }, + llmCall(name, request, handle, _attributes, _data, metadata, modelName) { + const llm = { + uuid: randomUUID(), + name, + parentUuid: handle?.uuid, + modelName, + } + events.push({ + kind: "scope", + category: "llm", + scope_category: "start", + stack: activeStack.id, + uuid: llm.uuid, + name, + parent_uuid: handle?.uuid, + data: request, + metadata, + modelName, + }) + return llm + }, + llmCallEnd(handle, response, data, metadata) { + events.push({ + kind: "scope", + category: "llm", + scope_category: "end", + stack: activeStack.id, + uuid: handle.uuid, + name: handle.name, + data: response ?? data, + metadata, + modelName: handle.modelName, + }) + }, + } +} + +function createFakePluginHost({ validateDiagnostics = [], initializeDiagnostics = [], onInitialize } = {}) { + return { + validateCalls: [], + initializeCalls: [], + clearCalls: 0, + validate(config) { + this.validateCalls.push(config) + return { diagnostics: validateDiagnostics } + }, + async initialize(config) { + await onInitialize?.(config) + this.initializeCalls.push(config) + return { diagnostics: initializeDiagnostics } + }, + clear() { + this.clearCalls += 1 + }, + } +} + +function createHarness(params = {}) { + const runtime = params.runtime ?? createFakeRuntime() + const pluginHost = params.pluginHost ?? createFakePluginHost(params) + let cleanup + const server = createServerPlugin({ + loadModules: async () => { + if (params.loadError) throw params.loadError + return { lib: runtime, pluginHost } + }, + registerCleanup(close) { + cleanup = close + return () => { + if (cleanup === close) cleanup = undefined + } + }, + }) + + return { + runtime, + pluginHost, + server, + cleanup: async () => cleanup?.(), + } +} + +function pluginConfig() { + return { + version: 1, + components: [ + { + kind: "observability", + enabled: true, + config: { + version: 1, + atof: { + enabled: true, + output_directory: "./.nemoflow", + filename: "opencode.atof.jsonl", + }, + atif: { + enabled: true, + agent_name: "opencode", + output_directory: "./.nemoflow", + filename_template: "opencode-{session_id}.atif.json", + }, + }, + }, + ], + } +} + +async function makeTempDir() { + return fs.mkdtemp(path.join(os.tmpdir(), "nemo-flow-opencode-")) +} + +async function waitForLogMatch(logPath, pattern) { + let log = "" + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + log = await fs.readFile(logPath, "utf8") + if (pattern.test(log)) return log + } catch (error) { + if (error.code !== "ENOENT") throw error + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } + assert.match(log, pattern) + return log +} + +async function statIsDirectory(targetPath) { + try { + return (await fs.stat(targetPath)).isDirectory() + } catch { + return false + } +} + +function eventNames(events) { + return events.map((event) => event.name).filter(Boolean) +} + +function assertSessionStackUsed(runtime) { + const sessionStart = runtime.events.find( + (event) => event.category === "agent" && event.scope_category === "start", + ) + assert.ok(sessionStart?.stack?.startsWith("stack-")) + for (const event of runtime.events) { + assert.equal(event.stack, sessionStart.stack, `${event.name ?? event.category} should use session scope stack`) + } + assert.equal(runtime.currentScopeStack().id, "current") +} + +describe("NeMo Flow OpenCode plugin", () => { + it("initializes generic plugin config and records OpenCode hooks until idle", async () => { + const dir = await makeTempDir() + const { runtime, pluginHost, server } = createHarness() + const hooks = await server( + { directory: dir }, + { + enabled: true, + logPath: "./.nemoflow/opencode-plugin.log", + plugins: pluginConfig(), + }, + ) + + const expectedOutputDirectory = path.join(dir, ".nemoflow") + assert.equal(pluginHost.validateCalls.length, 1) + assert.equal(pluginHost.initializeCalls.length, 1) + assert.ok(await statIsDirectory(expectedOutputDirectory)) + assert.equal(pluginHost.validateCalls[0].components[0].config.atof.output_directory, expectedOutputDirectory) + assert.equal(pluginHost.validateCalls[0].components[0].config.atif.output_directory, expectedOutputDirectory) + assert.deepEqual(pluginHost.initializeCalls[0], pluginHost.validateCalls[0]) + + await hooks.config?.({ model: "test-provider/test-model", agent: { build: {} } }) + await hooks["chat.message"]?.( + { + sessionID: "ses_1", + agent: "build", + model: { providerID: "test-provider", modelID: "test-model" }, + messageID: "msg_1", + }, + { + message: { id: "msg_1", role: "user", agent: "build" }, + parts: [{ id: "part_1", type: "text", text: "hello" }], + }, + ) + await hooks["chat.params"]?.( + { + sessionID: "ses_1", + agent: "build", + model: { providerID: "test-provider", id: "test-model" }, + provider: { source: "config", options: {} }, + message: { id: "msg_1" }, + }, + { temperature: 0, topP: 1, topK: 0, options: {} }, + ) + await hooks.event?.({ + event: { + id: "evt_assistant", + type: "message.updated", + properties: { + sessionID: "ses_1", + info: { id: "msg_assistant_1", role: "assistant", agent: "build", sessionID: "ses_1" }, + }, + }, + }) + await hooks.event?.({ + event: { + id: "evt_tool_part", + type: "message.part.updated", + properties: { + sessionID: "ses_1", + part: { + id: "part_tool_1", + messageID: "msg_assistant_1", + sessionID: "ses_1", + type: "tool", + tool: "write", + callID: "call_1", + state: { input: {}, status: "pending" }, + }, + }, + }, + }) + await hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_1", callID: "call_1" }, + { args: { path: "phase1-demo.txt" } }, + ) + await hooks["tool.execute.after"]?.( + { tool: "write", sessionID: "ses_1", callID: "call_1", args: { path: "phase1-demo.txt" } }, + { title: "Wrote file", output: "done", metadata: { ok: true } }, + ) + await hooks.event?.({ + event: { + id: "evt_1", + type: "session.status", + properties: { sessionID: "ses_1", status: { type: "idle" } }, + }, + }) + await hooks.event?.({ + event: { + id: "evt_1_duplicate", + type: "session.status", + properties: { sessionID: "ses_1", status: { type: "idle" } }, + }, + }) + + const names = eventNames(runtime.events) + assert.equal(names.includes("opencode.chat.message"), false) + assert.equal(names.includes("opencode.llm.request"), false) + assert.equal(names.filter((name) => name === "opencode.session.flush").length, 0) + assert.equal(runtime.events.filter((event) => event.category === "llm" && event.scope_category === "start").length, 1) + assert.equal(runtime.events.filter((event) => event.category === "llm" && event.scope_category === "end").length, 1) + const llmEnd = runtime.events.find((event) => event.category === "llm" && event.scope_category === "end") + assert.deepEqual(JSON.parse(llmEnd.data.tool_calls[0].function.arguments), { path: "phase1-demo.txt" }) + assert.equal(runtime.events.filter((event) => event.category === "tool" && event.scope_category === "start").length, 1) + assert.equal(runtime.events.filter((event) => event.category === "tool" && event.scope_category === "end").length, 1) + assert.equal(runtime.events.filter((event) => event.category === "agent" && event.scope_category === "end").length, 1) + assertSessionStackUsed(runtime) + }) + + it("creates observability output directories before plugin host initialization", async () => { + const dir = await makeTempDir() + const expectedOutputDirectory = path.join(dir, ".nemoflow") + const { server } = createHarness({ + async onInitialize() { + assert.ok(await statIsDirectory(expectedOutputDirectory)) + }, + }) + + await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) + }) + + it("registers default observability output when plugins are omitted", async () => { + const dir = await makeTempDir() + const { pluginHost, server } = createHarness() + const hooks = await server({ directory: dir }) + + assert.equal(typeof hooks.event, "function") + assert.equal(pluginHost.validateCalls.length, 1) + assert.equal(pluginHost.initializeCalls.length, 1) + const component = pluginHost.validateCalls[0].components[0] + assert.equal(component.kind, "observability") + assert.equal(component.config.atof.enabled, true) + assert.equal(component.config.atof.output_directory, path.join(dir, ".nemoflow")) + assert.equal(component.config.atof.filename, "opencode.atof.jsonl") + assert.equal(component.config.atif.enabled, true) + assert.equal(component.config.atif.output_directory, path.join(dir, ".nemoflow")) + assert.equal(component.config.atif.filename_template, "opencode-{session_id}.atif.json") + }) + + it("keeps normal bus events internal while recording session errors", async () => { + const dir = await makeTempDir() + const { runtime, server } = createHarness() + const hooks = await server( + { directory: dir }, + { + enabled: true, + logPath: "./.nemoflow/opencode-plugin.log", + plugins: pluginConfig(), + }, + ) + + const model = { providerID: "anthropic", modelID: "claude-test" } + await hooks.event?.({ + event: { + id: "evt_created", + type: "session.created", + properties: { + sessionID: "ses_2", + info: { id: "ses_2", agent: "review", model }, + apiKey: "secret", + outputTokens: 8, + }, + }, + }) + await hooks.event?.({ + event: { + id: "evt_updated", + type: "session.updated", + properties: { sessionID: "ses_2", info: { id: "ses_2", agent: "review", model } }, + }, + }) + await hooks["chat.message"]?.( + { + sessionID: "ses_2", + agent: "review", + model, + messageID: "msg_2", + apiKey: "secret", + outputTokens: 3, + }, + { + message: { id: "msg_2", role: "user", agent: "review" }, + parts: [{ id: "part_2", type: "text", text: "summarize this" }], + }, + ) + await hooks["chat.params"]?.( + { + sessionID: "ses_2", + agent: "review", + model, + provider: { source: "config", options: { apiKey: "secret" } }, + message: { id: "msg_2" }, + }, + { maxOutputTokens: 64, temperature: 0 }, + ) + await hooks.event?.({ + event: { + id: "evt_error", + type: "session.error", + properties: { sessionID: "ses_2", error: { message: "provider failed", apiKey: "secret" } }, + }, + }) + await hooks.event?.({ + event: { + id: "evt_deleted", + type: "session.deleted", + properties: { sessionID: "ses_2" }, + }, + }) + + const names = eventNames(runtime.events) + const error = runtime.events.find((event) => event.name === "opencode.session.error") + const serialized = JSON.stringify(runtime.events) + + assert.equal(names.includes("opencode.session.created"), false) + assert.equal(names.includes("opencode.session.updated"), false) + assert.ok(names.includes("opencode.session.error")) + assert.equal(names.includes("opencode.session.deleted"), false) + assert.equal(error.metadata.sessionID, "ses_2") + assert.equal(error.metadata.agent, "review") + assert.equal(error.metadata.model, "anthropic/claude-test") + assert.match(serialized, /"apiKey":"\[Redacted\]"/) + assert.equal(names.includes("opencode.session.flush"), false) + }) + + it("ignores hooks without an OpenCode session identifier", async () => { + const dir = await makeTempDir() + const { runtime, server } = createHarness() + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) + + await assert.doesNotReject(async () => { + await hooks["chat.message"]?.({ agent: "build" }, { message: { id: "msg_missing" } }) + await hooks["chat.params"]?.({ agent: "build", message: { id: "msg_missing" } }, {}) + await hooks["tool.execute.before"]?.({ tool: "read", callID: "call_missing" }, { args: { path: "x" } }) + await hooks["tool.execute.after"]?.({ tool: "read", callID: "call_missing" }, { output: "x" }) + }) + assert.equal(runtime.events.length, 0) + }) + + it("stays pass-through when disabled", async () => { + const dir = await makeTempDir() + const { pluginHost, server } = createHarness() + const hooks = await server({ directory: dir }, { enabled: false, plugins: pluginConfig() }) + + assert.deepEqual(hooks, {}) + assert.equal(pluginHost.validateCalls.length, 0) + assert.equal(pluginHost.initializeCalls.length, 0) + }) + + it("logs once and disables hooks when the runtime cannot load", async () => { + const dir = await makeTempDir() + const { server } = createHarness({ loadError: new Error("missing native binding") }) + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) + + assert.deepEqual(hooks, {}) + const log = await fs.readFile(path.join(dir, ".nemoflow", "opencode-plugin.log"), "utf8") + assert.match(log, /pass-through/) + }) + + it("logs and disables hooks when removed exporter options are used", async () => { + const dir = await makeTempDir() + const { pluginHost, server } = createHarness() + const hooks = await server( + { directory: dir }, + { + enabled: true, + atofPath: "./.nemoflow/opencode.atof.jsonl", + logPath: "./.nemoflow/opencode-plugin.log", + }, + ) + + assert.deepEqual(hooks, {}) + assert.equal(pluginHost.validateCalls.length, 0) + const log = await fs.readFile(path.join(dir, ".nemoflow", "opencode-plugin.log"), "utf8") + assert.match(log, /config invalid/) + assert.match(log, /atofPath was removed/) + }) + + it("logs and disables hooks when generic plugin validation fails", async () => { + const dir = await makeTempDir() + const { pluginHost, server } = createHarness({ + validateDiagnostics: [ + { + level: "error", + code: "plugin.unknown_component", + component: "missing", + message: "unknown component", + }, + ], + }) + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) + + assert.deepEqual(hooks, {}) + assert.equal(pluginHost.validateCalls.length, 1) + assert.equal(pluginHost.initializeCalls.length, 0) + const log = await fs.readFile(path.join(dir, ".nemoflow", "opencode-plugin.log"), "utf8") + assert.match(log, /plugin.unknown_component/) + assert.match(log, /plugin host config validation failed/) + }) + + it("redacts console diagnostics when file logging is disabled", async () => { + const dir = await makeTempDir() + const warnings = [] + const originalWarn = console.warn + console.warn = (...args) => { + warnings.push(args) + } + + try { + const { server } = createHarness({ + validateDiagnostics: [ + { + level: "error", + code: "plugin.invalid", + message: "invalid config", + apiKey: "secret-value", + }, + ], + }) + const hooks = await server({ directory: dir }, { enabled: true, logPath: "", plugins: pluginConfig() }) + + assert.deepEqual(hooks, {}) + } finally { + console.warn = originalWarn + } + + assert.ok(warnings.length > 0) + assert.equal(warnings[0][1].apiKey, "[Redacted]") + assert.equal(JSON.stringify(warnings).includes("secret-value"), false) + }) + + it("keeps hooks non-fatal when NeMo Flow runtime calls throw", async () => { + async function assertRuntimeFailure(mutator, runHook, pattern) { + const dir = await makeTempDir() + const runtime = createFakeRuntime() + mutator(runtime) + const { server } = createHarness({ runtime }) + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) + + await assert.doesNotReject(async () => { + await runHook(hooks) + }) + await waitForLogMatch(path.join(dir, ".nemoflow", "opencode-plugin.log"), pattern) + } + + await assertRuntimeFailure( + (runtime) => { + runtime.pushScope = () => { + throw new Error("scope failed") + } + }, + (hooks) => + hooks["chat.message"]?.( + { sessionID: "ses_scope", agent: "build" }, + { message: { id: "msg_scope", role: "user" }, parts: [] }, + ), + /failed to start OpenCode session scope/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.event = () => { + throw new Error("event failed") + } + }, + (hooks) => + hooks.event?.({ + event: { + id: "evt_error", + type: "session.error", + properties: { sessionID: "ses_event", error: { message: "provider failed" } }, + }, + }), + /failed to record OpenCode mark event/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.llmCall = () => { + throw new Error("llm failed") + } + }, + (hooks) => + hooks["chat.params"]?.( + { sessionID: "ses_llm", agent: "build", model: { providerID: "test-provider", id: "test-model" } }, + {}, + ), + /failed to start OpenCode LLM span/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.llmCallEnd = () => { + throw new Error("llm end failed") + } + }, + async (hooks) => { + await hooks["chat.params"]?.( + { sessionID: "ses_llm_end", agent: "build", model: { providerID: "test-provider", id: "test-model" } }, + {}, + ) + await hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_llm_end", callID: "call_llm_end" }, + { args: { path: "x" } }, + ) + }, + /failed to close OpenCode LLM span/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.toolCall = () => { + throw new Error("tool failed") + } + }, + (hooks) => + hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_tool", callID: "call_tool" }, + { args: { path: "x" } }, + ), + /failed to start OpenCode tool span/, + ) + + await assertRuntimeFailure( + (runtime) => { + runtime.toolCallEnd = () => { + throw new Error("tool end failed") + } + }, + async (hooks) => { + await hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_tool_end", callID: "call_tool_end" }, + { args: { path: "x" } }, + ) + await hooks["tool.execute.after"]?.( + { tool: "write", sessionID: "ses_tool_end", callID: "call_tool_end" }, + { output: "done" }, + ) + }, + /failed to close OpenCode tool span/, + ) + }) + + it("flushes open sessions and clears the plugin host during cleanup", async () => { + const dir = await makeTempDir() + const { runtime, pluginHost, server, cleanup } = createHarness() + const hooks = await server({ directory: dir }, { enabled: true, plugins: pluginConfig() }) + + await hooks["chat.message"]?.( + { + sessionID: "ses_3", + agent: "build", + model: { providerID: "test-provider", modelID: "test-model" }, + }, + { + message: { id: "msg_3", role: "user", agent: "build" }, + parts: [], + }, + ) + await hooks["tool.execute.before"]?.( + { tool: "write", sessionID: "ses_3", callID: "call_3" }, + { args: { path: "left-open.txt" } }, + ) + + await cleanup() + + const names = eventNames(runtime.events) + const pendingToolEnd = runtime.events.find( + (event) => event.category === "tool" && event.scope_category === "end" && event.metadata.callID === "call_3", + ) + assert.equal(pluginHost.clearCalls, 1) + assert.equal(names.includes("opencode.session.flush"), false) + assert.equal(pendingToolEnd.data.status, "unknown") + assert.equal(runtime.events.filter((event) => event.category === "agent" && event.scope_category === "end").length, 1) + assertSessionStackUsed(runtime) + }) +}) diff --git a/package-lock.json b/package-lock.json index a65b4bc..df6a8fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "workspaces": [ "crates/node", "crates/wasm", - "integrations/openclaw" + "integrations/openclaw", + "integrations/opencode" ], "devDependencies": { "typescript": "^5.9.3" @@ -848,6 +849,61 @@ "openclaw": ">=2026.5.12" } }, + "integrations/opencode": { + "name": "nemo-flow-opencode", + "version": "0.2.0", + "license": "Apache-2.0", + "dependencies": { + "nemo-flow-node": ">0.1.0 <1.0.0" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.40" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.14.40" + } + }, + "integrations/opencode/node_modules/@opencode-ai/plugin": { + "version": "1.14.51", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.51.tgz", + "integrity": "sha512-2110+2U+SdD90lNYHOmMx5Nkp2d9hitiZmSjsWm4H5BlzmiObJRzNV2HdYL1wPnScgglAYiwgrne08a3Z+XJNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.14.51", + "effect": "4.0.0-beta.65", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.2.10", + "@opentui/keymap": ">=0.2.10", + "@opentui/solid": ">=0.2.10" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "integrations/opencode/node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@agentclientprotocol/sdk": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.21.0.tgz", @@ -2249,6 +2305,90 @@ "node": ">=14.0.0" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -2262,6 +2402,14 @@ "license": "MIT", "peer": true }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.51", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.51.tgz", + "integrity": "sha512-qaoVzYvUDm2rXohrC2GjMlMaThHUKxxDZlBMYt2U6gyPaZ/kLLUCUPOff3CSzLoX9Mx7cBaqpV9F/Evm5PIDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" "node_modules/@openclaw/fs-safe": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.2.4.tgz", @@ -2504,6 +2652,20 @@ "node": ">=14.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@telegraf/types": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz", + "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==", + "license": "MIT", + "peer": true + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -3282,6 +3444,17 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -3403,6 +3576,39 @@ "license": "MIT", "peer": true }, + "node_modules/effect": { + "version": "4.0.0-beta.65", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.65.tgz", + "integrity": "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/effect/node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3693,6 +3899,29 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3858,6 +4087,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -4446,6 +4682,16 @@ "license": "ISC", "peer": true }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -4602,6 +4848,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "dev": true, + "license": "Apache-2.0" "node_modules/koffi": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", @@ -4870,6 +5122,46 @@ "license": "MIT", "peer": true }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "dev": true, + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -4900,6 +5192,10 @@ "resolved": "integrations/openclaw", "link": true }, + "node_modules/nemo-flow-opencode": { + "resolved": "integrations/opencode", + "link": true + }, "node_modules/nemo-flow-wasm": { "resolved": "crates/wasm", "link": true @@ -5084,6 +5380,22 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -5635,6 +5947,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -6474,6 +6803,16 @@ "url": "https://github.com/sponsors/vincentkoc" } }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/package.json b/package.json index c9b6163..89f501d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "workspaces": [ "crates/node", "crates/wasm", - "integrations/openclaw" + "integrations/openclaw", + "integrations/opencode" ], "devDependencies": { "typescript": "^5.9.3"