diff --git a/.gitignore b/.gitignore index eba83c4f..e8cc9d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,5 @@ CHANGELOG.md crates/wasm/coverage/ .scannerwork/ + +**/.nemoflow/ \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 8e05fcd8..6b53133c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -165,6 +165,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 a12a1b77..cd8361bf 100644 --- a/docs/integrate-frameworks/about.md +++ b/docs/integrate-frameworks/about.md @@ -41,6 +41,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 00000000..107d9c2c --- /dev/null +++ b/docs/integrate-frameworks/opencode.md @@ -0,0 +1,271 @@ + + +# OpenCode Plugin + +NeMo Flow integrates with OpenCode through a standalone 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, +messages, LLM request metadata, successful tool calls, and session errors. + +## What You Build + +You will configure stock OpenCode to load the NeMo Flow plugin in the +background. After that, you can use OpenCode normally through the interactive +interface or `opencode run`. The plugin observes OpenCode hooks and writes +NeMo Flow ATOF and ATIF files under the OpenCode project directory. + +```{mermaid} +flowchart LR + User[Developer] + OpenCode[Stock OpenCode] + Plugin[NeMo Flow
OpenCode plugin] + Runtime[NeMo Flow
Node.js binding] + ATOF[(ATOF JSONL)] + ATIF[(ATIF JSON)] + + User -->|uses normally| OpenCode + OpenCode -->|public plugin hooks| Plugin + Plugin -->|scopes, marks,
tool lifecycle| Runtime + Runtime -->|append events| ATOF + Runtime -->|export on idle
or deleted session| ATIF + + class User blue-lightest; + class OpenCode green-lightest; + class Plugin purple-lightest; + class Runtime green-light; + class ATOF yellow-lightest; + class ATIF yellow-lightest; +``` + +The plugin is passive. It records observability output but does not rewrite +prompts, tool arguments, model requests, or OpenCode execution behavior. + +## Install + +Build the NeMo Flow Node.js binding before loading the plugin from a source +checkout. `crates/node` is under the NeMo Flow repository root: + +```bash +export NEMO_FLOW_REPO=/absolute/path/to/NeMo-Flow +cd "$NEMO_FLOW_REPO/crates/node" +npm install +npm run build +``` + +For local development, install or use stock OpenCode and point `opencode.json` +at the plugin directory: + +```bash +npm install -g opencode-ai@latest +opencode --version +``` + +When the plugin package is published, use +`@nvidia/nemoflow-opencode-plugin` in the OpenCode config instead of the local +file URL. + +## Configure OpenCode + +Create or update `opencode.json` in the OpenCode project directory: + +```json +{ + "plugin": [ + [ + "nemo-flow-opencode", + { + "enabled": true, + "atofPath": "./.nemoflow/opencode.atof.jsonl", + "atifPath": "./.nemoflow/opencode.atif.json", + "logPath": "./.nemoflow/opencode-plugin.log" + } + ] + ] +} +``` + +The paths are resolved relative to the OpenCode project directory. If +`nemo-flow-node` is missing or cannot initialize, the plugin logs one warning +and returns no hooks, so OpenCode continues in pass-through mode. + +## Run the Demo + +Use this demo when you want to show the integration end to end. It uses a +source checkout plugin path because the package is not published yet. + +```bash +export NEMO_FLOW_REPO=/absolute/path/to/NeMo-Flow +export NEMO_FLOW_DEMO_DIR="$NEMO_FLOW_REPO/tmp/opencode-nemoflow-demo" + +rm -rf "$NEMO_FLOW_DEMO_DIR" +mkdir -p "$NEMO_FLOW_DEMO_DIR/.nemoflow" +cd "$NEMO_FLOW_DEMO_DIR" + +cat > opencode.json <>OC: Start OpenCode in a project + OC->>Plug: config(input) + Plug->>Files: Write plugin diagnostics + Dev->>OC: Send a prompt or run a task + OC->>Plug: chat.message and chat.params + Plug->>NF: Emit session and LLM request marks + NF->>Files: Append ATOF JSONL + OC->>Plug: tool.execute.before and after + Plug->>NF: Open and close tool lifecycle records + NF->>Files: Append ATOF JSONL + OC->>Plug: session.status idle or session.deleted + Plug->>NF: Flush session trajectory + NF->>Files: Write ATIF JSON +``` + +## Pass-Through Checks + +The plugin should not change OpenCode behavior when observability is disabled +or when the NeMo Flow runtime is unavailable. + +Disable the plugin: + +```bash +cp opencode.json opencode.enabled.json +jq '(.plugin[0][1].enabled) = false' opencode.json > opencode.disabled.json +mv opencode.disabled.json opencode.json +rm -f ./.nemoflow/opencode.* + +opencode run --title "nemo-flow disabled smoke" \ + "Reply with exactly: plugin disabled smoke." + +test ! -s ./.nemoflow/opencode.atof.jsonl +test ! -s ./.nemoflow/opencode.atif.json +mv opencode.enabled.json opencode.json +``` + +Force runtime initialization failure: + +```bash +rm -f ./.nemoflow/opencode.* + +NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE=1 opencode run \ + --title "nemo-flow init failure smoke" \ + "Reply with exactly: init failure smoke." + +grep -i "pass-through" ./.nemoflow/opencode-plugin.log +test ! -s ./.nemoflow/opencode.atof.jsonl +``` + +## Demo Video Script + +Use this storyboard to record a short walkthrough. + +| Shot | Show | Narration | +|---|---|---| +| 1 | `opencode.json` with the plugin file URL | Stock OpenCode loads the NeMo Flow plugin through normal plugin config. | +| 2 | `opencode debug info` output | OpenCode sees the plugin without applying an OpenCode patch. | +| 3 | `opencode run` or the interactive OpenCode UI | The developer uses OpenCode normally. | +| 4 | `ls -la .nemoflow` | The plugin writes observability files in the background. | +| 5 | `grep` against `opencode.atof.jsonl` | ATOF contains session, message, LLM request, and tool lifecycle events. | +| 6 | `jq` against `opencode.atif.json` | ATIF contains the exported session trajectory. | +| 7 | Disabled or forced-failure smoke | OpenCode still runs when the plugin is disabled or pass-through. | + +Keep the recording focused on the user-visible contract: install the plugin, +use OpenCode normally, and inspect `.nemoflow` output after the session. + +## Limits + +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-plugin/README.md b/integrations/opencode-plugin/README.md new file mode 100644 index 00000000..1eea1333 --- /dev/null +++ b/integrations/opencode-plugin/README.md @@ -0,0 +1,76 @@ + + +# NeMo Flow OpenCode Plugin + +This package is a standalone OpenCode server plugin for NeMo Flow observability. +It uses OpenCode's public plugin API and does not require patching OpenCode. + +For the illustrated setup guide and demo recording script, see +`docs/integrate-frameworks/opencode.md` in the NeMo Flow source checkout. + +## Configuration + +Use the plugin from an OpenCode config file. From a NeMo Flow source checkout, +use a file URL: + +```json +{ + "plugin": [ + [ + "file:///absolute/path/to/NeMo-Flow/integrations/opencode-plugin", + { + "enabled": true, + "atofPath": "./.nemoflow/opencode.atof.jsonl", + "atifPath": "./.nemoflow/opencode.atif.json", + "logPath": "./.nemoflow/opencode-plugin.log" + } + ] + ] +} +``` + +When this package is published, replace the file URL with the package name: + +```json +{ + "plugin": [ + [ + "@nvidia/nemoflow-opencode-plugin", + { + "enabled": true, + "atofPath": "./.nemoflow/opencode.atof.jsonl", + "atifPath": "./.nemoflow/opencode.atif.json", + "logPath": "./.nemoflow/opencode-plugin.log" + } + ] + ] +} +``` + +The package loads `nemo-flow-node` dynamically. If the native Node binding is +missing or cannot initialize, the plugin logs one pass-through warning and does +not change OpenCode behavior. + +## Compatibility + +The plugin declares support for OpenCode `>=1.14.40`. It uses the public +OpenCode server plugin hooks that are available in `@opencode-ai/plugin` +`1.14.40` and were verified against OpenCode `1.14.41`. + +## Output + +- `atofPath` receives raw NeMo Flow ATOF JSONL events for OpenCode session, + message, LLM request metadata, error, and successful tool lifecycle records. +- `atifPath` receives a session trajectory when OpenCode reports a session as + idle or deleted. +- `logPath` receives JSONL plugin diagnostics. + +## 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-plugin/package-lock.json b/integrations/opencode-plugin/package-lock.json new file mode 100644 index 00000000..eba44d2e --- /dev/null +++ b/integrations/opencode-plugin/package-lock.json @@ -0,0 +1,1185 @@ +{ + "name": "@nvidia/nemoflow-opencode-plugin", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nvidia/nemoflow-opencode-plugin", + "version": "0.2.0", + "license": "Apache-2.0", + "dependencies": { + "nemo-flow-node": "file:../../crates/node" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.40" + }, + "engines": { + "node": ">=20.0.0", + "opencode": ">=1.14.40" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.14.40" + } + }, + "../../crates/node": { + "name": "nemo-flow-node", + "version": "0.2.0", + "license": "Apache-2.0", + "devDependencies": { + "@napi-rs/cli": "^2", + "c8": "^11.0.0", + "prettier": "^3.8.2", + "typedoc": "^0.28.0", + "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "../../crates/node/node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "../../crates/node/node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "../../crates/node/node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "../../crates/node/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "../../crates/node/node_modules/@napi-rs/cli": { + "version": "2.18.4", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "../../crates/node/node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "../../crates/node/node_modules/@shikijs/langs": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "../../crates/node/node_modules/@shikijs/themes": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "../../crates/node/node_modules/@shikijs/types": { + "version": "3.23.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "../../crates/node/node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/@types/hast": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "../../crates/node/node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/@types/unist": { + "version": "3.0.3", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "../../crates/node/node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "../../crates/node/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "../../crates/node/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "../../crates/node/node_modules/c8": { + "version": "11.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "../../crates/node/node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "../../crates/node/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "../../crates/node/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "../../crates/node/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/entities": { + "version": "4.5.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "../../crates/node/node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "../../crates/node/node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "../../crates/node/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "../../crates/node/node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "../../crates/node/node_modules/istanbul-reports": { + "version": "3.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/linkify-it": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "../../crates/node/node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/lru-cache": { + "version": "11.2.7", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "../../crates/node/node_modules/lunr": { + "version": "2.3.9", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/markdown-it": { + "version": "14.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "../../crates/node/node_modules/mdurl": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "../../crates/node/node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "../../crates/node/node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/prettier": { + "version": "3.8.2", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "../../crates/node/node_modules/punycode.js": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "../../crates/node/node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "../../crates/node/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "../../crates/node/node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../crates/node/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "../../crates/node/node_modules/test-exclude": { + "version": "8.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "../../crates/node/node_modules/typedoc": { + "version": "0.28.19", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.1.1", + "minimatch": "^10.2.5", + "yaml": "^2.8.3" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, + "../../crates/node/node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "../../crates/node/node_modules/uc.micro": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "../../crates/node/node_modules/v8-to-istanbul": { + "version": "9.3.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "../../crates/node/node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "../../crates/node/node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "../../crates/node/node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "../../crates/node/node_modules/yaml": { + "version": "2.8.3", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "../../crates/node/node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "../../crates/node/node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "../../crates/node/node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "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/@opencode-ai/plugin": { + "version": "1.14.41", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.14.41", + "effect": "4.0.0-beta.59", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.41", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.59", + "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/fast-check": { + "version": "4.7.0", + "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/find-my-way-ts": { + "version": "0.1.6", + "dev": true, + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/nemo-flow-node": { + "resolved": "../../crates/node", + "link": true + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "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/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.2", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.4", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/integrations/opencode-plugin/package.json b/integrations/opencode-plugin/package.json new file mode 100644 index 00000000..42020374 --- /dev/null +++ b/integrations/opencode-plugin/package.json @@ -0,0 +1,55 @@ +{ + "name": "nemo-flow-opencode", + "version": "0.2.0", + "description": "OpenCode server plugin that exports NeMo Flow observability data.", + "type": "module", + "main": "./server.js", + "exports": { + ".": { + "default": "./server.js", + "import": "./server.js" + }, + "./server": { + "default": "./server.js", + "import": "./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#readme", + "bugs": { + "url": "https://github.com/NVIDIA/NeMo-Flow/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NVIDIA/NeMo-Flow.git", + "directory": "integrations/opencode-plugin" + }, + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "opencode": ">=1.14.40" + }, + "dependencies": { + "nemo-flow-node": "file:../../crates/node" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.14.40" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.40" + } +} diff --git a/integrations/opencode-plugin/server.js b/integrations/opencode-plugin/server.js new file mode 100644 index 00000000..6336889c --- /dev/null +++ b/integrations/opencode-plugin/server.js @@ -0,0 +1,599 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fsSync from "node:fs" +import fs from "node:fs/promises" +import path from "node:path" + +const PLUGIN_ID = "@nvidia/nemoflow-opencode-plugin" +const AGENT_VERSION = "opencode-plugin-0.2.0" +const RELEVANT_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", +]) + +/** + * 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}` + if (level === "error") console.error(text, extra ?? "") + else if (level === "warn") console.warn(text, extra ?? "") + else console.info(text, extra ?? "") + } + + 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) +} + +/** + * Normalize OpenCode plugin options into concrete runtime settings. + */ +function normalizeOptions(input, options = {}) { + const baseDir = input?.directory ?? process.cwd() + return { + enabled: options.enabled !== false, + atofPath: resolveOutputPath(baseDir, options.atofPath ?? "./.nemoflow/opencode.atof.jsonl"), + atifPath: resolveOutputPath(baseDir, options.atifPath ?? "./.nemoflow/opencode.atif.json"), + logPath: resolveOutputPath(baseDir, options.logPath ?? "./.nemoflow/opencode-plugin.log"), + } +} + +/** + * 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 +} + +/** + * Read the OpenCode session ID from a bus event payload. + */ +function eventSessionID(event) { + const props = event?.properties + return props?.sessionID ?? props?.info?.id +} + +/** + * 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" +} + +/** + * Create the NeMo Flow adapter behind the OpenCode plugin hooks. + */ +function createNemoFlowAdapter(lib, options, logger) { + const sessions = new Map() + const recentFlushes = new Map() + const trajectories = [] + let atofSubscriberName + let atofDeregisterTimer + let closed = false + + /** + * Register the process-local ATOF JSONL subscriber on first use. + */ + function registerAtOfJsonlExporter() { + if (atofDeregisterTimer) { + clearTimeout(atofDeregisterTimer) + atofDeregisterTimer = undefined + } + if (atofSubscriberName || !options.atofPath) return + fsSync.mkdirSync(path.dirname(options.atofPath), { recursive: true }) + atofSubscriberName = `${PLUGIN_ID}:atof:${process.pid}:${Date.now()}` + lib.registerSubscriber(atofSubscriberName, (event) => { + fsSync.appendFileSync(options.atofPath, JSON.stringify(event) + "\n") + }) + void logger.info("registered ATOF JSONL exporter", { path: options.atofPath }) + } + + /** + * Deregister the ATOF JSONL subscriber after the last session closes. + */ + function deregisterAtOfJsonlExporter() { + if (atofDeregisterTimer) { + clearTimeout(atofDeregisterTimer) + atofDeregisterTimer = undefined + } + if (!atofSubscriberName) return + try { + lib.deregisterSubscriber(atofSubscriberName) + } catch (error) { + void logger.warnOnce("atof-deregister", "failed to deregister ATOF JSONL exporter", error) + } finally { + atofSubscriberName = undefined + } + } + + /** + * Delay ATOF subscriber cleanup so adjacent events can still flush. + */ + function scheduleAtOfJsonlExporterDeregister() { + if (!atofSubscriberName || atofDeregisterTimer) return + atofDeregisterTimer = setTimeout(() => { + deregisterAtOfJsonlExporter() + }, 250) + } + + /** + * 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) lib.setThreadScopeStack(previous) + } + } + + /** + * Create or update the NeMo Flow session state for an OpenCode session. + */ + function ensureSession(sessionID, metadata = {}) { + if (!sessionID) return undefined + registerAtOfJsonlExporter() + + 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, + exporter: undefined, + exporterName: `${PLUGIN_ID}:atif:${sessionID}:${Date.now()}`, + pendingTools: new Map(), + } + + session.exporter = new lib.AtifExporter(session.id, session.agent, AGENT_VERSION, session.model ?? null) + session.exporter.register(session.exporterName) + session.scope = withStack(session, () => + lib.pushScope( + "opencode.session", + lib.ScopeType?.Agent ?? 0, + null, + null, + { sessionID }, + inputSessionMetadata(sessionID, session), + { sessionID, source: "opencode" }, + ), + ) + sessions.set(sessionID, session) + emitMark(session, "opencode.session.observed", { + sessionID, + agent: session.agent, + model: session.model, + }) + return session + } + + /** + * Emit an OpenCode milestone as a NeMo Flow mark event. + */ + function emitMark(session, name, data, metadata = {}) { + if (!session?.scope) return + lib.event( + name, + session.scope, + toJsonSafe(data), + { + source: "opencode", + sessionID: session.id, + ...toJsonSafe(metadata), + }, + null, + ) + } + + /** + * Write all collected ATIF trajectories to the configured file. + */ + function writeAtifFile() { + if (!options.atifPath) return + const payload = trajectories.length === 1 ? trajectories[0] : { trajectories } + fsSync.mkdirSync(path.dirname(options.atifPath), { recursive: true }) + fsSync.writeFileSync(options.atifPath, JSON.stringify(payload, null, 2)) + } + + /** + * Close an OpenCode session scope and export its trajectory. + */ + function flushSession(sessionID, reason) { + const session = sessions.get(sessionID) + if (!session) return + recentFlushes.set(sessionID, Date.now()) + emitMark(session, "opencode.session.flush", { sessionID, reason }) + for (const [key, tool] of session.pendingTools) { + try { + lib.toolCallEnd( + tool.handle, + { status: "unknown", reason: "session flushed before tool.execute.after" }, + null, + { source: "opencode", sessionID, callID: tool.callID }, + ) + } catch (error) { + void logger.warnOnce(`tool-close:${key}`, "failed to close pending OpenCode tool span", error) + } + } + session.pendingTools.clear() + + if (session.scope) { + try { + withStack(session, () => lib.popScope(session.scope, { sessionID, reason }, null)) + } catch (error) { + void logger.warnOnce(`scope-pop:${sessionID}`, "failed to close OpenCode session scope", error) + } + } + + try { + trajectories.push(JSON.parse(session.exporter.exportJson())) + writeAtifFile() + } catch (error) { + void logger.warnOnce(`atif-export:${sessionID}`, "failed to export ATIF trajectory", error) + } + + try { + session.exporter.deregister(session.exporterName) + } catch (error) { + void logger.warnOnce(`atif-deregister:${sessionID}`, "failed to deregister ATIF exporter", error) + } + sessions.delete(sessionID) + if (sessions.size === 0) scheduleAtOfJsonlExporterDeregister() + } + + 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, + }) + }, + + /** + * Record relevant OpenCode bus events as NeMo Flow marks. + */ + async recordEvent(event) { + if (closed || !RELEVANT_EVENTS.has(event?.type)) return + const sessionID = eventSessionID(event) + if (!sessionID) return + const recentFlushAt = recentFlushes.get(sessionID) + if (shouldFlushEvent(event) && recentFlushAt && Date.now() - recentFlushAt < 2000) return + const props = event.properties ?? {} + const session = ensureSession(sessionID, { + agent: agentName(props.info, undefined), + model: modelName(props.info?.model), + }) + emitMark( + session, + `opencode.${event.type}`, + { + id: event.id, + type: event.type, + properties: props, + }, + 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), + model: modelName(input.model ?? output?.message?.model), + }) + if (!session) return + emitMark( + session, + "opencode.chat.message", + { + input, + message: output?.message, + parts: output?.parts, + }, + eventMetadata(session, { messageID: input.messageID ?? output?.message?.id }), + ) + }, + + /** + * 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 + emitMark( + session, + "opencode.llm.request", + { + sessionID: input.sessionID, + agent: input.agent, + provider: input.provider, + model: input.model, + message: input.message, + params: output, + limitation: "OpenCode Phase 1 hooks expose request metadata but not exact stream completion.", + }, + eventMetadata(session, { messageID: input.message?.id }), + ) + }, + + /** + * 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) + const handle = 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, + ) + 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) + const handle = 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, + ) + pending = { handle, callID: input.callID, tool: input.tool, args } + } + 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() { + closed = true + for (const sessionID of [...sessions.keys()]) { + flushSession(sessionID, "plugin-close") + } + deregisterAtOfJsonlExporter() + }, + } +} + +/** + * Load the default NeMo Flow Node.js runtime. + */ +async function loadDefaultRuntime() { + if (process.env.NEMO_FLOW_OPENCODE_FORCE_INIT_FAILURE === "1") { + throw new Error("forced initialization failure") + } + const mod = await import("nemo-flow-node") + return mod.default ?? mod +} + +/** + * Create the OpenCode server plugin entrypoint. + */ +export function createServerPlugin({ loadRuntime = loadDefaultRuntime } = {}) { + return async function server(input, options) { + const normalized = normalizeOptions(input, options) + const logger = createLogger(normalized.logPath) + + if (!normalized.enabled) { + await logger.warnOnce("disabled", "NeMo Flow OpenCode plugin disabled by configuration") + return {} + } + + let adapter + try { + const lib = await loadRuntime() + adapter = createNemoFlowAdapter(lib, normalized, logger) + await logger.info("initialized NeMo Flow OpenCode plugin", { + atofPath: normalized.atofPath, + atifPath: normalized.atifPath, + }) + } catch (error) { + await logger.warnOnce( + "init-failed", + "NeMo Flow runtime unavailable; OpenCode plugin is running pass-through", + 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-plugin/test/server.test.mjs b/integrations/opencode-plugin/test/server.test.mjs new file mode 100644 index 00000000..d254a546 --- /dev/null +++ b/integrations/opencode-plugin/test/server.test.mjs @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict" +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 subscribers = new Map() + let counter = 0 + + function emit(event) { + for (const callback of subscribers.values()) callback(event) + } + + class AtifExporter { + constructor(sessionID, agentName, agentVersion, modelName) { + this.sessionID = sessionID + this.agentName = agentName + this.agentVersion = agentVersion + this.modelName = modelName + this.events = [] + this.callback = (event) => this.events.push(event) + } + + register(name) { + subscribers.set(name, this.callback) + } + + deregister(name) { + return subscribers.delete(name) + } + + exportJson() { + return JSON.stringify({ + session_id: this.sessionID, + agent: { + name: this.agentName, + version: this.agentVersion, + model_name: this.modelName, + }, + steps: this.events, + }) + } + } + + return { + ScopeType: { Agent: 0 }, + AtifExporter, + registerSubscriber(name, callback) { + subscribers.set(name, callback) + }, + deregisterSubscriber(name) { + return subscribers.delete(name) + }, + createScopeStack() { + return { id: `stack-${++counter}` } + }, + currentScopeStack() { + return { id: "current" } + }, + setThreadScopeStack(_stack) {}, + pushScope(name, scopeType, _parent, attributes, data, metadata, input) { + const handle = { + uuid: `scope-${++counter}`, + name, + scopeType, + attributes, + } + emit({ + kind: "scope", + category: "agent", + scope_category: "start", + uuid: handle.uuid, + name, + data, + metadata, + input, + }) + return handle + }, + popScope(handle, output) { + emit({ + kind: "scope", + category: "agent", + scope_category: "end", + uuid: handle.uuid, + name: handle.name, + data: output, + }) + }, + event(name, handle, data, metadata) { + emit({ + kind: "mark", + uuid: `mark-${++counter}`, + parent_uuid: handle?.uuid, + name, + data, + metadata, + }) + }, + toolCall(name, args, handle, attributes, data, metadata, toolCallID) { + const tool = { + uuid: `tool-${++counter}`, + name, + parentUuid: handle?.uuid, + toolCallID, + } + emit({ + kind: "scope", + category: "tool", + scope_category: "start", + uuid: tool.uuid, + name, + parent_uuid: handle?.uuid, + data: args, + metadata, + }) + return tool + }, + toolCallEnd(handle, result, data, metadata) { + emit({ + kind: "scope", + category: "tool", + scope_category: "end", + uuid: handle.uuid, + name: handle.name, + data: result ?? data, + metadata, + }) + }, + } +} + +async function makeTempDir() { + return fs.mkdtemp(path.join(os.tmpdir(), "nemo-flow-opencode-plugin-")) +} + +async function readJsonl(filePath) { + const content = await fs.readFile(filePath, "utf8") + return content + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) +} + +describe("NeMo Flow OpenCode plugin", () => { + it("records OpenCode hooks to ATOF and flushes ATIF on idle", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server( + { directory: dir }, + { + enabled: true, + atofPath: "./.nemoflow/opencode.atof.jsonl", + atifPath: "./.nemoflow/opencode.atif.json", + logPath: "./.nemoflow/opencode-plugin.log", + }, + ) + + 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["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" } }, + }, + }) + + const atofPath = path.join(dir, ".nemoflow", "opencode.atof.jsonl") + const atifPath = path.join(dir, ".nemoflow", "opencode.atif.json") + const atof = await fs.readFile(atofPath, "utf8") + const atif = JSON.parse(await fs.readFile(atifPath, "utf8")) + + assert.match(atof, /opencode\.chat\.message/) + assert.match(atof, /opencode\.llm\.request/) + assert.match(atof, /"category":"tool"/) + assert.equal(atif.session_id, "ses_1") + assert.ok(atif.steps.some((event) => event.name === "opencode.session.flush")) + }) + + it("records session lifecycle events, message metadata, errors, and deleted flushes", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server( + { directory: dir }, + { + enabled: true, + atofPath: "./.nemoflow/opencode.atof.jsonl", + atifPath: "./.nemoflow/opencode.atif.json", + logPath: "./.nemoflow/opencode-plugin.log", + }, + ) + + 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 atofPath = path.join(dir, ".nemoflow", "opencode.atof.jsonl") + const atifPath = path.join(dir, ".nemoflow", "opencode.atif.json") + const events = await readJsonl(atofPath) + const names = events.map((event) => event.name).filter(Boolean) + const message = events.find((event) => event.name === "opencode.chat.message") + const serialized = JSON.stringify(events) + const atif = JSON.parse(await fs.readFile(atifPath, "utf8")) + + assert.ok(names.includes("opencode.session.created")) + assert.ok(names.includes("opencode.session.updated")) + assert.ok(names.includes("opencode.session.error")) + assert.ok(names.includes("opencode.session.deleted")) + assert.equal(message.metadata.sessionID, "ses_2") + assert.equal(message.metadata.agent, "review") + assert.equal(message.metadata.model, "anthropic/claude-test") + assert.match(serialized, /"apiKey":"\[Redacted\]"/) + assert.match(serialized, /"outputTokens":3/) + assert.equal(atif.session_id, "ses_2") + assert.ok(atif.steps.some((event) => event.name === "opencode.session.flush")) + }) + + it("ignores hooks without an OpenCode session identifier", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server({ directory: dir }, { enabled: true }) + + 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" }) + }) + await assert.rejects(fs.stat(path.join(dir, ".nemoflow", "opencode.atof.jsonl"))) + }) + + it("stays pass-through when disabled", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ loadRuntime: async () => createFakeRuntime() }) + const hooks = await server({ directory: dir }, { enabled: false }) + + assert.deepEqual(hooks, {}) + await assert.rejects(fs.stat(path.join(dir, ".nemoflow", "opencode.atof.jsonl"))) + }) + + it("logs once and disables hooks when the runtime cannot load", async () => { + const dir = await makeTempDir() + const server = createServerPlugin({ + loadRuntime: async () => { + throw new Error("missing native binding") + }, + }) + const hooks = await server({ directory: dir }, { enabled: true }) + + assert.deepEqual(hooks, {}) + const log = await fs.readFile(path.join(dir, ".nemoflow", "opencode-plugin.log"), "utf8") + assert.match(log, /pass-through/) + }) +})