commit 52b7776d5a6e24127c9bc0049dddb8b907fede03 Author: bendtherules Date: Mon Oct 5 11:47:57 2026 +0530 feat: DSML tool-call recovery plugin for OpenCode V2 Recovers DeepSeek DSML tool calls that leak as assistant text: streaming middleware (AI-SDK path), SSE rewrite (native path), history sanitization, preventive directive, conditional system nudge, and a capped idle watchdog. 116 tests, synthetic golden fixtures. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ab415f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/README.md b/README.md new file mode 100644 index 0000000..63c2ec1 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# opencode-plugin-dsml-fix + +OpenCode V2 plugin that recovers DeepSeek DSML tool calls when the provider's +parser fails and the raw markup leaks into assistant text. + +## What it does + +1. **Streaming recovery (strategy B).** Wraps the provider language model via + `ctx.aisdk.hook("language")`. Leaked DSML blocks in `text-delta` parts become + real AI-SDK `tool-input-*` + `tool-call` parts, and `finishReason` flips from + `stop` to `tool-calls` — so the agent loop dispatches the intended call instead + of stalling. Reasoning and native tool parts pass through untouched. +2. **History sanitization.** Strips leaked DSML spans from replayed assistant text + so the model stops imitating its own degraded output. Never synthesizes calls + from history (no double execution). +3. **Correction directive.** A system instruction stating the exact required envelope + (complete outer block layer, one invoke per call, parameters inside invokes). +4. **Auto-resume safety net (strategy A).** On session idle with unrecovered DSML + markup, sends one short retry nudge, capped per message via plugin storage. + +## Install + +Add to `opencode.jsonc`: + +```jsonc +{ + "plugins": ["/code/opencode-dsml-plugin"] +} +``` + +Or publish and reference the package name. Requires OpenCode V2 (`2.0.x`). + +## Configuration + +```jsonc +{ + "plugins": [ + { + "package": "/code/opencode-dsml-plugin", + "options": { + "providers": ["opencode-go", "opencode"], + "mode": "relaxed", + "resume": { "enabled": true, "maxAttempts": 3 } + } + } + ] +} +``` + +| Option | Default | Meaning | +|---|---|---| +| `providers` | `["opencode-go","opencode"]` | Provider IDs in scope. Empty disables the plugin. | +| `mode` | `"relaxed"` | `"relaxed"` = full catalogue; `"strict"` = complete blocks only. | +| `rescue.orphanInvoke` | mode | Recover invokes with no outer opener. | +| `rescue.looseParameters` | mode | Tolerant re-scan past broken closers. | +| `rescue.rawJsonBody` | mode | Bare-JSON invoke bodies. | +| `rescue.wrappedTool` | mode | Orphan `` with complete inner params. | +| `bufferLimit` | `65536` | Streaming hold cap in bytes. | +| `response.sse` | `true` | Native path: rewrite SSE bytes in `http.response`. | +| `response.aisdkStream` | `true` | AI-SDK path: wrap language model, streaming parts. | +| `response.aisdkGenerate` | `true` | AI-SDK path: wrap language model, generate results. | +| `request.sanitize` | `true` | Strip leaked spans from replayed history (+ one note). | +| `request.directive` | `true` | Preventive system directive (alias: `prompt.enabled`). | +| `request.systemNudge` | `true` | Conditional correction in system on unrecovered tail. | +| `resume.enabled` | `true` | Idle watchdog wake (alias: `strategies.watchdog`). | +| `resume.maxAttempts` | `3` | Cap per assistant message. | +| `resume.channel` | `"system"` | `"system"` = minimal ping + system nudge; `"user"` = legacy full-text synthetic turn. | +| `debug` | `false` | Log recoveries to stderr + probe file. | + +`recovery.*` and `strategies.*` remain as deprecated aliases +(`sse`→`response.sse`, `stream`→`response.aisdkStream`, +`generate`→`response.aisdkGenerate`, `sanitize`/`directive`/`systemNudge`→`request.*`, +`watchdog`→`resume.enabled`). Precedence: `response`/`request` > `recovery` > +`strategies` > legacy `resume`/`prompt` > default. +If any layer misfires, switch that one flag off (or `mode: "strict"`) — the plugin +degrades to the safer subset and never breaks normal inference. + +## Per-turn impact logging (`debug: true`) + +Every request turn logs one line; every response stream logs one line on close. +Turns are identified as `sessionID/user-messageID` — OpenCode's own message +identity — with `#n` counting physical requests (retries) within the turn: + +``` +[dsml] request ses_abc/msg_xyz sanitized=2msgs/3parts directive=yes nudge=no +[dsml] response ses_abc/msg_xyz#1 recovered=1 +[dsml] response ses_abc/msg_xyz#2 passthrough +[dsml] response ses_abc/msg_xyz#3 held-candidate-no-call +``` + +- `sanitized=Nmsgs/Mparts`: replayed history messages/parts stripped (+ note). +- `directive`/`nudge`: whether system text was added this turn. +- `recovered=N`: tool calls recovered from leaked markup this turn. +- `passthrough`: no DSML candidate ever seen — bytes untouched. +- `held-candidate-no-call`: something looked like markup but parsed to nothing + (emitted verbatim). If you see this often, send the log excerpt — it's a new + variant to catalogue. + +## Development + +```bash +bun install +bun test # 111 tests across grammar/parser/stream/wrapper/config/sse +bun run check # typecheck + tests (tsc --noEmit) +``` + +- `docs/PATTERNS.md` — variant catalogue (source of truth for every regex) +- `docs/TEST-MATRIX.md` — behaviour ↔ test map +- `docs/STATUS.md` — handoff / project status +- `test/fixtures.ts` — fixture builders (the repo never stores a live DSML token) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..4745e31 --- /dev/null +++ b/bun.lock @@ -0,0 +1,604 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "opencode-plugin-dsml", + "dependencies": { + "@ai-sdk/provider": "^3.0.8", + }, + "devDependencies": { + "@opencode/plugin": "2.0.22", + "bun-types": "latest", + "typescript": "^5.6.0", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.18", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-IpefF5ssZVOZCD4Wu7zxt6PwZoGT2HLVjxpJ10Bjx6EAmRfLYJPjyrfxb0RYDlwvXnDb9nkQ7Do/oJVDuRFzew=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1057.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.978.1", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.35.0", "@smithy/signature-v4": "^5.7.3", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg=="], + + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.71", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-rsxsi8tF3vMGdWdaHQNXceHBdb49442yR2Pe08K+Phoc/yICbptdsHwL25Wf0z5+hjhSS5a+hDJHP7Xul4zvHQ=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.74", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.17", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/credential-provider-env": "^3.972.72", "@aws-sdk/credential-provider-http": "^3.972.74", "@aws-sdk/credential-provider-login": "^3.972.79", "@aws-sdk/credential-provider-process": "^3.972.72", "@aws-sdk/credential-provider-sso": "^3.973.16", "@aws-sdk/credential-provider-web-identity": "^3.972.78", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.79", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.84", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.72", "@aws-sdk/credential-provider-http": "^3.972.74", "@aws-sdk/credential-provider-ini": "^3.973.17", "@aws-sdk/credential-provider-process": "^3.972.72", "@aws-sdk/credential-provider-sso": "^3.973.16", "@aws-sdk/credential-provider-web-identity": "^3.972.78", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/token-providers": "3.1138.0", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA=="], + + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1057.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1057.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.46", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/signature-v4-multi-region": "^3.996.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.47", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/signature-v4": "^5.7.3", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1138.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.6", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.10", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.41", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="], + + "@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="], + + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], + + "@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="], + + "@npmcli/config": ["@npmcli/config@10.8.1", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ=="], + + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], + + "@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="], + + "@npmcli/installed-package-contents": ["@npmcli/installed-package-contents@4.0.0", "", { "dependencies": { "npm-bundled": "^5.0.0", "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" } }, "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA=="], + + "@npmcli/map-workspaces": ["@npmcli/map-workspaces@5.0.3", "", { "dependencies": { "@npmcli/name-from-folder": "^4.0.0", "@npmcli/package-json": "^7.0.0", "glob": "^13.0.0", "minimatch": "^10.0.3" } }, "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw=="], + + "@npmcli/metavuln-calculator": ["@npmcli/metavuln-calculator@9.0.3", "", { "dependencies": { "cacache": "^20.0.0", "json-parse-even-better-errors": "^5.0.0", "pacote": "^21.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5" } }, "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg=="], + + "@npmcli/name-from-folder": ["@npmcli/name-from-folder@4.0.0", "", {}, "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg=="], + + "@npmcli/node-gyp": ["@npmcli/node-gyp@5.0.0", "", {}, "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ=="], + + "@npmcli/package-json": ["@npmcli/package-json@7.0.5", "", { "dependencies": { "@npmcli/git": "^7.0.0", "glob": "^13.0.0", "hosted-git-info": "^9.0.0", "json-parse-even-better-errors": "^5.0.0", "proc-log": "^6.0.0", "semver": "^7.5.3", "spdx-expression-parse": "^4.0.0" } }, "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ=="], + + "@npmcli/promise-spawn": ["@npmcli/promise-spawn@9.0.1", "", { "dependencies": { "which": "^6.0.0" } }, "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q=="], + + "@npmcli/query": ["@npmcli/query@5.0.0", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ=="], + + "@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="], + + "@npmcli/run-script": ["@npmcli/run-script@10.0.4", "", { "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", "proc-log": "^6.0.0" } }, "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg=="], + + "@opencode/ai": ["@opencode/ai@2.0.22", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.0.22", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-wdD2ut4G/areDkA9bYizYiho6C1sRQ6mPqMSji93XzadavbkIcbhU/VyodOmExtZtOg2IjheJCUVCpDXQ8n8FA=="], + + "@opencode/client": ["@opencode/client@2.0.22", "", { "dependencies": { "@opencode/protocol": "2.0.22", "@opencode/schema": "2.0.22" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-FJKugH301QHlV/rGL8TvgL3ly+toVlww+TP+YdVTKQWytQvjLoUdYtvvm+Iq5k6pN6Ki3ybE/08E8Cg+e6Opbg=="], + + "@opencode/plugin": ["@opencode/plugin@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.0.22", "@opencode/client": "2.0.22", "@opencode/protocol": "2.0.22", "@opencode/schema": "2.0.22", "@opencode/util": "2.0.22", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { "@opencode/theme": "2.0.22", "@opentui/core": ">=0.5.14", "@opentui/solid": ">=0.5.14", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-EJyr+qA+I41rfJqE2zfyKL2qNez+wE8QkNwXf5TddEKIPmL8FUvYMVT5lLhbnXyOGTDxsEc0/8bX7ErQfX5/Rg=="], + + "@opencode/protocol": ["@opencode/protocol@2.0.22", "", { "dependencies": { "@opencode/schema": "2.0.22", "effect": "4.0.0-rc.112" } }, "sha512-Ws3DKQW/rlBDwNz2cPE9+nGwbw7pgnZ+5bzgoHWCWxgTZmxdFulmLP05lxR+PPwMklUwzOGoUXFIzODPZ27WfA=="], + + "@opencode/schema": ["@opencode/schema@2.0.22", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-PTqTrx3QHhlRL5ypfoMnSzXDU8XB2+vtKX8QKwIHP7B1F1yw90LN0XTmGP82k9rYoEcERq0TLuL+ykAsYJXJdA=="], + + "@opencode/util": ["@opencode/util@2.0.22", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "open": "11.0.4", "pacote": "21.5.1" } }, "sha512-nipMu7ZAM9gCOKwH06eqAUVgATR+5XbqXEKSN+ECYLgm9PStsmxraxDN93gJLV3bYzpR+9Ow0Be/s0ZYKUxPKg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.8.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-transformer": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.8.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@redis/bloom": ["@redis/bloom@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA=="], + + "@redis/client": ["@redis/client@6.3.0", "", { "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg=="], + + "@redis/json": ["@redis/json@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw=="], + + "@redis/search": ["@redis/search@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw=="], + + "@redis/time-series": ["@redis/time-series@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A=="], + + "@sigstore/bundle": ["@sigstore/bundle@4.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A=="], + + "@sigstore/core": ["@sigstore/core@3.2.1", "", {}, "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g=="], + + "@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.5.2", "", {}, "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw=="], + + "@sigstore/sign": ["@sigstore/sign@4.1.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^15.0.4", "proc-log": "^6.1.0" } }, "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ=="], + + "@sigstore/tuf": ["@sigstore/tuf@4.0.2", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^4.1.0" } }, "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ=="], + + "@sigstore/verify": ["@sigstore/verify@3.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA=="], + + "@smithy/core": ["@smithy/core@3.35.1", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.4", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="], + + "@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], + + "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], + + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], + + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "bundle-name": ["bundle-name@4.1.1", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA=="], + + "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], + + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.4", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw=="], + + "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.2.0", "", {}, "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], + + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + + "ip-address": ["ip-address@10.7.3", "", {}, "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@5.0.0", "", {}, "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="], + + "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + + "just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="], + + "just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + + "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], + + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "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" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + + "npm-bundled": ["npm-bundled@5.0.0", "", { "dependencies": { "npm-normalize-package-bin": "^5.0.0" } }, "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw=="], + + "npm-install-checks": ["npm-install-checks@8.0.0", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA=="], + + "npm-normalize-package-bin": ["npm-normalize-package-bin@5.0.0", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="], + + "npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], + + "npm-packlist": ["npm-packlist@10.0.4", "", { "dependencies": { "ignore-walk": "^8.0.0", "proc-log": "^6.0.0" } }, "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng=="], + + "npm-pick-manifest": ["npm-pick-manifest@11.0.3", "", { "dependencies": { "npm-install-checks": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "npm-package-arg": "^13.0.0", "semver": "^7.3.5" } }, "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ=="], + + "npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="], + + "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], + + "p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="], + + "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], + + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "proggy": ["proggy@4.0.0", "", {}, "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ=="], + + "promise-all-reject-late": ["promise-all-reject-late@1.0.1", "", {}, "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw=="], + + "promise-call-limit": ["promise-call-limit@3.0.2", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], + + "redis": ["redis@6.3.0", "", { "dependencies": { "@redis/bloom": "6.3.0", "@redis/client": "6.3.0", "@redis/json": "6.3.0", "@redis/search": "6.3.0", "@redis/time-series": "6.3.0" } }, "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw=="], + + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sigstore": ["sigstore@4.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], + + "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], + + "spdx-license-ids": ["spdx-license-ids@3.0.24", "", {}, "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ=="], + + "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + + "wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@npmcli/git/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@npmcli/promise-spawn/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@opencode/plugin/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "node-gyp/undici": ["undici@6.29.0", "", {}, "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "rimraf/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], + + "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + } +} diff --git a/docs/PATTERNS.md b/docs/PATTERNS.md new file mode 100644 index 0000000..bf5d013 --- /dev/null +++ b/docs/PATTERNS.md @@ -0,0 +1,124 @@ +# DSML Tool-Call Pattern Catalogue + +Single source of truth for what the parser **accepts**, what it **repairs**, what it +**rejects**, and which test proves each row. Every regex in `src/grammar.ts` must trace +to a row here. Add a row and a test before adding a pattern. + +> Writing rule for this file: never paste a live DSML token. The model may emit it and +> our own harness (or the provider's greedy extractor) will turn this document into a +> phantom tool call. Represent tokens with the placeholder `‹DSML›` and show the +> literal bytes as `U+FF5C` escapes. The parser tests are the place for real bytes. + +## 0. Notation + +- `BAR` = one full-width bar `U+FF5C` (`|`) **or** one ASCII pipe `|`. +- `DSML` literal word. +- Canonical marker: `< BAR DSML BAR >` — e.g. `<‹DSML›tool_calls>`. +- Doubled marker (observed): `< BAR BAR DSML BAR BAR >`. +- In real bytes: `\uFF5C` repeated. + +## 1. Canonical grammar + +``` +< MARKER tool_calls> + < MARKER invoke name="TOOL"> + < MARKER parameter name="KEY" string="true">VALUE + < MARKER parameter name="KEY" string="false">JSON + + +``` + +- Outer block names: `tool_calls` (V4), `function_calls` (V3.2), and the mangled `calls`. +- Single or doubled bars; optional whitespace around the marker. +- `string="true"` → raw string value. `string="false"` → JSON value. Attribute absent → + raw string (default). Quotes may be `"` or `'`. +- Invoke/param names may be quoted with `"`/`'` or be a bare token. + +## 2. Leak / degradation variants (the real-world enemies) + +These are the shapes observed in the wild, in the order a stream degrades. Source tags: +`[DB]` = captured in the user's OpenCode session DB, `[vLLM]` = vLLM PR #54686, +`[OpenClaw]` = openclaw doubled-bar issue, `[Pi]` = pi-dsml grammar, `[CS]` = Cherry Studio. + +| # | Variant | Shape | Handle by | Test | +|---|---------|-------|-----------|------| +| V1 | Canonical single-bar | `< BAR DSML BAR tool_calls>` complete | parse normally | `v1-canonical` | +| V2 | Doubled full-width bars | `< BAR BAR DSML BAR BAR tool_calls>` | marker accepts 1–2 bars | `v2-doubled-bars` | +| V3 | ASCII pipes | `< | DSML | tool_calls>` | marker accepts `|` | `v3-ascii-pipes` | +| V4 | Space inside marker | `< BAR DSML BAR tool_calls>` / `calls` | trailing `\s*` + `calls` alt | `v4-marker-space` | +| V5 | Truncated block tag | `< BAR DSML BAR calls>` | `calls` alternative for `tool_calls` | `v5-truncated-tag` | +| V6 | Spaces around invoke name | `< BAR DSML BAR invoke name = "x">` | `\s+` around `name=` | `v6-name-spacing` | +| V7 | `tool_call` (singular) | `< BAR DSML BAR tool_call>` | block tag alt | `v7-tool-call-singular` | +| V8 | `function_calls` (V3.2) | `< BAR DSML BAR function_calls>` | block tag alt | `v8-function-calls` | +| V9 | **Missing outer opener** | invoke/parameter appear with no `tool_calls` block | implicit-block rescue | `v9-orphan-invoke` | +| V10 | **Missing invoke opener** | leaked `parameter` only, no `invoke name=` | implicit-invoke rescue | `v10-orphan-parameter` | +| V11 | Mis-spelled closer | `` (bar then `>`), swallowing next param | tolerant scan + drop runaway param | `v11-misspelled-closer` | +| V12 | Runaway invoke name | `invoke name="tool {` (JSON leaked into name) | reject call, strip text | `v12-runaway-name` | +| V13 | Undeclared tool | well-formed, name not in tool set | **keep call**; runtime answers "tool not found" | `v13-undeclared-tool` | +| V14 | Empty invoke name | `name=""` | reject (never trap buffer) | `v14-empty-name` | +| V15 | Unclosed block at end | opener, no closer, stream ends | flush as text, recover complete calls only | `v15-unclosed` | +| V16 | Truncated mid-invoke | invoke has no closing tag | drop partial invoke, keep earlier ones | `v16-truncated-invoke` | +| V17 | Chunk-split opener | `<` `BAR` `DSML` … across deltas | suffix-prefix buffering | `v17-chunk-split` | +| V18 | Text before/after block | prose + block + prose | preserve prose, excise block | `v18-surrounding` | +| V19 | Multiple invokes / blocks | 2+ calls in one or many blocks | parse all | `v19-multiple` | +| V20 | Inside fenced code | grammar shown in ``` fence | **ignore** (no parse, no strip) | `v20-code-fence` | +| V21 | Trailing `‹EOS›` token | `…‹EOS›` | strip EOS token | `v21-eos` | +| V22 | HTML entities in values | `"` `&` `<` | decode entities | `v22-entities` | +| V23 | Multi-line / heredoc value | value contains newlines, indentation | preserve verbatim except hugging newline | `v23-multiline-value` | +| V24 | JSON params | `string="false"` | `JSON.parse`, fall back to raw on failure | `v24-json-param` | +| V25 | Raw JSON body (no params) | invoke body is a bare `{...}` | fallback JSON parse | `v25-raw-json-body` | +| V26 | **Wrapped tool** (dominant real shape) | orphan `` (marker optional) containing ≥1 complete inner `` | recover call X with inner args (V13 policy: runtime validates) | `v26-*` | + +## 3. Reject arms (never emit a call) + +The parser is a **strict acceptor**. When in doubt it does not execute; it either strips +noise or leaves text alone. This is deliberate: a false positive executes an arbitrary +tool the model never asked for. + +| Reject arm | Rationale | Test | +|-----------|-----------|------| +| No DSML marker anywhere | fast path, zero cost | `reject-no-marker` | +| Text inside a code fence | docs/examples must not execute | `v20-code-fence` | +| Complete call but empty name | unusable, and must not trap buffer | `v14-empty-name` | +| Invoke with no parseable parameter and no JSON body | nothing to run | `reject-empty-args` | +| Native tool calls already present on the message | strip text only, do not double-run | `reject-native-present` | +| `looksLikeDsml` false (no `DSML`/`end-of-sentence`) | skip whole pipeline | `reject-not-dsml` | + +## 4. Transition: removal in old transcripts vs. rejection in new streams + +The user wants the same engine to serve **two consumers**: + +1. **Streaming (new turns, `ctx.aisdk.language`)** — convert a DSML block into a real + `tool-call` part *before* OpenCode's lowering, and rewrite `finishReason` to + `tool-calls`. The block must never reach the transcript. +2. **Stored transcript repair (old sessions)** — walk existing messages, excise DSML + spans, and (for messages that never got a native call) synthesize the missing call. + +Both consume the **same `parseDsml(text) -> { calls, ranges }`** function. The only +difference is the sink: + +- Stream sink: buffer `text-delta`, emit `tool-input-*` + `tool-call` for each call, + drop `ranges` from the emitted text, set `finish` to `tool-calls` if any call. +- Repair sink: `stripRangesWithNote` for display (short inline note naming the + removed tool when known, e.g. `[removed malformed edit call]`), append calls if + none were native. + +Because both share the parser, a variant is "supported" only when both sinks agree, and +the test table covers both. + +## 5. Streaming buffer policy + +- Hold text ONLY from the first byte that could open a call: a DSML opener, a + partial-opener suffix (`<`, `` with or without marker). Everything before it is + emitted immediately, every chunk — plain prose is never buffered. +- Eager stop: a held candidate that produces no complete parameter-open within + 1024 chars is prose — flush as text and resume scanning fresh. Once a complete + parameter is open (inside a value), only the 64 KiB cap bounds the hold. +- Transitions: any non-text part (reasoning, native tool parts) flushes held text + FIRST, preserving transcript order. +- End-of-text guarantee: `text-end` / `finish` / stream flush always resolve the + buffer — recovered calls plus verbatim remainder — so the buffer always ends + empty and no text is ever lost. +- Trailing markup noise (`` / `` arriving after an already + recovered call) is dropped, not leaked. diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..f99788d --- /dev/null +++ b/docs/STATUS.md @@ -0,0 +1,207 @@ +# PROJECT STATUS / HANDOFF + +> Read this first when resuming. It captures the goal, the design, every decision, +> current test state, and the exact next steps. Companion docs: `docs/PATTERNS.md` +> (variant catalogue) and `docs/TEST-MATRIX.md` (test ↔ behaviour map). + +## 1. Goal + +Build an **OpenCode V2 plugin** that fixes DeepSeek "DSML" tool calls that leak into +assistant **text** instead of being parsed as structured tool calls. Two consumers: +fix **new streams** and repair **old stored transcripts**. + +## 2. Hard facts established (do not re-investigate) + +- OpenCode has **no DSML handling** anywhere. Upgrading will not fix it. + `grep -ri dsml` over the whole `v2` source = 0 hits. Installed binary = 0 hits. +- Installed OpenCode: **v2.0.18**. Latest stable `latest` = **2.0.22**. Nightly `dev` + tag = `0.0.0-dev-*`. We target **2.0.22**. +- V2 source repo = `anomalyco/opencode`, **default branch `v2`** (HEAD 2026-10-04). + Worktree: `/code/opencode-v2`. (The older `/code/opencode-src` is `sst/opencode`, + branch `dev`, which is V1 — not what we build against.) +- Failing path in the user's real session: provider **`opencode-go`**, model + **`deepseek-v4.1-flash`**, package **`@ai-sdk/openai-compatible`**, baseURL + `https://opencode.ai/zen/go/v1`. The `opencode` (Zen) provider uses the same shape. +- Trigger (evidence from a real long-context session: an Android speech-to-text + project on `opencode-go/deepseek-v4.1-flash`): **all leaks happen late in a huge + context** (seq 5177+ of ~5700 messages). Degradation: + outer `` opener missing, `invoke` opener mangled to a bare + `parameter name="edit"`, and only inner params carry the DSML token. `finish_reason` + was `stop`. Matches vLLM #48931 / #54686. +- Two extra hypotheses confirmed: (a) the provider backend's **greedy text→tool-call + extractor** can turn arbitrary prose containing a tool-call-shaped tag into a real + call (we saw this with the user's own messages); (b) the extractor is the same layer + that fails to parse legit DSML at long context. + +## 3. Where to insert the middleware (verified against `/code/opencode-v2`) + +Chain for the AI-SDK provider path: + +``` +@ai-sdk/openai-compatible SSE + → LanguageModelV3 doStream() + → LanguageModelV3StreamPart stream + → [ctx.aisdk.hook("language") + wrapLanguageModel] ← OUR MIDDLEWARE + → streamPartEvents() packages/core/src/aisdk.ts ← OpenCode lowering + → LLMEvent.toolCall / stepFinish + → runner/step.ts dispatches tool-call events +``` + +- Enter at **`ctx.aisdk.hook("language", ...)`** (PluginContext field `aisdk`, type + `AISDKDomain` with `hook: ModelHooks`). Replace `event.language` with + `wrapLanguageModel({ model: event.language, middleware })`. +- We must return **AI-SDK `LanguageModelV3StreamPart`s**. OpenCode already converts + `tool-input-start|delta|end` and `tool-call` into `LLMEvent.toolCall`, then + `runner/step.ts:117` dispatches it. `finish.finishReason.unified` must become + `"tool-calls"` (else runner sees `stop`/`unknown`). +- Other usable hooks if needed: `ctx.session.hook("http.response"|"http.request"| + "context"|"retry"|"prompt")`, `ctx.event.subscribe`, `ctx.session.synthetic/prompt`, + `ctx.storage`. Plugin package `@opencode/plugin` (v2.0.22), `Plugin.define({ id, setup })`. + +## 4. Prior art (nothing to duplicate) + +- **CherryStudio** (peer Electron app, NOT OpenCode): `deepseekDsmlParserPlugin.ts`, + PR #14747. Streaming state machine + `wrapGenerate`. We port its algorithm. +- **openclaw** (peer agent): `packages/ai/src/transports/openai-completions-dsml.ts` + + `deepseek-dsml-grammar.ts`. Doubled-bar markers, buffer cap, chunk boundary. +- **pi-dsml** (peer agent, npm `pi-dsml@0.0.1`, unpacked at + `/tmp/opencode/package`): grammar with mangled ` calls>` variant, ranges model, + code-fence guard, `string=false` JSON rule. We port its `parse.ts` shape. +- **vLLM #54686**: leak taxonomy (runaway name / mis-closed param / misspelled opener). +- **No OpenCode plugin exists** for this. Do not go looking again. + +## 5. Repository layout (`/code/opencode-dsml-plugin`) + +``` +docs/PATTERNS.md variant catalogue + reject arms + two-consumer model (source of truth) +docs/TEST-MATRIX.md each behaviour ↔ test id +docs/STATUS.md this file (handoff — read first after compaction) +src/grammar.ts all regexes, each linked to a V# in PATTERNS.md +src/parse.ts parseDsml(text)->{calls,ranges}, stripRanges, orphan rescue +src/middleware.ts createDsmlStreamMiddleware() AI-SDK LanguageModelV3Middleware +src/index.ts (TODO) Plugin.define: aisdk.language hook + fallback +src/fallback.ts (TODO) Strategy A: event subscribe + synthetic resume + storage cap +src/prompt.ts (TODO) correction directive text (see §8) +test/fixtures.ts runtime fixture builders (never store a live DSML token) +test/parse.test.ts 28 parser tests — ALL PASS +test/stream.test.ts 11 streaming tests — ALL PASS +test/wrapper.test.ts generate + wrapper delegation — ALL PASS +test/config.test.ts config matrix, strict parsing, prompt guards, fallback, system nudge — ALL PASS +``` + +Total: **89 tests, 0 fail**. `tsc --noEmit` clean. + +Golden verification against real captured failures (9 wrapped edit/shell leaks +from a long-context DeepSeek session, preserved as synthetic byte-faithful +fixtures in `test/golden/`): +all 9 failing messages now recover the intended call — 5× edit +(newString+oldString+path), 4× shell (command) — with the finish flipping to +`tool-calls` and no text lost. The dominant shape is V26 (wrapped tool); the fix +for the earlier 0-for-9 miss was two parser bugs found by the golden test itself: +orphan ranges shadowing the wrapped rescue (`insideBlock` now checks block ranges +only), and the markerless outer opener needing candidate-hold in the stream. + +## 6. Current test state + +`bun test` → **39/39 pass** (`test/parse.test.ts` 28/28, `test/stream.test.ts` 11/11). +`./node_modules/.bin/tsc --noEmit` → clean. + +Fixed during this session: the streaming `drain` was flushing partial openers +(`<`, `` per call, `` with `string="true"` for raw strings). +- Recovery-failure nudge: on a stream where DSML appeared but no call was recovered, or + on session idle with leaked markup, send a short synthetic message: "Your last tool + call was malformed (missing the outer block). Re-issue it using the correct format, + wrapped in the outer `` layer, exactly once." + +Exact wording + whether preventive injection is always-on or only for the DEESEEK +providers is **not yet decided** — ask the user. + +## 9. Exact next steps + +1. ~~Fix the 2 streaming tests~~ — done, 69/69 green. +2. ~~Write `src/index.ts`, `src/prompt.ts`, `src/fallback.ts`~~ — done. +3. ~~System-channel nudge + full strategy config matrix~~ — done (see §11). +4. Live-wire verification: restart the OpenCode service so the plugin entry in + the global `opencode.jsonc` loads, then run a DeepSeek session and + confirm `[dsml]` debug lines + recovery behaviour. +5. Update `docs/PATTERNS.md`/`TEST-MATRIX.md` as rows move from FAIL to PASS. + +## 11. Strategy report (final) + +Per-request (in `context` hook, before the model call): + +- **Preventive directive** (`strategies.directive`, default on): appends a fixed + ~130-token instruction to `event.system` stating the exact required envelope + (complete outer block layer, single bars, one invoke per call, parameters inside + invokes). Static bytes every request → one-time prefix shift, then cache-stable. +- **History sanitization** (`strategies.sanitize`, default on): strips leaked DSML + spans from replayed assistant text. Fires only when a leak exists; deterministic + given stored messages, so retries converge to identical bytes (cache-friendly). + Never synthesizes calls (no double-execution). +- **Conditional system nudge** (`strategies.systemNudge`, default on): when the + assembled history ends in *unrecoverable* DSML, pushes the correction as a + **transient system instruction** — never persisted, auto-clears when the + condition clears. Capped per message (id, else content hash) via storage. + +Per-response (in `aisdk.language` wrapper, after the provider stream): + +- **B1 streaming** (`strategies.stream`): buffers `text-delta` only, holds partial + openers, resolves closed blocks immediately, emits `tool-input-*` + `tool-call`, + flips `finish` to `tool-calls`. Zero calls → buffer emitted verbatim, finish + untouched. 64 KiB cap → over-cap unclosed tail emitted as text. Zero request + impact → zero cache impact. +- **B2 generate** (`strategies.generate`): same recovery for non-streaming results. + +Idle safety net (`strategies.watchdog` + `resume.channel`/`maxAttempts`): + +- On session idle with unrecovered DSML last message: `channel:"system"` (default) + sends a minimal synthetic wake ping (`"Resume the interrupted tool call."`) and + the context hook attaches the full correction in system on the woken turn; + `channel:"user"` restores the legacy full-text synthetic turn. +- Attempts capped per message; counters in plugin storage. + +Fixed prompt texts live in `src/prompt.ts` (`preventiveDirective()`, +`recoveryNudge()`); tests assert they contain no live marker bytes. + +Caching verdict: only stable, deterministic bytes are ever added; nothing in the +plugin churns per-request content. The one real invalidation is the one-time shift +when the directive/nudge first appears — expected and unavoidable for any fix, and +documented as acceptable. + +Rejected alternative: `session.instructions.entry.put` as the nudge channel. It +renders into the system baseline (ideal placement), but the Promise plugin runtime +allowlists session methods and `instructions` is not exposed (type *and* runtime). +Revisit only if OpenCode exposes it to plugins. + +## 10. Environment + +- Bun 1.4.2. `bun test`, `./node_modules/.bin/tsc --noEmit` (tsgo unavailable here). +- Deps installed: `@ai-sdk/provider@3.0.18`, `@opencode/plugin@2.0.22`, `typescript`. +- Never write a literal DSML token into a source/doc file — the client/provider may + parse it as a phantom tool call. Always build it via `test/fixtures.ts` or use the + `‹DSML›` placeholder. diff --git a/docs/TEST-MATRIX.md b/docs/TEST-MATRIX.md new file mode 100644 index 0000000..90f92b2 --- /dev/null +++ b/docs/TEST-MATRIX.md @@ -0,0 +1,112 @@ +# Test Matrix + +One row per parser behaviour. Each row names a fixture (inline string or file under +`test/fixtures/`) and the assertion. Tests are numbered `V#`/`reject-*` to match +`docs/PATTERNS.md`. + +Fixture convention: a **fixture is raw text built at runtime** so we can inject the real +`U+FF5C` bytes without this repository ever containing them literally; helpers in +`test/fixtures.ts` provide `m` (marker), `invoke`, `param`, `block`. + +## Parser: `parseDsml(text)` + +| ID | Fixture | Expect | +|----|---------|--------| +| `v1-canonical` | `block([invoke("bash", [param("command","ls -la")])])` | 1 call `{name:"bash", args:{command:"ls -la"}}`, 1 range covering block | +| `v2-doubled-bars` | same with doubled bars `m2` | 1 call | +| `v3-ascii-pipes` | same with `|DSML|` | 1 call | +| `v4-marker-space` | `< m " calls>"` (space before `calls`) | 1 call | +| `v5-truncated-tag` | block tag `calls` instead of `tool_calls` | 1 call | +| `v6-name-spacing` | `invoke name = "bash"` | 1 call | +| `v7-tool-call-singular` | block tag `tool_call` | 1 call | +| `v8-function-calls` | block tag `function_calls` | 1 call | +| `v9-orphan-invoke` | `invoke("edit",[param("path","a.kt")])` with **no** block | 1 call, range covers invoke | +| `v10-orphan-parameter` | `param("content","x")` only (no invoke) | 1 call named `unknown`? **NO** — see note | +| `v11-misspelled-closer` | `param("alpha",..)‹BAR›>‹BAR›parameter name="beta"..` | 2 calls: alpha + beta (not swallowed) | +| `v12-runaway-name` | `invoke("record_item {\\ncategory")` | 0 calls, range strips it | +| `v13-undeclared-tool` | `invoke("totally_made_up",[param("x","y")])` | 1 call kept | +| `v14-empty-name` | `invoke("",[param("x","y")])` | 0 calls, no infinite loop | +| `v15-unclosed` | `block-open + invoke("bash",..)` with no closes | calls from complete invokes only; remainder as text | +| `v16-truncated-invoke` | two invokes, 2nd missing `` | 1 call (first) | +| `v18-surrounding` | `"before " + block + " after"` | text before/after preserved, block ranged | +| `v19-multiple` | two invokes in one block + two blocks | 4 calls | +| `v20-code-fence` | `"```\n" + block + "\n```"` | 0 calls, 0 ranges | +| `v21-eos` | `block + eosMarker` | eos in ranges | +| `v22-entities` | param value `a & b "q"` | decoded `a & b "q"` | +| `v23-multiline-value` | `param` value with `\n` and indent | value preserved, only hug newlines removed | +| `v24-json-param` | `param` `string="false"` value `{"a":1}` | args[{a:1}] as object | +| `v25-raw-json-body` | invoke body `{"a":1}` no params | args `{a:1}` | +| `v26-wrapped-tool` etc. | markerless/markered outer + complete inners | call X recovered; plain prose / incomplete nesting untouched | +| `reject-no-marker` | `"hello world"` | 0 calls, 0 ranges | +| `reject-not-dsml` | long prose, no marker | fast path (looksLikeDsml false) | +| `reject-empty-args` | `invoke("bash",[])` | 0 calls (nothing to run) | + +**Note on `v10-orphan-parameter`:** a bare `parameter` with no enclosing invoke has no +tool name, so it cannot become a call. Decision: **strip the noise, emit no call** +(not `unknown`). Executing an `unknown` tool is worse than dropping an unusable +fragment. This row therefore expects `calls: []`, `ranges: [the parameter markup]`. + +## Streaming: `createDsmlStreamMiddleware()` + +| ID | Fixture | Expect | +|----|---------|--------| +| `stream-text-passthrough` | plain text deltas | all `text-delta` emitted, no tool parts | +| `stream-v2-recovered` | doubled-bar block split per token | `tool-input-start/delta/end` + `tool-call`, no leaked text | +| `stream-v17-chunk-split` | opener split `<`/`BAR`/`DSML`/… | recovered, no partial marker leaked | +| `stream-finish-rewrite` | recovered call, source finish `stop` | finish `tool-calls` | +| `stream-finish-untouched` | no DSML | finish stays `stop` | +| `stream-unclosed-fallback` | open block, then `text-end` | buffered text emitted verbatim, finish `stop` | +| `stream-empty-invoke-fallback` | closed block, unparseable | block re-emitted as text, finish `stop` | +| `stream-surrounding` | `"a " + block + " b"` | text `"a b"`, 1 call | +| `stream-multiline-edit` | real captured edit shape (DB V9) | 1 `edit` call with correct args | +| `stream-orphan-invoke` | V9 degradation mid-stream | 1 call | +| `stream-buffer-cap` | >64 KiB unclosed | fallback to text, no unbounded growth | +| `stream-eager-stop` | prose opener, no structure | flushed early as text, nothing held | +| `stream-transition` | reasoning part mid-text | held text flushed first, order preserved | + +## Repair: `recoverStoredMessage(message)` + +| ID | Fixture | Expect | +|----|---------|--------| +| `repair-strip-only` | assistant msg with native tool-call + leaked text | text stripped, calls **not** appended | +| `repair-append` | assistant msg with leaked call, no native | text stripped, call appended, `stopReason:"toolUse"` | +| `repair-thinking` | leaked block in a `thinking` part | thinking stripped | +| `repair-idempotent` | run twice | second run is a no-op | + +> Status: the repair *parser* (`parseDsml` + `stripRanges`) is fully tested. The +> message-level append wrapper is intentionally **not** built: OpenCode persists +> assistant messages durably and the plugin has no safe write path for them, so +> repair happens at request time via history sanitization in the `context` hook +> (`src/index.ts`) instead of mutating stored messages. + +## Config / modes + +| ID | Fixture | Expect | +|----|---------|--------| +| `config-defaults` | `resolveConfig()` | both Zen providers, relaxed, resume 3 | +| `config-strict` | `mode:"strict"` | every rescue off | +| `config-granular` | one rescue off | others stay on | +| `strict-block` | complete block + strict flags | parses | +| `strict-orphan` | orphan invoke + strict flags | no call | +| `prompt-no-marker` | both directives | no live marker bytes, outer-layer rule stated | +| `fallback-*` | decide/messageText/attempts | nudge policy + cap | + +## Wrapper: `wrapDsmlLanguageModel` + +| ID | Fixture | Expect | +|----|---------|--------| +| `wrapper-stream` | fake model, block in stream | 1 tool-call, finish `tool-calls` | +| `wrapper-passthrough` | plain text | untouched | +| `wrapper-generate` | block in generate text | tool-call content part, finish `tool-calls` | + +## Sources / provenance + +Fixtures marked with a source are byte-faithful captures from that source; others are +constructed from the grammar. Provenance is recorded in `test/fixtures.ts` beside each. + +- `[DB]` shapes observed in a real long-context DeepSeek session: wrapped + edit/shell leaks with O(1000)-char multiline values and shell metachars +- `[vLLM]` vLLM #54686 leak classes (runaway name, mis-closed param, misspelled opener) +- `[OpenClaw]` openclaw #128858 doubled full-width bars +- `[Pi]` pi-dsml grammar (mangled ` calls>` variant) +- `[CS]` CherryStudio #14747 chunk-split SSE sample diff --git a/package.json b/package.json new file mode 100644 index 0000000..138f95a --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "opencode-plugin-dsml-fix", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "OpenCode V2 plugin that recovers DeepSeek DSML tool calls leaked as assistant text", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "scripts": { + "test": "bun test", + "typecheck": "tsgo --noEmit", + "check": "bun run typecheck && bun test" + }, + "dependencies": { + "@ai-sdk/provider": "^3.0.8" + }, + "devDependencies": { + "@opencode/plugin": "2.0.22", + "bun-types": "latest", + "typescript": "^5.6.0" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..1128d1e --- /dev/null +++ b/server.ts @@ -0,0 +1,5 @@ +/** + * Plugin server entrypoint (resolved by OpenCode's local-directory loader). + * Implementation lives in `src/`. + */ +export { default } from "./src/index.ts" diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..bb0e25d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,176 @@ +/** + * Plugin configuration. Every behaviour the plugin adds can be narrowed back to a + * simpler version without uninstalling: if the deeper fix misbehaves, turn its flag + * off and the plugin degrades to the safer subset. The invariant is that the plugin + * must never break normal inference — when in doubt it passes traffic through. + */ + +export interface DsmlPluginOptions { + /** + * Provider IDs this plugin applies to. Defaults to the two Zen endpoints where + * the DSML leak was observed. An empty list disables the plugin entirely. + */ + providers?: string[] + /** + * Parsing strictness. + * - `"relaxed"` (default): full catalogue — complete blocks, doubled bars, + * truncated tags, orphan-invoke rescue, mis-closed fallback, raw-JSON body. + * - `"strict"`: complete `tool_calls`/`function_calls` blocks with proper closers + * only. Use when the deeper rescue ever misfires. + */ + mode?: "strict" | "relaxed" + /** + * Granular rescue toggles. Each defaults to the `mode` value; setting one + * explicitly overrides the mode for that area so a single failing rescue can be + * switched off in isolation. + */ + rescue?: { + /** Recover `invoke` blocks with no outer opener (V9). Default: mode. */ + orphanInvoke?: boolean + /** Tolerant re-scan when a value ran past a broken closer (V11). Default: mode. */ + looseParameters?: boolean + /** Bare-`{…}` invoke body parsed as JSON (V25). Default: mode. */ + rawJsonBody?: boolean + /** Wrapped tool: orphan `` with complete inner params (V26). Default: mode. */ + wrappedTool?: boolean + } + /** Streaming swallow cap in bytes (V15/buffer-cap). Default 65536. */ + bufferLimit?: number + /** + * Response-side recovery: turn leaked markup into real tool calls. + * The two transports are independent paths to the model and are named as such. + * (`recovery` is accepted as a deprecated alias; `response` wins when set.) + */ + response?: { + /** Native path: rewrite SSE bytes in `http.response` (opencode-go). Default true. */ + sse?: boolean + /** AI-SDK path: wrap the language model, streaming parts. Default true. */ + aisdkStream?: boolean + /** AI-SDK path: wrap the language model, non-streaming results. Default true. */ + aisdkGenerate?: boolean + } + /** + * Deprecated alias for `response`. When both forms are set, `response` wins. + */ + recovery?: { + /** Alias: response.sse. */ + sse?: boolean + /** Alias: response.aisdkStream. */ + aisdkStream?: boolean + /** Alias: response.aisdkGenerate. */ + aisdkGenerate?: boolean + } + /** + * Request-side shaping: control what the model sees in history and system. + */ + request?: { + /** Strip leaked DSML spans from replayed assistant history. Default true. */ + sanitize?: boolean + /** Preventive system directive on every request. Default true. */ + directive?: boolean + /** Conditional correction nudge in system when history ends in unrecovered DSML. Default true. */ + systemNudge?: boolean + } + /** + * Per-strategy switches (deprecated alias for `recovery` + `request`; kept for + * backward compatibility). When both forms are set, `recovery`/`request` win. + */ + strategies?: { + /** Alias: recovery.aisdkStream. */ + stream?: boolean + /** Alias: recovery.aisdkGenerate. */ + generate?: boolean + /** Alias: recovery.sse. */ + sse?: boolean + /** Alias: request.sanitize. */ + sanitize?: boolean + /** Alias: request.directive. */ + directive?: boolean + /** Alias: request.systemNudge. */ + systemNudge?: boolean + /** Alias: watchdog.enabled. */ + watchdog?: boolean + } + /** Strategy-A auto-resume safety net (idle watchdog). */ + resume?: { + /** + * Master switch (aliases: strategies.watchdog). Default true. + * Precedence: strategies.watchdog > resume.enabled > default. + */ + enabled?: boolean + /** Max nudges per assistant message. Default 3. */ + maxAttempts?: number + /** + * Delivery channel for the watchdog wake. + * - `"system"` (default): the wake is a minimal synthetic ping; the full + * correction rides as a transient system instruction via the context hook. + * - `"user"`: legacy single-message correction nudge as a synthetic user turn. + */ + channel?: "system" | "user" + } + /** Preventive system instruction (alias: request.directive). Default true. */ + prompt?: { + /** + * Master switch. Default true. + * Precedence: request.directive > strategies.directive > prompt.enabled > default. + */ + enabled?: boolean + } + /** Log recoveries to stderr. Default false. */ + debug?: boolean +} + +export interface ResolvedDsmlConfig { + readonly providers: readonly string[] + readonly relaxed: boolean + readonly orphanInvoke: boolean + readonly looseParameters: boolean + readonly rawJsonBody: boolean + readonly wrappedTool: boolean + readonly bufferLimit: number + readonly streamEnabled: boolean + readonly generateEnabled: boolean + readonly sseEnabled: boolean + readonly sanitizeEnabled: boolean + readonly directiveEnabled: boolean + readonly systemNudgeEnabled: boolean + readonly watchdogEnabled: boolean + readonly resumeMaxAttempts: number + readonly resumeChannel: "system" | "user" + readonly debug: boolean +} + +const DEFAULT_PROVIDERS = ["opencode-go", "opencode"] as const + +export function resolveConfig(input: DsmlPluginOptions = {}): ResolvedDsmlConfig { + const relaxed = (input.mode ?? "relaxed") === "relaxed" + const s = input.strategies ?? {} + const response = input.response ?? {} + const recovery = input.recovery ?? {} + const request = input.request ?? {} + return { + providers: input.providers ?? [...DEFAULT_PROVIDERS], + relaxed, + orphanInvoke: input.rescue?.orphanInvoke ?? relaxed, + looseParameters: input.rescue?.looseParameters ?? relaxed, + rawJsonBody: input.rescue?.rawJsonBody ?? relaxed, + wrappedTool: input.rescue?.wrappedTool ?? relaxed, + bufferLimit: input.bufferLimit ?? 64 * 1024, + streamEnabled: response.aisdkStream ?? recovery.aisdkStream ?? s.stream ?? true, + generateEnabled: response.aisdkGenerate ?? recovery.aisdkGenerate ?? s.generate ?? true, + sseEnabled: response.sse ?? recovery.sse ?? s.sse ?? true, + sanitizeEnabled: request.sanitize ?? s.sanitize ?? true, + directiveEnabled: request.directive ?? s.directive ?? input.prompt?.enabled ?? true, + systemNudgeEnabled: request.systemNudge ?? s.systemNudge ?? true, + watchdogEnabled: s.watchdog ?? input.resume?.enabled ?? true, + resumeMaxAttempts: input.resume?.maxAttempts ?? 3, + resumeChannel: input.resume?.channel ?? "system", + debug: input.debug ?? false, + } +} + +/** Whether this model/provider pair is in scope. Matches provider ID exactly. */ +export function appliesToProvider(config: ResolvedDsmlConfig, providerID: string | undefined): boolean { + if (!providerID) return false + return config.providers.includes(providerID) +} diff --git a/src/fallback.ts b/src/fallback.ts new file mode 100644 index 0000000..b94c591 --- /dev/null +++ b/src/fallback.ts @@ -0,0 +1,131 @@ +/** + * Strategy A: auto-resume safety net for turns the streaming middleware could not + * recover. Pure decision logic lives here so it is unit-testable; OpenCode I/O + * (events, session reads, synthetic prompts, storage) is injected by `src/index.ts`. + * + * Policy: when a session goes idle and its latest assistant text ends in DSML markup + * that never became a tool call, send one short correction nudge (see + * `src/prompt.ts`). Attempts are capped per message so a pathological loop cannot + * run away. + */ + +import { looksLikeDsml } from "./grammar.ts" +import { parseDsml, type ParseOptions } from "./parse.ts" +import { recoveryNudge } from "./prompt.ts" + +export interface FallbackDecision { + /** Whether to send the nudge. */ + readonly send: boolean + /** The nudge text, present when `send` is true. */ + readonly text?: string + /** Storage key the attempt was (or would be) counted under. */ + readonly key?: string + /** Why no nudge is sent. */ + readonly reason?: string +} + +/** Storage key for the attempt counter of one assistant message. */ +export function attemptKey(sessionID: string, messageID: string): string { + return `dsml/resume/${sessionID}/${messageID}` +} + +/** + * Minimal wake ping for the `"system"` resume channel. The substantive correction + * rides as a transient system instruction (see `needsSystemNudge`); this message + * only exists to wake the session so a new turn happens. Kept short and factual + * so the user-channel transcript stays clean. + */ +export function wakeText(): string { + return "Resume the interrupted tool call." +} + +export interface HookMessage { + readonly id?: string + readonly role?: string + readonly content?: ReadonlyArray<{ readonly type?: string; readonly text?: unknown }> +} + +export interface SystemNudge { + /** Cap key: message id when present, otherwise a hash of the text. */ + readonly key: string + /** The correction text for the system channel. */ + readonly text: string +} + +/** Stable cap key for a hook message: id when present, content hash otherwise. */ +export function nudgeCapKey(message: HookMessage): string { + if (message.id) return message.id + return contentHash(message) +} + +function contentHash(message: HookMessage): string { + let hash = 0 + for (const part of message.content ?? []) { + const text = part.type === "text" && typeof part.text === "string" ? part.text : "" + for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) | 0 + } + return `hash-${(hash >>> 0).toString(36)}` +} + +/** + * Identify the current user turn from assembled history: the id of the last + * user-role message, falling back to a content hash when ids are absent. This + * is OpenCode's own message identity (`SessionMessage.ID`), so turns line up + * with what the transcript, inbox, and logs already use. Retries within one + * turn share the turn id — attempts are counted separately by the caller. + */ +export function turnId(messages: ReadonlyArray): string { + const last = [...messages].reverse().find((message) => message.role === "user") + if (!last) return "no-user-turn" + return last.id ?? contentHash(last) +} + +/** + * Decide whether the assembled request history ends in unrecovered DSML and needs + * the correction as a transient system instruction. Pure: the caller enforces the + * per-key cap via storage. Returns undefined when no nudge is warranted. + */ +export function needsSystemNudge( + messages: ReadonlyArray, + parseOptions?: ParseOptions, +): SystemNudge | undefined { + const last = [...messages].reverse().find((message) => message.role === "assistant") + if (!last) return undefined + const text = messageText({ role: last.role, content: last.content }) + if (!text || !looksLikeDsml(text)) return undefined + const { calls } = parseDsml(text, parseOptions) + if (calls.length > 0) return undefined + return { key: nudgeCapKey(last), text: recoveryNudge() } +} + +/** + * Decide whether the latest assistant text warrants a correction nudge. + * + * @param text the latest assistant text (already stripped of nothing) + * @param attempts nudges already sent for this message + * @param maxAttempts cap from config + */ +export function decideResume(text: string, attempts: number, maxAttempts: number): FallbackDecision { + if (!looksLikeDsml(text)) return { send: false, reason: "no-marker" } + const { calls } = parseDsml(text) + if (calls.length > 0) return { send: false, reason: "already-recoverable" } + if (attempts >= maxAttempts) return { send: false, reason: "cap-reached" } + return { send: true, text: recoveryNudge() } +} + +/** Extract readable text from an OpenCode session-context message. */ +export function messageText(message: { + readonly role?: string + readonly content?: ReadonlyArray<{ readonly type?: string; readonly text?: unknown }> +}): string | undefined { + if (message.role && message.role !== "assistant") return undefined + const parts = (message.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string") + if (parts.length === 0) return undefined + return parts.map((part) => part.text as string).join("\n") +} + +/** Parse an attempt counter value read from storage. */ +export function parseAttempts(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.floor(value) + return 0 +} diff --git a/src/grammar.ts b/src/grammar.ts new file mode 100644 index 0000000..ada0f4c --- /dev/null +++ b/src/grammar.ts @@ -0,0 +1,136 @@ +/** + * DSML marker grammar — the single source of truth for every regex in this plugin. + * + * See `docs/PATTERNS.md` for the catalogue of variants this grammar accepts. Every + * regex here traces to a numbered row (V1–V25, reject arms) and has a matching test + * in `test/` (see `docs/TEST-MATRIX.md`). + * + * The canonical DeepSeek grammar uses single full-width bars: + * + * + * + * ls + * + * + * + * Gateways that re-decode special tokens emit mangled variants instead: bars double + * up, a space appears where `tool_` was, the block tag is left as a bare `calls`, or + * the outer opener disappears entirely. One marker fragment has to swallow all of + * them without ever matching ordinary prose. + */ + +/** U+FF5C full-width vertical bar, or an ASCII pipe. */ +const BAR = "[\\uFF5C|]" + +/** + * A DSML marker: one or two bars, the word DSML, one or two bars, optional + * whitespace. Accepts `|DSML|`, `||DSML||`, `|DSML|`, and the spaced + * mangled form. (V2, V3, V4) + */ +export const MARKER = `(?:${BAR}{1,2}\\s*DSML\\s*${BAR}{1,2}\\s*)` + +/** Outer block tag names. `calls` is the mangled truncation of `tool_calls`. (V5, V7, V8) */ +export const BLOCK_TAG = "(?:tool_calls|function_calls|tool_call|calls)" + +/** Every tag name that carries a marker, for dangling-marker cleanup. */ +const ANY_TAG = "(?:tool_calls|function_calls|tool_call|calls|invoke|parameter)" + +/** `` and friends. */ +export const blockOpenRe = (): RegExp => new RegExp(`<${MARKER}${BLOCK_TAG}\\s*>`, "giu") + +/** `` and friends, tolerant of an omitted closing marker. (V11) */ +export const blockCloseRe = (): RegExp => new RegExp(``, "giu") + +/** `` with flexible spacing and quote style. (V6) */ +export const invokeOpenRe = (): RegExp => + new RegExp(`<${MARKER}invoke\\s+name\\s*=\\s*["']?([^"'\\s>]+)["']?\\s*>`, "giu") + +/** ``. */ +export const invokeCloseRe = (): RegExp => new RegExp(``, "giu") + +/** `value`. (V22, V23, V24) */ +export const parameterRe = (): RegExp => + new RegExp( + `<${MARKER}parameter\\s+name\\s*=\\s*["']?([^"'\\s>]+)["']?` + + `(?:\\s+string\\s*=\\s*["']?(true|false)["']?)?\\s*>` + + `([\\s\\S]*?)`, + "giu", + ) + +/** + * Tolerant parameter scan used only as a *fallback* when a well-formed + * `parameterRe` value ran away into the next parameter. This is the vLLM #54686 + * 49% leak: a mis-spelled closer `` (bar then `>`) makes the strict + * regex consume the whole next parameter. Here each open tag captures until the + * next parameter open, any invoke/block close, a malformed closer, or end. (V11) + */ +export const parameterLooseRe = (): RegExp => + new RegExp( + `<${MARKER}parameter\\s+name\\s*=\\s*["']?([^"'\\s>]+)["']?` + + `(?:\\s+string\\s*=\\s*["']?(true|false)["']?)?\\s*>` + + `([\\s\\S]*?)` + + `(?=` + + `|$)`, + "giu", + ) + +/** + * A single orphan parameter element (no enclosing invoke), captured with its body + * and either a proper or malformed closer, so the leaked value is removed too. (V10) + */ +export const orphanParameterRe = (): RegExp => + new RegExp( + `<${MARKER}parameter\\b[^>]*>` + + `([\\s\\S]*?)` + + `(?=` + + `|$)`, + "giu", + ) + +/** An orphan `invoke` with no block, captured with its body. (V9) */ +export const orphanInvokeRe = (): RegExp => + new RegExp( + `<${MARKER}invoke\\s+name\\s*=\\s*["']?([^"'\\s>]+)["']?\\s*>` + + `([\\s\\S]*?)` + + `(?=|$)`, + "giu", + ) + +/** + * A wrapped tool call (V26): an orphan `` — marker optional — + * whose body contains at least one COMPLETE inner `` element, + * closed by a mismatched `` / `` or end. This is the dominant + * real-world degradation: the tool name moved into a parameter tag while the + * invoke opener vanished. The nested completes are the authenticity signal: a + * plain prose `` never contains marker-tagged children. + */ +export const wrappedToolRe = (): RegExp => + new RegExp( + `<(?:${MARKER})?parameter\\s+name\\s*=\\s*["']?([^"'\\s>]+)["']?[^>]*>` + + `(?=[\\s\\S]*?${MARKER}parameter\\b)` + + `([\\s\\S]*?)` + + `(?= + new RegExp(`\\n]*)?>?`, "giu") + +/** The end-of-sentence sentinel and its degraded spellings. (V21) */ +export const endOfSentenceRe = (): RegExp => + new RegExp(`<${BAR}{1,2}\\s*end[\\u2581_\\s]?of[\\u2581_\\s]?sentence\\s*${BAR}{1,2}>`, "giu") + +/** + * Cheap pre-check: skip the whole pipeline when the text cannot contain DSML. The + * sentinel and `end-of-sentence` are the two things every variant shares. (reject-not-dsml) + */ +export const looksLikeDsml = (text: string): boolean => + /DSML|end[\u2581_]of[\u2581_]sentence/i.test(text) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f7d3dff --- /dev/null +++ b/src/index.ts @@ -0,0 +1,275 @@ +/** + * opencode-plugin-dsml-fix — recover DeepSeek DSML tool calls that leak as text. + * + * Strategy matrix (each independently configurable, see `src/config.ts`): + * - B1 `strategies.stream`: streaming middleware recovery via ctx.aisdk language hook. + * - B2 `strategies.generate`: non-streaming generate recovery (same hook). + * - `strategies.sanitize`: strip leaked DSML spans from replayed history. + * - `strategies.directive`: preventive correction directive in system. + * - `strategies.systemNudge`: conditional correction nudge in system when history + * ends in unrecovered DSML (transient, never persisted). + * - `strategies.watchdog`: idle watchdog that wakes stalled sessions; the wake + * travels on `resume.channel` ("system" = minimal ping + system nudge, + * "user" = legacy full-text synthetic turn). + * + * Invariant: the plugin must never break normal inference. Unknown shapes pass + * through untouched, and every strategy degrades to a safer subset via config. + */ + +import { Plugin } from "@opencode/plugin" +import { appendFileSync } from "node:fs" +import { appliesToProvider, resolveConfig, type DsmlPluginOptions } from "./config.ts" +import { + attemptKey, + decideResume, + messageText, + needsSystemNudge, + parseAttempts, + turnId, + wakeText, +} from "./fallback.ts" +import { createDsmlStreamMiddleware, wrapDsmlLanguageModel } from "./middleware.ts" +import { parseDsml, redactionNote, stripRangesWithNote, type ParseOptions } from "./parse.ts" +import { preventiveDirective } from "./prompt.ts" +import { rewriteSseResponse } from "./sse.ts" + +const PLUGIN_ID = "dsml" + +export default Plugin.define({ + id: PLUGIN_ID, + setup: async (ctx) => { + try { + appendFileSync( + "/tmp/opencode/dsml-plugin.log", + `${new Date().toISOString()} [dsml] setup enter dir=${ctx.location.directory}\n`, + ) + } catch { + // best effort + } + const config = resolveConfig((ctx.options ?? {}) as DsmlPluginOptions) + if (config.providers.length === 0) return + + const log = (...args: unknown[]) => { + if (!config.debug) return + const line = `[${PLUGIN_ID}] ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}\n` + console.error(line.trimEnd()) + try { + appendFileSync("/tmp/opencode/dsml-plugin.log", `${new Date().toISOString()} ${line}`) + } catch { + // best effort + } + } + const parseOptions: ParseOptions = { + orphanInvoke: config.orphanInvoke, + looseParameters: config.looseParameters, + rawJsonBody: config.rawJsonBody, + wrappedTool: config.wrappedTool, + } + // Active turn per session: OpenCode's own user-message id, captured on the + // request and reused by the response so both log lines carry the same + // `session/message` identity. Attempts count physical requests within a turn. + const turns = new Map() + + // --- Strategies B1+B2: model recovery ------------------------------------ + if (config.streamEnabled || config.generateEnabled) { + await ctx.aisdk.hook("language", (event) => { + try { + appendFileSync( + "/tmp/opencode/dsml-plugin.log", + `${new Date().toISOString()} [dsml] language hook provider=${event.model.providerID} id=${event.model.id} hasLanguage=${!!event.language} hasSdk=${!!event.sdk}\n`, + ) + } catch { + // best effort + } + if (!appliesToProvider(config, event.model.providerID)) return + // Hooks run before the default construction, so `language` is usually + // still unset. Wrap the default model when the SDK exposes a constructor; + // otherwise wrap whatever a previous hook installed. If neither exists, + // pass through — the plugin must never break inference. + const constructed = + typeof event.sdk?.languageModel === "function" + ? event.sdk.languageModel(event.model.modelID ?? event.model.id) + : undefined + const base = event.language ?? constructed + if (!base) { + log("no language model to wrap for", event.model.providerID, event.model.id) + return + } + event.language = wrapDsmlLanguageModel(base, { + ...parseOptions, + stream: config.streamEnabled, + generate: config.generateEnabled, + bufferLimit: config.bufferLimit, + onSettled: (stats) => { + const what = + stats.calls > 0 ? `recovered=${stats.calls}` : stats.sawCandidate ? "held-candidate-no-call" : "passthrough" + log(`response model=${event.model.providerID}/${event.model.id} ${what}`) + }, + }) + log("wrapped language model for", event.model.providerID, event.model.id) + }) + } + + // --- Strategy B3: SSE rewrite on the native protocol path ------------------ + // Catalog providers such as opencode-go resolve to the NATIVE + // openai-compatible route, where `aisdk.language` never fires. The native + // route offers every request to the http hooks, so rewrite the SSE bytes + // there instead. Same parser, same policy, different wire format. + if (config.sseEnabled) { + await ctx.session.hook("http.response", (event) => { + if (!appliesToProvider(config, event.model.providerID)) return + if (event.kind !== "primary") return + const sessionID = event.sessionID + const slot = turns.get(sessionID) ?? { turn: "unknown-turn", attempts: 0 } + slot.attempts += 1 + turns.set(sessionID, slot) + const label = `${sessionID}/${slot.turn}#${slot.attempts}` + try { + event.response = rewriteSseResponse(event.response, { + ...parseOptions, + bufferLimit: config.bufferLimit, + onSettled: (stats) => { + const what = + stats.calls > 0 + ? `recovered=${stats.calls}` + : stats.sawCandidate + ? "held-candidate-no-call" + : "passthrough" + log(`response ${label} ${what}`) + }, + }) + } catch (error) { + log("sse rewrite skipped:", error instanceof Error ? error.message : String(error)) + } + }) + } + + // --- Request strategies: directive + sanitize + system nudge ------------ + await ctx.session.hook("context", async (event) => { + if (!appliesToProvider(config, event.model.providerID)) return + // A new user message starts a new turn: reset the attempt counter so the + // response lines for this turn share one `session/message` identity. + const turn = turnId(event.messages) + const seen = turns.get(event.sessionID) + if (!seen || seen.turn !== turn) turns.set(event.sessionID, { turn, attempts: 0 }) + const label = `${event.sessionID}/${turn}` + let sanitizedMsgs = 0 + let sanitizedParts = 0 + let directive = false + let nudge = false + if (config.directiveEnabled) { + event.system.push({ type: "text", text: preventiveDirective() }) + directive = true + } + if (config.sanitizeEnabled) { + // Strip leaked DSML spans from replayed assistant text so the model does + // not imitate its own degraded output. A short inline note marks each + // redaction so the transcript records that something was tried and + // dropped, without keeping the broken markup as a bad example. Never + // synthesize calls here: history calls already ran (or never will), and + // appending would double-execute. + for (const message of event.messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue + let touched = false + for (const part of message.content) { + if (typeof part !== "object" || part === null) continue + const text = (part as { type?: unknown; text?: unknown }).text + if ((part as { type?: unknown }).type !== "text" || typeof text !== "string") continue + const { calls, ranges } = parseDsml(text, parseOptions) + if (ranges.length === 0) continue + ;(part as { text: string }).text = stripRangesWithNote( + text, + ranges, + redactionNote(calls.map((call) => call.name)), + ) + touched = true + sanitizedParts++ + } + if (touched) sanitizedMsgs++ + } + } + if (config.systemNudgeEnabled) { + const need = needsSystemNudge(event.messages, parseOptions) + if (need) { + const key = attemptKey(event.sessionID, need.key) + const attempts = parseAttempts(await ctx.storage.get(key)) + if (attempts >= config.resumeMaxAttempts) { + log("system nudge cap reached for", need.key) + } else { + await ctx.storage.set(key, attempts + 1) + event.system.push({ type: "text", text: need.text }) + nudge = true + } + } + } + // One line per request turn: what each request layer actually did. + if (sanitizedMsgs > 0 || directive || nudge) { + log( + `request ${label} sanitized=${sanitizedMsgs}msgs/${sanitizedParts}parts directive=${directive ? "yes" : "no"} nudge=${nudge ? "yes" : "no"}`, + ) + } + }) + + // --- Strategy: idle watchdog ---------------------------------------------- + if (!config.watchdogEnabled) return + const controller = new AbortController() + void (async () => { + try { + for await (const raw of ctx.event.subscribe({ signal: controller.signal })) { + const type = (raw as { type?: unknown }).type + const idle = type === "session.idle" || (type === "session.status" && (raw as { status?: unknown }).status === "idle") + // `session.status` carries `{ status: { type: "idle" } }` in V2. + const statusIdle = + type === "session.status" && + typeof (raw as { status?: unknown }).status === "object" && + (raw as { status?: { type?: unknown } }).status?.type === "idle" + if (!idle && !statusIdle) continue + const sessionID = (raw as { sessionID?: unknown }).sessionID + if (typeof sessionID !== "string") continue + await maybeWake(ctx, config, log, sessionID).catch((error) => log("wake failed", error)) + } + } catch { + // Aborted on unload. + } + })() + return () => controller.abort() + }, +}) + +type Ctx = Parameters[0]["setup"]>[0] +type Config = ReturnType + +async function maybeWake( + ctx: Ctx, + config: Config, + log: (...args: unknown[]) => void, + sessionID: string, +): Promise { + const messages = await ctx.session.context({ sessionID }) + const last = [...messages].reverse().find((message) => message.type === "assistant") + if (!last || last.type !== "assistant") return + const text = messageText({ + role: last.type, + content: last.content as ReadonlyArray<{ readonly type?: string; readonly text?: string }>, + }) + if (!text) return + const key = attemptKey(sessionID, last.id) + const attempts = parseAttempts(await ctx.storage.get(key)) + const decision = decideResume(text, attempts, config.resumeMaxAttempts) + if (!decision.send || !decision.text) { + if (decision.reason && decision.reason !== "no-marker") log(decision.reason, "for", last.id) + return + } + await ctx.storage.set(key, attempts + 1) + if (config.resumeChannel === "user") { + // Legacy: the full correction travels as a synthetic user turn. + await ctx.session.synthetic({ sessionID, text: decision.text }) + } else { + // System channel: minimal wake ping; the context hook attaches the full + // correction as a transient system instruction on the woken turn. + await ctx.session.synthetic({ sessionID, text: wakeText(), description: "dsml-retry wake" }) + } + log("sent wake for", last.id, `attempt ${attempts + 1} channel=${config.resumeChannel}`) +} + +export { createDsmlStreamMiddleware } diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..e53efd6 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,447 @@ +/** + * Streaming DSML recovery as an AI SDK `LanguageModelV3Middleware`. + * + * This is the strategy-B seam: OpenCode installs it via `ctx.aisdk.hook("language")` + * and wraps the provider model with `wrapLanguageModel`. Everything downstream of + * this middleware is OpenCode's own AI-SDK lowering (`packages/core/src/aisdk.ts`), + * which already converts `tool-input-*` / `tool-call` / `finish` parts into real tool + * execution. We therefore only have to speak the AI SDK part vocabulary — see + * `docs/PATTERNS.md` §4 and `docs/TEST-MATRIX.md` for the cases. + * + * Design (ported from CherryStudio #14747, with pi-dsml + openclaw grammar): + * - Only `text-delta` is buffered; reasoning, tool, and metadata parts pass through. + * - A 64 KiB swallow cap prevents unbounded buffering if a block never closes. + * - Unclosed / unparseable blocks are re-emitted as text and `finish` stays `stop`. + * - Recovered calls rewrite `finishReason.unified` from `stop` to `tool-calls`. + */ + +import type { + LanguageModelV3, + LanguageModelV3Content, + LanguageModelV3Middleware, + LanguageModelV3StreamPart, +} from "@ai-sdk/provider" +import { parseDsml, type DsmlToolCall, type ParseOptions } from "./parse.ts" + +export interface DsmlMiddlewareOptions extends ParseOptions { + /** Streaming swallow cap in bytes. Default 65536. */ + readonly bufferLimit?: number + /** Enable streaming recovery. Default true. */ + readonly stream?: boolean + /** Enable non-streaming generate recovery. Default true. */ + readonly generate?: boolean + /** Called once when the stream ends, with the per-turn outcome. */ + readonly onSettled?: (stats: DsmlStats) => void +} + +/** Per-turn outcome for logging: what the middleware actually did. */ +export interface DsmlStats { + /** Tool calls recovered from leaked markup. */ + readonly calls: number + /** Whether any DSML candidate was ever held (false = pure passthrough). */ + readonly sawCandidate: boolean +} + +const DEFAULT_BUFFER_LIMIT = 64 * 1024 + +/** + * Trailing suffix of `buffer` (after its last `<`) that could still grow into a + * tag opener. A tag split across deltas (`<`, ` 64 || tail.includes(">")) return 0 + if (!TAG_PREFIX.test(tail)) return 0 + return tail.length +} + +/** A `<`-led prefix that may still become a DSML/wrapped open or close tag. */ +const TAG_PREFIX = + /^(?:<)\/?[\uFF5C|]{0,2}\s*(?:D?S?M?L?[\uFF5C|]{0,2}\s*)?(?:p?a?r?a?m?e?t?e?r?|i?n?v?o?k?e?|t?o?o?l?_?c?a?l?l?s?|f?u?n?c?t?i?o?n?_?c?a?l?l?s?|c?a?l?l?s?)?(?:\s*n?a?m?e?\s*=?\s*["']?[^<>"']*)?\s*$/iu + +let toolCallSequence = 0 + +/** + * Unique tool-call id across parallel streams and sessions. A per-stream index + * alone could collide when two streams recover a call in the same millisecond, + * so the id combines time, a process-wide sequence, and randomness. + */ +function toolCallId(index: number): string { + const seq = toolCallSequence++ + const rand = Math.random().toString(36).slice(2, 8) + return `dsml_${Date.now().toString(36)}_${seq}_${index}_${rand}` +} + +/** Best-effort id for parts that carry one (used only for fallback attribution). */ +function textIdOf(chunk: LanguageModelV3StreamPart): string | undefined { + return typeof (chunk as { id?: unknown }).id === "string" ? (chunk as { id: string }).id : undefined +} + +/** + * Buffering policy (streaming): hold text ONLY from the first byte that could + * open a tool call. Everything before it is emitted immediately, every chunk. + * Candidates, in order: + * 1. A DSML-marked opener (`` with or without a marker — + * the outer shell of the dominant real-world degradation (V26). Plain prose + * almost never contains this shape, and the eager-stop below bounds the hold. + */ +const WRAPPED_CANDIDATE = /<(?:[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*)?parameter\s+name\s*=\s*["']?[^\s>"']+/iu +const DSML_OPENER = /<[\uFF5C|]{1,2}\s*DSML/iu + +/** Earliest index in `buffer` that could open a call, or -1 when there is none. */ +function holdStart(buffer: string): number { + let earliest = -1 + for (const re of [DSML_OPENER, WRAPPED_CANDIDATE]) { + re.lastIndex = 0 + const found = re.exec(buffer) + if (found && (earliest === -1 || found.index < earliest)) earliest = found.index + } + return earliest +} + +/** + * Eager-stop window: when holding a candidate that has not yet produced a + * complete parameter-open (i.e. we are not inside a value), give up after this + * many held chars and flush as text. Prevents holding prose that merely looked + * like an opener. Once a complete parameter is open, only the 64 KiB cap bounds + * the hold, because values can be arbitrarily long. + */ +const EAGER_WINDOW = 1024 + +/** A complete parameter open tag exists in the held text: we may be inside a value. */ +const PARAM_OPEN = /<[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*parameter\s+name\s*=/iu + +/** + * Recover tool calls from one complete text buffer. Returns the calls and the text + * with their ranges removed. Reuses the exact same parser as the repair path. + */ +export function recoverFromText( + text: string, + options?: ParseOptions, +): { calls: Array<{ name: string; arguments: Record }>; text: string } { + const { calls, ranges } = parseDsml(text, options) + if (calls.length === 0 && ranges.length === 0) return { calls: [], text } + let cleaned = "" + let cursor = 0 + for (const range of ranges) { + if (range.start > cursor) cleaned += text.slice(cursor, range.start) + cursor = Math.max(cursor, range.end) + } + cleaned += text.slice(cursor) + return { calls, text: cleaned } +} + +export function createDsmlStreamMiddleware(input?: DsmlMiddlewareOptions): LanguageModelV3Middleware { + const bufferLimit = input?.bufferLimit ?? DEFAULT_BUFFER_LIMIT + const parseOptions: ParseOptions = { + orphanInvoke: input?.orphanInvoke, + looseParameters: input?.looseParameters, + rawJsonBody: input?.rawJsonBody, + wrappedTool: input?.wrappedTool, + } + return { + specificationVersion: "v3", + wrapStream: async ({ doStream }) => { + const { stream, ...rest } = await doStream() + + let textBuffer = "" + let activeTextId: string | null = null + let recovered = false + let recoveredInBlock = false + let sawCandidate = false + let settled = false + let counter = 0 + + const settle = () => { + if (settled) return + settled = true + input?.onSettled?.({ calls: counter, sawCandidate }) + } + + const enqueueCall = (controller: TransformStreamDefaultController, call: DsmlToolCall) => { + const id = toolCallId(counter++) + const input = JSON.stringify(call.arguments) + controller.enqueue({ type: "tool-input-start", id, toolName: call.name }) + controller.enqueue({ type: "tool-input-delta", id, delta: input }) + controller.enqueue({ type: "tool-input-end", id }) + controller.enqueue({ type: "tool-call", toolCallId: id, toolName: call.name, input }) + recovered = true + recoveredInBlock = true + } + + /** True when `text` is only dangling markup/closers and whitespace. */ + const isMarkupNoise = (text: string): boolean => { + const stripped = text + .replace(/<\/?[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls|invoke|parameter)(?:\s+[^>\n]*)?>?/giu, "") + // Leading fragment of a closer split across chunks (`| calls>`). + .replace(/^[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls|invoke|parameter)\s*>?/iu, "") + .replace(/\s+/g, "") + return stripped.length === 0 && text.length > 0 + } + + /** + * Decide what to do with the current buffer. + * + * Buffering policy: hold text ONLY from the first byte that could open a + * tool call (a DSML opener, a partial opener suffix, or a wrapped-tool + * candidate). Everything before it is emitted immediately. A held + * candidate that never develops call structure hits the eager-stop window + * and is flushed as text. `final` resolves whatever remains — recovering + * complete calls, emitting the rest verbatim, so the buffer always ends + * empty and no text is ever lost. + */ + const drain = (controller: TransformStreamDefaultController, textId: string, final: boolean) => { + if (!textBuffer) return + + const start = holdStart(textBuffer) + if (start === -1) { + // No candidate: hold back only a suffix that could still grow into an + // opener. Everything before it is safe to emit. + const keep = final ? 0 : partialOpenerSuffix(textBuffer) + if (keep > 0) { + sawCandidate = true + const emit = textBuffer.slice(0, textBuffer.length - keep) + textBuffer = textBuffer.slice(textBuffer.length - keep) + if (emit.length > 0) controller.enqueue({ type: "text-delta", id: textId, delta: emit }) + return + } + // Trailing markup noise after an already-recovered call (a mismatched + // `` / `` that arrived in a later chunk) is dropped + // rather than leaked: it is pure closers, never prose. + if (recoveredInBlock && isMarkupNoise(textBuffer)) { + textBuffer = "" + return + } + controller.enqueue({ type: "text-delta", id: textId, delta: textBuffer }) + textBuffer = "" + return + } + + // Emit the safe head; hold from the candidate on. + if (start > 0) { + controller.enqueue({ type: "text-delta", id: textId, delta: textBuffer.slice(0, start) }) + textBuffer = textBuffer.slice(start) + } + sawCandidate = true + + // A complete, closed block is resolvable now. Wrapped-tool shapes end in + // a mismatched `` / `` rather than a block closer — + // those count as completion ONLY when no block opener is pending + // (otherwise the trailing block closer would arrive later and leak). + const hasBlockOpen = + /<[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls)\s*>/iu.test( + textBuffer, + ) + const closed = + /<\/[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls)\s*>/iu.test( + textBuffer, + ) || + (!hasBlockOpen && + /<\/[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:invoke|calls)\s*>/iu.test(textBuffer)) + const overCap = textBuffer.length > bufferLimit + if (!final && closed) { + emitResolved(controller, textId, false) + return + } + // Eager stop: a candidate that is not developing call structure (no + // complete parameter open yet, so we are not inside a long value) and has + // grown past the window is prose — flush it and resume scanning fresh. + PARAM_OPEN.lastIndex = 0 + const insideValue = PARAM_OPEN.test(textBuffer) + if (!final && !overCap && !insideValue && textBuffer.length > EAGER_WINDOW) { + controller.enqueue({ type: "text-delta", id: textId, delta: textBuffer }) + textBuffer = "" + return + } + // Nothing complete yet and we are still streaming: hold, until the cap. + if (!final && !overCap) return + // Final, over-cap, or unresolved at end: emit everything (recovering any + // complete calls that do exist, but never swallowing an unclosed tail). + if (overCap && !closed) { + controller.enqueue({ type: "text-delta", id: textId, delta: textBuffer }) + textBuffer = "" + return + } + emitResolved(controller, textId, true) + } + + const emitResolved = (controller: TransformStreamDefaultController, textId: string, isFinal: boolean) => { + const original = textBuffer + const { calls, text } = recoverFromText(textBuffer, parseOptions) + textBuffer = "" + // Streaming rule: never strip unless we recovered something. An unclosed or + // unparseable tail is emitted verbatim so no content is lost mid-stream. + if (calls.length === 0) { + controller.enqueue({ type: "text-delta", id: textId, delta: original }) + return + } + // The buffer can end mid-tag (a trailing closer already streaming in when + // resolution fired). Hold the partial back while the stream continues; + // at finality it ships verbatim (never swallow). + let cleaned = text + if (!isFinal) { + const holdBack = partialOpenerSuffix(cleaned) + if (holdBack > 0) { + textBuffer = cleaned.slice(cleaned.length - holdBack) + cleaned = cleaned.slice(0, cleaned.length - holdBack) + } + } + if (cleaned.length > 0) controller.enqueue({ type: "text-delta", id: textId, delta: cleaned }) + for (const call of calls) enqueueCall(controller, call) + } + + return { + stream: stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (chunk.type === "text-start") { + activeTextId = chunk.id + recoveredInBlock = false + controller.enqueue(chunk) + return + } + if (chunk.type === "text-end") { + drain(controller, chunk.id, true) + controller.enqueue(chunk) + activeTextId = null + return + } + if (chunk.type === "finish") { + drain(controller, activeTextId ?? "dsml-fallback", true) + const rewritten = + recovered && chunk.finishReason.unified === "stop" + ? { ...chunk, finishReason: { ...chunk.finishReason, unified: "tool-calls" as const } } + : chunk + controller.enqueue(rewritten) + return + } + if (chunk.type !== "text-delta") { + // Transition: the stream is moving to reasoning, a native tool + // part, or another block. Flush held text FIRST so transcript + // order is preserved — buffered text must never leak out after + // the parts that follow it. + if (textBuffer) drain(controller, activeTextId ?? textIdOf(chunk) ?? "dsml-fallback", true) + controller.enqueue(chunk) + return + } + + const id = chunk.id + if (activeTextId === null) activeTextId = id + textBuffer += chunk.delta + drain(controller, id, false) + }, + flush(controller) { + drain(controller, activeTextId ?? "dsml-fallback", true) + settle() + }, + }), + ), + ...rest, + } + }, + } +} + +/** + * Recover DSML calls from a non-streaming generate result. Text parts are split: + * prose stays text, each recovered call becomes a `tool-call` content part. The + * finish reason flips to `tool-calls` when at least one call was recovered. + */ +export function recoverGenerateResult( + content: ReadonlyArray, + finishReason: { unified: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other"; raw: string | undefined }, + options?: ParseOptions, +): { content: LanguageModelV3Content[]; finishReason: typeof finishReason; recovered: number } { + const next: LanguageModelV3Content[] = [] + let recovered = 0 + let counter = 0 + for (const part of content) { + if (part.type !== "text") { + next.push(part) + continue + } + const { calls, ranges } = parseDsml(part.text, options) + if (calls.length === 0 && ranges.length === 0) { + next.push(part) + continue + } + let cursor = 0 + let kept = "" + for (const range of ranges) { + if (range.start > cursor) kept += part.text.slice(cursor, range.start) + cursor = Math.max(cursor, range.end) + } + kept += part.text.slice(cursor) + // Streaming rule applies here too: no recovered call means the text goes back + // untouched rather than stripped. + if (calls.length === 0) { + next.push(part) + continue + } + if (kept.length > 0) next.push({ ...part, text: kept }) + for (const call of calls) { + recovered++ + next.push({ + type: "tool-call", + toolCallId: toolCallId(counter++), + toolName: call.name, + input: JSON.stringify(call.arguments), + }) + } + } + if (recovered === 0) return { content: [...content], finishReason, recovered } + return { + content: next, + finishReason: finishReason.unified === "stop" ? { ...finishReason, unified: "tool-calls" } : finishReason, + recovered, + } +} + +/** + * Wrap a `LanguageModelV3` so every `doStream`/`doGenerate` passes through DSML + * recovery. This is a dependency-free alternative to `wrapLanguageModel` from the + * `ai` package: the returned object implements the same `LanguageModelV3` surface, + * so OpenCode's AI-SDK lowering consumes it unchanged. + */ +export function wrapDsmlLanguageModel(model: LanguageModelV3, input?: DsmlMiddlewareOptions): LanguageModelV3 { + const streamEnabled = input?.stream ?? true + const generateEnabled = input?.generate ?? true + if (!streamEnabled && !generateEnabled) return model + const middleware = createDsmlStreamMiddleware(input) + const parseOptions: ParseOptions = { + orphanInvoke: input?.orphanInvoke, + looseParameters: input?.looseParameters, + rawJsonBody: input?.rawJsonBody, + wrappedTool: input?.wrappedTool, + } + return { + specificationVersion: "v3", + provider: model.provider, + modelId: model.modelId, + supportedUrls: model.supportedUrls, + async doGenerate(options) { + const result = await model.doGenerate(options) + if (!generateEnabled) return result + const recovered = recoverGenerateResult(result.content, result.finishReason, parseOptions) + input?.onSettled?.({ calls: recovered.recovered, sawCandidate: recovered.recovered > 0 }) + if (recovered.recovered === 0) return result + return { ...result, content: recovered.content, finishReason: recovered.finishReason } + }, + async doStream(options) { + if (!streamEnabled) return model.doStream(options) + const wrapped = await middleware.wrapStream!({ + doStream: () => model.doStream(options), + doGenerate: () => model.doGenerate(options), + params: options, + model, + }) + return wrapped + }, + } +} diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..ae247c5 --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,362 @@ +/** + * Parse DSML tool-call blocks out of assistant text. + * + * Returns the recovered calls plus every character range that should be removed from + * the text, so a caller can both execute the calls and leave a clean transcript. + * Shared by the streaming middleware and the stored-transcript repair path + * (see `docs/PATTERNS.md` §4). Every behaviour maps to `docs/TEST-MATRIX.md`. + */ + +import { + blockCloseRe, + blockOpenRe, + danglingMarkerRe, + endOfSentenceRe, + invokeCloseRe, + invokeOpenRe, + looksLikeDsml, + orphanInvokeRe, + orphanParameterRe, + parameterLooseRe, + parameterRe, + wrappedToolRe, +} from "./grammar.ts" + +export interface DsmlToolCall { + name: string + arguments: Record +} + +export interface Range { + start: number + end: number +} + +export interface ParseResult { + calls: DsmlToolCall[] + /** Ranges to delete from the text, sorted and non-overlapping. */ + ranges: Range[] +} + +const EMPTY: ParseResult = { calls: [], ranges: [] } + +export interface ParseOptions { + /** Recover `invoke` blocks with no outer opener (V9). Default true. */ + readonly orphanInvoke?: boolean + /** Tolerant re-scan when a value ran past a broken closer (V11). Default true. */ + readonly looseParameters?: boolean + /** Bare-`{…}` invoke body parsed as JSON (V25). Default true. */ + readonly rawJsonBody?: boolean + /** + * Recover a wrapped tool call: orphan `` (marker optional) + * containing complete inner parameters (V26, the dominant real-world shape). + * Default true. + */ + readonly wrappedTool?: boolean +} + +export function parseDsml(text: string, input?: ParseOptions): ParseResult { + // NB: explicit `??` defaults, not spread — `{...defaults, ...{flag: undefined}}` + // would clobber the default with undefined and silently disable the rescue. + const options: Required = { + orphanInvoke: input?.orphanInvoke ?? true, + looseParameters: input?.looseParameters ?? true, + rawJsonBody: input?.rawJsonBody ?? true, + wrappedTool: input?.wrappedTool ?? true, + } + if (!looksLikeDsml(text)) return EMPTY + + const calls: DsmlToolCall[] = [] + const ranges: Range[] = [] + + const blockOpens = matchAll(text, blockOpenRe()) + for (const open of blockOpens) { + if (insideCodeFence(text, open.start)) continue + + const bodyStart = open.end + const close = firstMatch(text, blockCloseRe(), bodyStart) + // A truncated response can end mid-block. Parse only up to the last `` + // so a half-written call is never recovered, but strip to the end — nothing + // after an unclosed block is prose. (V15) + const bodyEnd = close ? close.start : (lastMatch(text, invokeCloseRe(), bodyStart)?.end ?? bodyStart) + + calls.push(...parseInvokes(text.slice(bodyStart, bodyEnd), options)) + ranges.push({ start: open.start, end: close ? close.end : text.length }) + } + + // Orphan/incomplete forms: an invoke with no block, or a parameter with no invoke, + // or a runaway name. We already consumed every real block, so scan the *remaining* + // text for invoke/parameter markers that were not inside a block. (V9, V10, V12) + // Disabled in strict mode: only complete blocks are trusted there. + // NB: orphans skip only REAL block ranges. Orphan ranges added by earlier orphans + // must not shadow later ones — the wrapped-tool rescue (V26) starts at the same + // offset as its outer orphan-parameter range and would otherwise be skipped. + const blockRanges = [...ranges] + const insideBlock = (index: number) => blockRanges.some((r) => index >= r.start && index < r.end) + if (options.orphanInvoke) { + for (const orphan of findOrphans(text, options)) { + if (insideBlock(orphan.start)) continue + if (insideCodeFence(text, orphan.start)) continue + ranges.push({ start: orphan.start, end: orphan.end }) + calls.push(...orphan.calls) + } + } + + // Markers that never formed a usable block (stray opens, orphan closers) are still + // noise in the transcript. (V10 remainder, V12 remainder) + for (const marker of matchAll(text, danglingMarkerRe())) { + if (insideCodeFence(text, marker.start)) continue + if (ranges.some((r) => marker.start >= r.start && marker.start < r.end)) continue + ranges.push(marker) + } + + for (const eos of matchAll(text, endOfSentenceRe())) { + if (insideCodeFence(text, eos.start)) continue + ranges.push(eos) + } + + if (calls.length === 0 && ranges.length === 0) return EMPTY + return { calls, ranges: mergeRanges(ranges) } +} + +/** Remove `ranges` from `text` and tidy the whitespace they leave behind. */ +export function stripRanges(text: string, ranges: Range[]): string { + let result = "" + let cursor = 0 + for (const range of ranges) { + if (range.start > cursor) result += text.slice(cursor, range.start) + cursor = Math.max(cursor, range.end) + } + result += text.slice(cursor) + return result + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim() +} + +/** Short inline note left where history markup was redacted (never marker bytes). */ +export function redactionNote(toolNames: string[]): string { + const names = [...new Set(toolNames)].filter(Boolean).slice(0, 3).join(",") + return names.length > 0 ? `[removed malformed ${names} call]` : "[removed malformed markup]" +} + +/** + * Remove `ranges` from `text`, leaving a short inline note so the transcript + * records that something was tried and dropped — without keeping the broken + * markup as an example for future calls to imitate. The note is placed at the + * FIRST removed span only; remaining spans are removed silently, so one message + * yields one note even when several fragments are stripped. + * The note must never contain marker bytes (asserted by tests). + */ +export function stripRangesWithNote(text: string, ranges: Range[], note?: string): string { + const marker = note ?? "[malformed tool call removed]" + let result = "" + let cursor = 0 + let noted = false + for (const range of ranges) { + if (range.start > cursor) result += text.slice(cursor, range.start) + if (!noted) { + result += marker + noted = true + } + cursor = Math.max(cursor, range.end) + } + result += text.slice(cursor) + return result + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim() +} + +interface Orphan extends Range { + calls: DsmlToolCall[] +} + +/** + * Find invoke/parameter fragments that appear outside a complete block. This is the + * exact degradation in the user's session DB: the outer `tool_calls` opener and the + * proper `invoke` opener are missing, leaving an orphan ``. + * + * A true orphan `invoke` yields a call; a bare `parameter` has no tool name so it only + * produces a range (strip the noise, execute nothing) — see PATTERNS.md V10 note. + */ +function findOrphans(text: string, options: Required): Orphan[] { + const orphans: Orphan[] = [] + + // Orphan invokes with a body: `…`. + for (const open of matchAll(text, orphanInvokeRe())) { + const name = open.groups[0]?.trim() + const body = open.groups[1] ?? "" + const args = parseParameters(body, options) + const calls = name && Object.keys(args).length > 0 ? [{ name, arguments: args }] : [] + orphans.push({ start: open.start, end: open.end, calls }) + } + + // Orphan parameters (no invoke): range the tag *and* the leaked value body so the + // transcript is clean. No call is produced — there is no tool name. (V10) + const parameterBodies = matchAll(text, orphanParameterRe()) + for (const p of parameterBodies) { + orphans.push({ start: p.start, end: p.end, calls: [] }) + } + + // Wrapped tool calls (V26): orphan `` containing complete + // inner parameters. The nesting is the authenticity signal — plain prose never + // contains marker-tagged children. Recovers the dominant real-world shape. + // Requires at least one STRICT-complete inner parameter: a half-written inner + // value must not become a call (V16 philosophy). + if (options.wrappedTool) { + for (const w of matchAll(text, wrappedToolRe())) { + const name = w.groups[0]?.trim() + if (!name) continue + const body = w.groups[1] ?? "" + if (matchAll(body, parameterRe()).length === 0) continue + const args = parseParameters(body, options) + if (Object.keys(args).length === 0) continue + orphans.push({ start: w.start, end: w.end, calls: [{ name, arguments: args }] }) + } + } + + return orphans +} + +function parseInvokes(body: string, options: Required): DsmlToolCall[] { + const calls: DsmlToolCall[] = [] + const opens = matchAll(body, invokeOpenRe()) + + for (let i = 0; i < opens.length; i++) { + const open = opens[i]! + const name = open.groups[0]?.trim() + if (!name) continue // V14: empty or runaway name + + // Prefer the real closing tag; otherwise stop at the next invoke so a truncated + // tail does not swallow the following call. (V11, V16) + const close = firstMatch(body, invokeCloseRe(), open.end) + const nextOpen = opens[i + 1] + const end = close && (!nextOpen || close.start < nextOpen.start) ? close.start : (nextOpen?.start ?? body.length) + + const args = parseParameters(body.slice(open.end, end), options) + if (Object.keys(args).length === 0) continue // reject-empty-args + calls.push({ name, arguments: args }) + } + + return calls +} + +function parseParameters(body: string, options: Required): Record { + const strict = collectParameters(body, parameterRe()) + // If the strict scan produced nothing, or one value ran away into the next + // parameter (the vLLM mis-closed-closer leak), retry with the tolerant scan. (V11) + // The tolerant scan is a relaxed-mode rescue; strict mode keeps the strict result. + const ranAway = Object.values(strict).some((value) => typeof value === "string" && value.includes("DSML")) + const picked = + options.looseParameters && (Object.keys(strict).length === 0 || ranAway) + ? collectParameters(body, parameterLooseRe(), true) + : strict + + if (Object.keys(picked).length > 0) return picked + if (!options.rawJsonBody) return picked + // Raw JSON body fallback: an invoke whose body is a bare JSON object. (V25) + const trimmed = body.trim() + if (!trimmed.startsWith("{")) return picked + try { + const parsed = JSON.parse(trimmed) as unknown + if (isRecord(parsed) && Object.keys(parsed).length > 0) return parsed + } catch { + // fall through + } + return picked +} + +function collectParameters(body: string, re: RegExp, dropRunaway = false): Record { + const args: Record = {} + // Positions of every mis-spelled closer `` in the body. A parameter + // whose match spans one of these is the vLLM mis-closed leak: its value ran past a + // broken boundary, so it cannot be trusted and is dropped. (V11) + const brokenClosers = matchAll(body, /<\/[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}>/giu).map((m) => m.start) + + for (const p of matchAll(body, re)) { + const key = p.groups[0]?.trim() + if (!key) continue + const value = p.groups[2] ?? "" + if (dropRunaway && /[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*parameter\b/iu.test(value)) continue + if (dropRunaway && brokenClosers.some((start) => start >= p.start && start <= p.end)) continue + args[key] = decodeValue(value.replace(/<\/[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}>$/iu, ""), p.groups[1]) + } + return args +} + +/** + * `string="false"` means JSON; anything else (including absent) means a raw string. + * Only the newline that hugs each tag is removed — the value itself (heredocs, + * indentation, trailing spaces) is preserved verbatim. (V23, V24) + */ +function decodeValue(raw: string, stringAttr: string | undefined): unknown { + const trimmedHug = raw.replace(/^\r?\n/, "").replace(/\r?\n$/, "") + if (stringAttr !== "false") return decodeEntities(trimmedHug) + try { + return JSON.parse(trimmedHug.trim()) + } catch { + return decodeEntities(trimmedHug) + } +} + +/** Decode the HTML entities gateways introduce when re-serialising values. (V22) */ +function decodeEntities(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&") +} + +interface Match extends Range { + groups: (string | undefined)[] +} + +function matchAll(text: string, re: RegExp): Match[] { + const matches: Match[] = [] + for (const match of text.matchAll(re)) { + if (match.index === undefined || match[0].length === 0) continue + matches.push({ start: match.index, end: match.index + match[0].length, groups: match.slice(1) }) + } + return matches +} + +function firstMatch(text: string, re: RegExp, from: number): Match | undefined { + return matchAll(text.slice(from), re).map((m) => offset(m, from))[0] +} + +function lastMatch(text: string, re: RegExp, from: number): Match | undefined { + const matches = matchAll(text.slice(from), re) + return matches.length > 0 ? offset(matches[matches.length - 1]!, from) : undefined +} + +function offset(match: Match, by: number): Match { + return { start: match.start + by, end: match.end + by, groups: match.groups } +} + +/** Sort, drop ranges contained in earlier ones, and coalesce overlaps. */ +function mergeRanges(ranges: Range[]): Range[] { + const sorted = [...ranges].sort((a, b) => a.start - b.start || b.end - a.end) + const merged: Range[] = [] + for (const range of sorted) { + const last = merged[merged.length - 1] + if (last && range.start <= last.end) { + last.end = Math.max(last.end, range.end) + continue + } + merged.push({ ...range }) + } + return merged +} + +/** An odd number of fences before `index` means we are inside a code block. (V20) */ +function insideCodeFence(text: string, index: number): boolean { + const fences = text.slice(0, index).match(/```/g) + return fences !== null && fences.length % 2 === 1 +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 0000000..a18f621 --- /dev/null +++ b/src/prompt.ts @@ -0,0 +1,53 @@ +/** + * Correction directives: the text the plugin shows the model so it emits tool calls + * in the correct format with the outer XML layer. + * + * Writing rule: these strings are *sent to the model*, so they describe the grammar + * without containing a live DSML token. A literal token here would be parsed as a + * phantom call by greedy provider extractors. The fixer tests assert the strings + * contain no marker bytes. + */ + +export const MARKER_DESCRIPTION = + "a DeepSeek DSML tag uses single full-width bars around the word DSML " + + "(open angle bracket, U+FF5C, the letters DSML, U+FF5C, then the tag name)" + +/** + * Preventive instruction, installed via the `context` hook as a system part. Tells + * the model the exact envelope so degraded forms are less likely in the first place. + */ +export function preventiveDirective(): string { + return [ + "Tool-call format (must follow exactly):", + "1. Wrap every tool call in a complete outer block layer: an opening block tag", + " for tool_calls, then one invoke element per call, then the closing block tag.", + `2. Each tag is ${MARKER_DESCRIPTION}. Never use doubled bars, never use plain ASCII pipes, never leave a space where the tag name belongs.`, + "3. Each invoke element carries the tool name in its name attribute and contains", + ' one parameter element per argument, with string="true" for plain text values', + ' and string="false" for JSON values.', + "4. Never emit a parameter element without its enclosing invoke element, and never", + " emit an invoke element without the outer block layer. Incomplete markup will", + " not be executed.", + ].join("\n") +} + +/** + * Recovery-failure nudge, sent (via Strategy A) when DSML markup appeared but no call + * could be recovered, or when a session idles on leaked markup. Short, imperative, + * and self-contained: it restates the required outer layer. + */ +export function recoveryNudge(toolHint?: string): string { + const target = toolHint ? ` (${toolHint})` : "" + return [ + `Your last tool call${target} arrived malformed and was not executed.`, + "Re-issue it exactly once, using the correct format: a complete outer", + "tool_calls block layer wrapping one invoke element per call, with each", + "parameter element inside its invoke element. Do not emit bare parameter", + "elements and do not omit the outer block layer.", + ].join(" ") +} + +/** Guard used by tests: neither directive may contain a live marker. */ +export function containsLiveMarker(text: string): boolean { + return /[\uFF5C|]\s*DSML\s*[\uFF5C|]/i.test(text) +} diff --git a/src/sse.ts b/src/sse.ts new file mode 100644 index 0000000..42c2b9e --- /dev/null +++ b/src/sse.ts @@ -0,0 +1,396 @@ +/** + * Strategy B for the native protocol path: rewrite the provider's OpenAI-Chat + * SSE stream inside `session.hook("http.response")`. + * + * Why this seam exists: catalog providers such as `opencode-go` resolve to the + * NATIVE `@opencode/ai/providers/openai-compatible` route (see + * `AISDKNative.native`), so `ctx.aisdk.hook("language")` never fires for them. + * The native route, however, offers every request to the `http.request` / + * `http.response` hooks (`packages/core/src/session/model-request.ts`), which see + * the raw SSE bytes. This module parses those bytes, runs the same DSML state + * machine as `src/middleware.ts` over the `content` deltas, and re-emits a + * corrected stream: text without DSML spans, synthetic `tool_calls` deltas for + * each recovered call, and a terminal `finish_reason` rewritten from `stop` to + * `tool_calls`. The native `openai-chat` parser accepts exactly this shape + * (`delta.tool_calls[]` indexed by `index`, arguments accumulated across deltas). + * + * Streaming is preserved: chunks without a DSML candidate pass through + * immediately; only candidate spans are held, under the same eager-stop and cap + * policy as the AI-SDK middleware. + */ + +import { parseDsml, type DsmlToolCall, type ParseOptions } from "./parse.ts" + +export interface SseRewriterOptions extends ParseOptions { + /** Streaming swallow cap in bytes. Default 65536. */ + readonly bufferLimit?: number + /** Called once when the stream closes, with the per-turn outcome. */ + readonly onSettled?: (stats: SseStats) => void +} + +/** Per-turn outcome for logging: what the rewrite actually did. */ +export interface SseStats { + /** Tool calls recovered from leaked markup. */ + readonly calls: number + /** Whether any DSML candidate was ever held (false = pure passthrough). */ + readonly sawCandidate: boolean +} + +const DEFAULT_BUFFER_LIMIT = 64 * 1024 +const EAGER_WINDOW = 1024 + +const WRAPPED_CANDIDATE = /<(?:[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*)?parameter\s+name\s*=\s*["']?[^\s>"']+/iu +const DSML_OPENER = /<[\uFF5C|]{1,2}\s*DSML/iu +const PARAM_OPEN = /<[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*parameter\s+name\s*=/iu + +function holdStart(buffer: string): number { + let earliest = -1 + for (const re of [DSML_OPENER, WRAPPED_CANDIDATE]) { + re.lastIndex = 0 + const found = re.exec(buffer) + if (found && (earliest === -1 || found.index < earliest)) earliest = found.index + } + return earliest +} + +const TAG_PREFIX = + /^(?:<)\/?[\uFF5C|]{0,2}\s*(?:D?S?M?L?[\uFF5C|]{0,2}\s*)?(?:p?a?r?a?m?e?t?e?r?|i?n?v?o?k?e?|t?o?o?l?_?c?a?l?l?s?|f?u?n?c?t?i?o?n?_?c?a?l?l?s?|c?a?l?l?s?)?(?:\s*n?a?m?e?\s*=?\s*["']?[^<>"']*)?\s*$/iu + +function partialOpenerSuffix(buffer: string): number { + const lt = buffer.lastIndexOf("<") + if (lt === -1) return 0 + const tail = buffer.slice(lt) + if (tail.length > 64 || tail.includes(">")) return 0 + if (!TAG_PREFIX.test(tail)) return 0 + return tail.length +} + +export interface SseFrame { + readonly data: string +} + +/** Split raw SSE bytes into complete `data:` payloads, keeping a text remainder. */ +export function splitSseFrames(text: string): { frames: string[]; rest: string } { + const frames: string[] = [] + const parts = text.split("\n\n") + const rest = parts.pop() ?? "" + for (const part of parts) { + const lines = part.split("\n").filter((line) => line.startsWith("data:")) + if (lines.length === 0) continue + frames.push(lines.map((line) => line.slice(5).trimStart()).join("\n")) + } + return { frames, rest } +} + +export function sseFrame(payload: string): string { + return `data: ${payload}\n\n` +} + +interface ToolDelta { + readonly index: number + readonly id?: string + readonly function?: { readonly name?: string; readonly arguments?: string } +} + +function contentOf(event: Record): string | undefined { + const choices = event["choices"] + if (!Array.isArray(choices)) return undefined + const delta = (choices[0] as Record | undefined)?.["delta"] as Record | undefined + const content = delta?.["content"] + return typeof content === "string" ? content : undefined +} + +function finishOf(event: Record): string | undefined { + const choices = event["choices"] + if (!Array.isArray(choices)) return undefined + const reason = (choices[0] as Record | undefined)?.["finish_reason"] + return typeof reason === "string" ? reason : undefined +} + +function textEvent(text: string): string { + return sseFrame(JSON.stringify({ choices: [{ delta: { content: text }, index: 0 }] })) +} + +let toolCallCounter = 0 + +function toolCallEvents(call: DsmlToolCall, index: number): string[] { + const id = `dsml_${Date.now().toString(36)}_${toolCallCounter++}_${Math.random().toString(36).slice(2, 8)}` + const args = JSON.stringify(call.arguments) + // Name first, then arguments in small chunks so progressive parsers keep up. + const out = [ + sseFrame( + JSON.stringify({ choices: [{ delta: { tool_calls: [{ index, id, function: { name: call.name } }] }, index: 0 }] }), + ), + ] + for (let i = 0; i < args.length; i += 512) { + out.push( + sseFrame( + JSON.stringify({ + choices: [{ delta: { tool_calls: [{ index, function: { arguments: args.slice(i, i + 512) } }] }, index: 0 }], + }), + ), + ) + } + return out +} + +/** + * Incremental SSE rewriter. Feed raw upstream bytes with `push`; collect + * rewritten SSE frames from the return value. Call `flush` at stream end. + * Non-DSML traffic passes straight through, preserving streaming. + */ +export class SseDsmlRewriter { + private frameRest = "" + private textBuffer = "" + private recovered = false + private recoveredInBlock = false + private toolIndex = 0 + private maxNativeIndex = -1 + private sawCandidate = false + private settled = false + private readonly bufferLimit: number + private readonly parseOptions: ParseOptions + private readonly onSettled?: (stats: SseStats) => void + + constructor(input?: SseRewriterOptions) { + this.bufferLimit = input?.bufferLimit ?? DEFAULT_BUFFER_LIMIT + this.onSettled = input?.onSettled + this.parseOptions = { + orphanInvoke: input?.orphanInvoke, + looseParameters: input?.looseParameters, + rawJsonBody: input?.rawJsonBody, + wrappedTool: input?.wrappedTool, + } + } + + get recoveredCalls(): boolean { + return this.recovered + } + + /** Report the per-turn outcome exactly once (stream close). */ + settle(): void { + if (this.settled) return + this.settled = true + this.onSettled?.({ calls: this.toolIndex, sawCandidate: this.sawCandidate }) + } + + /** Feed upstream bytes; returns rewritten SSE frames to send downstream. */ + push(bytes: string): string[] { + const out: string[] = [] + const { frames, rest } = splitSseFrames(this.frameRest + bytes) + this.frameRest = rest + for (const payload of frames) { + if (payload === "[DONE]") { + out.push(...this.drain(true)) + out.push(sseFrame("[DONE]")) + continue + } + let event: Record + try { + const parsed: unknown = JSON.parse(payload) + if (typeof parsed !== "object" || parsed === null) { + out.push(sseFrame(payload)) + continue + } + event = parsed as Record + } catch { + out.push(sseFrame(payload)) + continue + } + const content = contentOf(event) + const finish = finishOf(event) + // NB: content and finish_reason can arrive in the SAME event. The content + // is consumed into the text buffer, so the forwarded event must carry it + // stripped — otherwise the text appears twice downstream. Dropping either + // side breaks the run ("stream ended without finish_reason" / duplication). + const choices = Array.isArray(event["choices"]) ? (event["choices"] as unknown[]) : [] + const first = ((choices[0] as Record | undefined) ?? {}) as Record + // Track native tool-call indexes: synthetic indexes are allocated above + // every native index observed, because the native parser keys accumulation + // by `index` (falling back from `id`) — reusing one would merge our + // arguments into the provider's call. + const nativeList = (first["delta"] as Record | undefined)?.["tool_calls"] + if (Array.isArray(nativeList)) { + for (const tc of nativeList) { + const idx = (tc as Record)?.["index"] + if (typeof idx === "number" && idx > this.maxNativeIndex) this.maxNativeIndex = idx + } + } + const firstDelta = (first["delta"] as Record) ?? {} + const { content: _consumed, ...deltaRest } = firstDelta + const strippedEvent = { ...event, choices: [{ ...first, delta: deltaRest }] } + const hasMore = finish !== undefined || Object.keys(deltaRest).length > 0 || choices.length > 1 + if (content !== undefined) { + this.textBuffer += content + out.push(...this.drain(false)) + } + if (finish !== undefined) { + out.push(...this.drain(true)) + if (this.recovered && finish === "stop") { + out.push(sseFrame(JSON.stringify({ ...event, choices: [{ ...first, finish_reason: "tool_calls" }] }))) + } else if (hasMore) { + out.push(sseFrame(JSON.stringify(strippedEvent))) + } else if (content === undefined) { + out.push(sseFrame(payload)) + } + continue + } + if (content !== undefined) { + if (hasMore) out.push(sseFrame(JSON.stringify(strippedEvent))) + continue + } + // Usage chunks, role deltas, errors: forward untouched (order preserved: + // the text buffer was drained above for content; usage carries no text). + out.push(sseFrame(payload)) + } + return out + } + + /** End of stream: resolve whatever remains. Buffer always ends empty. */ + flush(): string[] { + const out = this.drain(true) + if (this.frameRest.trim().length > 0) out.push(sseFrame(this.frameRest.trim())) + this.frameRest = "" + this.settle() + return out + } + + private emitText(text: string, out: string[]): void { + if (text.length > 0) out.push(textEvent(text)) + } + + private emitResolved(out: string[], final: boolean): void { + const original = this.textBuffer + const { calls, ranges } = parseDsml(this.textBuffer, this.parseOptions) + this.textBuffer = "" + if (calls.length === 0) { + this.emitText(original, out) + return + } + let cleaned = "" + let cursor = 0 + for (const range of ranges) { + if (range.start > cursor) cleaned += original.slice(cursor, range.start) + cursor = Math.max(cursor, range.end) + } + cleaned += original.slice(cursor) + // The buffer can end mid-tag (a trailing closer already streaming in when + // resolution fired). A partial tag is unparseable by definition: hold it + // back for the next drain while the stream continues; at finality there is + // nothing left to complete it, so it ships verbatim (never swallow). + if (!final) { + const holdBack = partialOpenerSuffix(cleaned) + if (holdBack > 0) { + this.textBuffer = cleaned.slice(cleaned.length - holdBack) + cleaned = cleaned.slice(0, cleaned.length - holdBack) + } + } + this.emitText(cleaned, out) + for (const call of calls) { + // Allocate synthetic indexes above every native index observed so far. + // The native parser keys accumulation by `index` (falling back from `id`), + // so reusing a native index would merge our arguments into their call. + if (this.toolIndex <= this.maxNativeIndex) this.toolIndex = this.maxNativeIndex + 1 + for (const frame of toolCallEvents(call, this.toolIndex++)) out.push(frame) + this.recovered = true + this.recoveredInBlock = true + } + } + + private isMarkupNoise(text: string): boolean { + const stripped = text + .replace(/<\/?[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls|invoke|parameter)(?:\s+[^>\n]*)?>?/giu, "") + // Leading fragment of a closer split across chunks (`| calls>`): the `<` + // went out with an earlier resolution, leaving only bars + tag name. + .replace(/^[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls|invoke|parameter)\s*>?/iu, "") + .replace(/\s+/g, "") + return stripped.length === 0 && text.length > 0 + } + + private drain(final: boolean): string[] { + const out: string[] = [] + if (!this.textBuffer) return out + const start = holdStart(this.textBuffer) + if (start === -1) { + const keep = final ? 0 : partialOpenerSuffix(this.textBuffer) + if (keep > 0) { + this.sawCandidate = true + const emit = this.textBuffer.slice(0, this.textBuffer.length - keep) + this.textBuffer = this.textBuffer.slice(this.textBuffer.length - keep) + this.emitText(emit, out) + return out + } + if (this.recoveredInBlock && this.isMarkupNoise(this.textBuffer)) { + // NB: recoveredInBlock stays true for the rest of this text block — + // later closer fragments (`` split across chunks) must still drop. + this.textBuffer = "" + return out + } + this.emitText(this.textBuffer, out) + this.textBuffer = "" + return out + } + if (start > 0) { + this.emitText(this.textBuffer.slice(0, start), out) + this.textBuffer = this.textBuffer.slice(start) + } + this.sawCandidate = true + const hasBlockOpen = + /<[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls)\s*>/iu.test(this.textBuffer) + const closed = + /<\/[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls)\s*>/iu.test(this.textBuffer) || + (!hasBlockOpen && /<\/[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:invoke|calls)\s*>/iu.test(this.textBuffer)) + const overCap = this.textBuffer.length > this.bufferLimit + if (!final && closed) { + this.emitResolved(out, false) + return out + } + PARAM_OPEN.lastIndex = 0 + const insideValue = PARAM_OPEN.test(this.textBuffer) + if (!final && !overCap && !insideValue && this.textBuffer.length > EAGER_WINDOW) { + this.emitText(this.textBuffer, out) + this.textBuffer = "" + return out + } + if (!final && !overCap) return out + if (overCap && !closed) { + this.emitText(this.textBuffer, out) + this.textBuffer = "" + return out + } + this.emitResolved(out, true) + return out + } +} + +/** + * Wrap a provider SSE `Response` with DSML recovery. Non-SSE responses pass + * through untouched. Consumes the upstream body incrementally — normal traffic + * keeps streaming; only candidate spans are held. Synchronous: the returned + * Response pumps lazily, so it can be assigned directly in the hook. + */ +export function rewriteSseResponse(response: Response, options?: SseRewriterOptions): Response { + const contentType = response.headers.get("content-type") ?? "" + if (!contentType.includes("text/event-stream") || !response.body) return response + const rewriter = new SseDsmlRewriter(options) + const stream = new ReadableStream({ + async start(controller) { + const reader = response.body!.getReader() + const decoder = new TextDecoder() + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + const frames = rewriter.push(decoder.decode(value, { stream: true })) + for (const frame of frames) controller.enqueue(new TextEncoder().encode(frame)) + } + for (const frame of rewriter.flush()) controller.enqueue(new TextEncoder().encode(frame)) + controller.close() + } catch (error) { + rewriter.settle() + controller.error(error) + } + }, + }) + return new Response(stream, { status: response.status, headers: response.headers }) +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..8c31788 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,236 @@ +/** Config, mode gating, prompt guards, and fallback decisions. */ + +import { describe, expect, test } from "bun:test" +import { appliesToProvider, resolveConfig } from "../src/config.ts" +import { attemptKey, decideResume, messageText, needsSystemNudge, nudgeCapKey, parseAttempts, wakeText } from "../src/fallback.ts" +import { block, invoke, param } from "./fixtures.ts" +import { parseDsml, redactionNote, stripRangesWithNote } from "../src/parse.ts" +import { containsLiveMarker, preventiveDirective, recoveryNudge } from "../src/prompt.ts" + +describe("config", () => { + test("defaults target both Zen providers in relaxed mode", () => { + const c = resolveConfig() + expect([...c.providers]).toEqual(["opencode-go", "opencode"]) + expect(c.relaxed).toBe(true) + expect(c.orphanInvoke).toBe(true) + expect(c.watchdogEnabled).toBe(true) + expect(c.resumeMaxAttempts).toBe(3) + expect(c.directiveEnabled).toBe(true) + expect(c.resumeChannel).toBe("system") + }) + + test("strict mode disables every rescue", () => { + const c = resolveConfig({ mode: "strict" }) + expect(c.relaxed).toBe(false) + expect(c.orphanInvoke).toBe(false) + expect(c.looseParameters).toBe(false) + expect(c.rawJsonBody).toBe(false) + }) + + test("one rescue can be switched off in isolation", () => { + const c = resolveConfig({ rescue: { orphanInvoke: false } }) + expect(c.orphanInvoke).toBe(false) + expect(c.looseParameters).toBe(true) + }) + + test("empty providers disables scoping", () => { + const c = resolveConfig({ providers: [] }) + expect(appliesToProvider(c, "opencode-go")).toBe(false) + }) + + test("provider scoping", () => { + const c = resolveConfig() + expect(appliesToProvider(c, "opencode-go")).toBe(true) + expect(appliesToProvider(c, "opencode")).toBe(true) + expect(appliesToProvider(c, "anthropic")).toBe(false) + expect(appliesToProvider(c, undefined)).toBe(false) + }) +}) + +describe("strategy matrix", () => { + test("each transport recovers independently", () => { + const off = resolveConfig({ recovery: { sse: false } }) + expect(off.sseEnabled).toBe(false) + expect(off.generateEnabled).toBe(true) + const all = resolveConfig({ + recovery: { sse: false, aisdkStream: false, aisdkGenerate: false }, + request: { sanitize: false, directive: false, systemNudge: false }, + }) + expect(all.streamEnabled).toBe(false) + expect(all.generateEnabled).toBe(false) + expect(all.sanitizeEnabled).toBe(false) + expect(all.directiveEnabled).toBe(false) + expect(all.systemNudgeEnabled).toBe(false) + }) + + test("recovery/request win over legacy strategies.*", () => { + expect(resolveConfig({ strategies: { sse: false } }).sseEnabled).toBe(false) + expect( + resolveConfig({ strategies: { sse: false }, response: { sse: true } }).sseEnabled, + ).toBe(true) + expect( + resolveConfig({ strategies: { sanitize: false }, request: { sanitize: true } }).sanitizeEnabled, + ).toBe(true) + }) + + test("legacy aliases still work; new names win when set", () => { + expect(resolveConfig({ resume: { enabled: false } }).watchdogEnabled).toBe(false) + expect(resolveConfig({ prompt: { enabled: false } }).directiveEnabled).toBe(false) + expect(resolveConfig({ resume: { enabled: false }, strategies: { watchdog: true } }).watchdogEnabled).toBe(true) + expect(resolveConfig({ resume: { channel: "user" } }).resumeChannel).toBe("user") + }) +}) + +describe("strict mode parsing", () => { + const strict = { orphanInvoke: false, looseParameters: false, rawJsonBody: false } + + test("complete block still parses", () => { + const r = parseDsml(block([invoke("bash", [param("command", "pwd")])]), strict) + expect(r.calls).toHaveLength(1) + }) + + test("orphan invoke is not recovered", () => { + const r = parseDsml(invoke("edit", [param("path", "a")]), strict) + expect(r.calls).toHaveLength(0) + }) + + test("mis-closed parameter is not rescued", () => { + const a = `<\uFF5CDSML\uFF5Cparameter name="alpha" string="true">first` + const src = block([invoke("r", [a, param("beta", "second")])]) + const r = parseDsml(src, strict) + // Strict keeps the strict scan as-is (runaway value), never the tolerant one. + expect(r.calls.map((c) => c.name)).toEqual(["r"]) + }) +}) + +describe("prompt", () => { + test("directives contain no live marker", () => { + expect(containsLiveMarker(preventiveDirective())).toBe(false) + expect(containsLiveMarker(recoveryNudge("edit"))).toBe(false) + }) + + test("directive states the outer-layer rule", () => { + expect(preventiveDirective()).toContain("outer block") + expect(recoveryNudge()).toContain("outer") + }) +}) + +describe("fallback", () => { + test("no marker → no nudge", () => { + expect(decideResume("hello world", 0, 3)).toEqual({ send: false, reason: "no-marker" }) + }) + + test("recoverable text → no nudge (middleware owns it)", () => { + const decision = decideResume(block([invoke("bash", [param("command", "pwd")])]), 0, 3) + expect(decision.send).toBe(false) + expect(decision.reason).toBe("already-recoverable") + }) + + test("unrecoverable markup → nudge with correction text", () => { + const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", 0, 3) + expect(decision.send).toBe(true) + expect(decision.text).toContain("outer") + }) + + test("cap is enforced", () => { + const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", 3, 3) + expect(decision).toEqual({ send: false, reason: "cap-reached" }) + }) + + test("attempt key is namespaced per message", () => { + expect(attemptKey("s1", "m1")).not.toBe(attemptKey("s1", "m2")) + }) + + test("parseAttempts tolerates garbage", () => { + expect(parseAttempts(undefined)).toBe(0) + expect(parseAttempts("x")).toBe(0) + expect(parseAttempts(2)).toBe(2) + }) + + test("messageText reads assistant text only", () => { + expect(messageText({ role: "user", content: [{ type: "text", text: "hi" }] })).toBeUndefined() + expect(messageText({ role: "assistant", content: [{ type: "text", text: "a" }] })).toBe("a") + }) + + test("messageText tolerates null text", () => { + expect(messageText({ role: "assistant", content: [{ type: "text", text: null }] })).toBeUndefined() + }) +}) + +describe("system nudge", () => { + const unrecoverable = `trailing \uFF5CDSML\uFF5C tool_calls garbage without structure` + + test("fires when history ends in unrecoverable DSML", () => { + const nudge = needsSystemNudge([{ role: "assistant", content: [{ type: "text", text: unrecoverable }] }]) + expect(nudge?.text).toContain("outer") + expect(nudge?.key).toBeTruthy() + }) + + test("silent for clean history", () => { + expect(needsSystemNudge([{ role: "assistant", content: [{ type: "text", text: "done" }] }])).toBeUndefined() + }) + + test("silent when the middleware could recover it", () => { + const nudge = needsSystemNudge([ + { role: "assistant", content: [{ type: "text", text: block([invoke("bash", [param("command", "pwd")])]) }] }, + ]) + expect(nudge).toBeUndefined() + }) + + test("prefers message id, falls back to content hash", () => { + expect(nudgeCapKey({ id: "msg-1", role: "assistant", content: [{ type: "text", text: "x" }] })).toBe("msg-1") + const a = nudgeCapKey({ role: "assistant", content: [{ type: "text", text: "same" }] }) + const b = nudgeCapKey({ role: "assistant", content: [{ type: "text", text: "same" }] }) + const c = nudgeCapKey({ role: "assistant", content: [{ type: "text", text: "different" }] }) + expect(a).toBe(b) + expect(a).not.toBe(c) + }) + + test("wake text is minimal", () => { + expect(wakeText().length).toBeLessThan(60) + }) + + test("turnId uses OpenCode's user message id", async () => { + const { turnId } = await import("../src/fallback.ts") + expect( + turnId([ + { role: "user", content: [{ type: "text", text: "first" }], id: "msg-aaa" }, + { role: "assistant", content: [{ type: "text", text: "reply" }], id: "msg-bbb" }, + { role: "user", content: [{ type: "text", text: "second" }], id: "msg-ccc" }, + ]), + ).toBe("msg-ccc") + expect(turnId([])).toBe("no-user-turn") + expect( + turnId([{ role: "assistant", content: [{ type: "text", text: "x" }] }]), + ).toBe("no-user-turn") + }) + + test("sseEnabled defaults true; response.sse wins", () => { + expect(resolveConfig().sseEnabled).toBe(true) + expect(resolveConfig({ response: { sse: false } }).sseEnabled).toBe(false) + expect(resolveConfig({ recovery: { sse: false } }).sseEnabled).toBe(false) + expect( + resolveConfig({ recovery: { sse: false }, response: { sse: true } }).sseEnabled, + ).toBe(true) + }) +}) + +describe("redaction note", () => { + test("placeholder replaces each removed span", () => { + expect(stripRangesWithNote("a c", [{ start: 2, end: 7 }], "[removed malformed edit call]")).toBe( + "a [removed malformed edit call]", + ) + expect(stripRangesWithNote("a c", [{ start: 2, end: 5 }])).toBe("a [malformed tool call removed]c") + }) + + test("note carries no marker bytes", () => { + const out = stripRangesWithNote("x", [{ start: 0, end: 1 }], "[removed malformed edit call]") + expect(containsLiveMarker(out)).toBe(false) + }) + + test("redactionNote names tools, caps length", () => { + expect(redactionNote(["edit"])).toBe("[removed malformed edit call]") + expect(redactionNote([])).toBe("[removed malformed markup]") + expect(containsLiveMarker(redactionNote(["edit", "shell"]))).toBe(false) + }) +}) diff --git a/test/fixtures.ts b/test/fixtures.ts new file mode 100644 index 0000000..9abfbbf --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,58 @@ +/** + * Fixture builders for DSML parser tests. + * + * The live DSML marker contains U+FF5C (full-width vertical bar). We never write + * that byte literally in this repository: models and greedy provider extractors + * treat it as a tool-call start, and a literal token in a source file becomes a + * phantom call. Everything is built from `BAR` at runtime instead. + */ + +/** U+FF5C full-width vertical bar. */ +export const BAR = String.fromCharCode(0xff5c) + +/** ASCII pipe, the other bar the grammar accepts. */ +export const PIPE = "|" + +/** Canonical marker: `<|DSML|>`. */ +export const marker = (bar = BAR, count = 1) => `${bar.repeat(count)}DSML${bar.repeat(count)}` + +/** Mangled / doubled marker, as emitted by gateways that re-decode tokens. */ +export const markerDoubled = () => marker(BAR, 2) +export const markerAscii = () => "|DSML|" + +/** A DSML tag: ``. */ +export const tag = (name: string, attrs = "", m = marker()) => `<${m}${name}${attrs ? " " + attrs : ""}>` + +/** A closing DSML tag. */ +export const closeTag = (name: string, m = marker()) => `` + +/** A parameter element. `string=false` encodes a JSON value. */ +export const param = (name: string, value: string, opts: { json?: boolean; m?: string; quote?: string } = {}) => { + const m = opts.m ?? marker() + const q = opts.quote ?? '"' + const stringAttr = opts.json ? ` string="false"` : ` string="true"` + return `${tag("parameter", `name=${q}${name}${q}${stringAttr}`, m)}${value}${closeTag("parameter", m)}` +} + +/** An invoke element wrapping zero or more parameters. */ +export const invoke = (name: string, params: string[] = [], opts: { m?: string; quote?: string } = {}) => { + const m = opts.m ?? marker() + const q = opts.quote ?? '"' + return `${tag("invoke", `name=${q}${name}${q}`, m)}${params.join("")}${closeTag("invoke", m)}` +} + +/** A tool_calls block wrapping one or more invokes. */ +export const block = ( + invokes: string[], + opts: { m?: string; kind?: string } = {}, +) => { + const m = opts.m ?? marker() + const kind = opts.kind ?? "tool_calls" + return `${tag(kind, "", m)}${invokes.join("")}${closeTag(kind, m)}` +} + +/** The end-of-sentence sentinel some backends emit. */ +export const eos = (bar = BAR) => `<${bar.repeat(2)}end\u2581of\u2581sentence${bar.repeat(2)}>` + +/** Concatenate fragments (makes intent explicit at call sites). */ +export const text = (...parts: string[]) => parts.join("") diff --git a/test/golden.test.ts b/test/golden.test.ts new file mode 100644 index 0000000..01d4e7e --- /dev/null +++ b/test/golden.test.ts @@ -0,0 +1,118 @@ +/** + * Golden test: 9 real-world DSML leak shapes captured from a long-context + * DeepSeek session (wrapped edit/shell calls, seqs 5177–5695), preserved as + * synthetic byte-faithful fixtures (`test/golden/db-messages.json`, generated by + * `test/golden/build-fixtures.ts`). No private content: paths, code, and prose + * are generic. Each message is run through the parser AND the streaming + * middleware. + * + * Expectations per message (verified against the DB by hand): + * - 5177, 5425, 5443, 5624, 5647, 5663, 5679, 5695: finish=stop, orphan/degraded + * markup. The parser must recover the intended call where one is unambiguous + * (edit/shell with parseable params) or strip the noise otherwise; the stream + * must emit identical text plus the recovered call and flip finish to tool-calls. + * - 5608: finish=tool-calls with leaked markup alongside — parser recovers the call, + * leaked text is stripped, finish stays tool-calls. + */ + +import { describe, expect, test } from "bun:test" +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider" +import { parseDsml, stripRanges } from "../src/parse.ts" +import { createDsmlStreamMiddleware } from "../src/middleware.ts" +import raw from "./golden/db-messages.json" with { type: "json" } + +interface GoldenMessage { + seq: number + finish: string + text: string +} + +const messages = raw as GoldenMessage[] + +function splitDeltas(text: string, size: number): string[] { + const parts: string[] = [] + for (let i = 0; i < text.length; i += size) parts.push(text.slice(i, i + size)) + return parts +} + +async function runStream(text: string) { + const chunks = splitDeltas(text, 7) // awkward split: cuts through markers + const parts: LanguageModelV3StreamPart[] = [ + { type: "stream-start", warnings: [] }, + { type: "text-start", id: "t1" }, + ...chunks.map((delta): LanguageModelV3StreamPart => ({ type: "text-delta", id: "t1", delta })), + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: {} as never }, + ] + const stream = new ReadableStream({ + start(c) { + for (const p of parts) c.enqueue(p) + c.close() + }, + }) + const middleware = createDsmlStreamMiddleware() + const wrapped = await middleware.wrapStream!({ + doStream: async () => ({ stream }), + doGenerate: (async () => ({})) as never, + params: {} as never, + model: {} as never, + }) + const events: LanguageModelV3StreamPart[] = [] + const reader = wrapped.stream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe("golden: DB session leaks", () => { + test("all 9 fixtures load with DSML content", () => { + expect(messages).toHaveLength(9) + for (const m of messages) expect(m.text).toMatch(/DSML/) + }) + + for (const m of messages) { + test(`seq ${m.seq}: stream preserves text, recovers call, flips finish`, async () => { + const parsed = parseDsml(m.text) + const events = await runStream(m.text) + const textOut = events + .filter((e): e is Extract => e.type === "text-delta") + .map((e) => e.delta) + .join("") + const calls = events.filter((e) => e.type === "tool-call") + const finish = events.find((e) => e.type === "finish") as Extract< + LanguageModelV3StreamPart, + { type: "finish" } + > + // No text is ever lost: emitted text + stripped ranges === input minus calls. + // Concretely: every non-markup word of the input must appear in the output. + // (Comparison ignores quote chars: call args travel JSON-escaped in parts.) + const inputWords = m.text.replace(/<[^>]*>/g, " ").split(/\s+/).filter(Boolean) + const haystacks = [textOut, JSON.stringify(calls)].map((h) => h.replace(/["\\]/g, "")) + for (const word of inputWords) { + if (word.length > 20) continue // long values may be inside recovered args + const bare = word.replace(/["\\]/g, "") + expect(haystacks.some((h) => h.includes(bare))).toBe(true) + } + // Parser and stream agree on the call count. + expect(calls.length).toBe(parsed.calls.length) + if (parsed.calls.length > 0) { + expect(finish.finishReason.unified).toBe("tool-calls") + } else { + expect(finish.finishReason.unified).toBe("stop") + } + // Stripped output contains no marker. + expect(stripRanges(m.text, parsed.ranges)).not.toMatch(/DSML/) + }) + } + + test("seq 5608 (native tool-calls): wrapped edit call recovered", async () => { + const m = messages.find((x) => x.seq === 5608)! + const parsed = parseDsml(m.text) + expect(parsed.calls.length).toBeGreaterThan(0) + expect(parsed.calls[0]!.name).toBe("edit") + expect(parsed.calls[0]!.arguments).toHaveProperty("newString") + }) +}) diff --git a/test/golden/build-fixtures.ts b/test/golden/build-fixtures.ts new file mode 100644 index 0000000..692c6fd --- /dev/null +++ b/test/golden/build-fixtures.ts @@ -0,0 +1,128 @@ +/** + * Build synthetic golden fixtures: same 9 structural shapes as the real failures, + * with generic content. Run with `bun test/golden/build-fixtures.ts` and redirect + * to `test/golden/db-messages.json`. + * + * Shapes covered: wrapped edit/shell with markerless outer (V26), marked outer, + * spaced closers, multiline values, shell metachars, one native tool-calls case. + * Real bytes are assembled at runtime (never stored literally). + */ +const BAR = String.fromCharCode(0xff5c) +const M = `${BAR}DSML${BAR}` + +const param = (name: string, value: string, m = M, json = false) => + `<${m}parameter name="${name}" string="${json ? "false" : "true"}">${value}` + +interface Golden { + seq: number + finish: string + text: string +} + +const messages: Golden[] = [ + { + seq: 5177, + finish: "stop", + text: + `Now collect the player state reactively and add the seek callback. Let me update the screen.\n\n` + + `\nCollect state in onCreate and pass it into the settings screen.\n\n\n` + + `\n` + + `${param("newString", " // Mirror the player state into a snapshot the UI reads.\n playerState.collect { state.value = it }")}\n` + + `${param("oldString", " playerState.collect { state.value = it }")}\n` + + `${param("path", "/proj/app/src/main/java/com/example/app/MainScreen.kt")}\n` + + `\n`, + }, + { + seq: 5425, + finish: "stop", + text: + `The renderer joins pieces when the text buffer grows. Let me find where that happens.\n\n` + + `\nFind the append call sites in the render loop.\n\n\n` + + `\n` + + `${param("command", "cd /proj/native\ngrep -n 'append' src/render.cpp | head -20")}\n` + + `\n`, + }, + { + seq: 5443, + finish: "stop", + text: + `The join rule handles the boundary marker. Let me inspect the decoder.\n\n` + + `\nInspect how boundary markers turn into separators.\n\n\n` + + `\n` + + `${param("command", "cd /proj/native\ngrep -rn 'decode' src/*.cpp src/*.hpp | head\necho \"=== impl ===\"\ngrep -rn 'std::string decode' src/")}\n` + + `\n`, + }, + { + seq: 5608, + finish: "tool-calls", + text: + `The worker pool defaults to 8 threads, which oversubscribes a phone. Let me expose a thread-count knob with a sensible default.\n\n` + + `\nAdd a small native setter and call it before first inference.\n\n\n` + + `<${M}parameter name="edit">\n` + + `${param("newString", '#include "engine_capi.h"\n\nnamespace rt { void set_num_threads(int n); }')}\n` + + `${param("oldString", '#include "engine_capi.h"')}\n` + + `${param("path", "/proj/app/src/main/cpp/engine_jni.cpp")}\n` + + ``, + }, + { + seq: 5624, + finish: "stop", + text: + `Call the thread setter during preload, tuned to the fast cores.\n\n` + + `\nSet threads once before loading, capped to the big cluster.\n\n\n` + + `\n` + + `${param("newString", " loading = true\n val threads = (cores / 2).coerceIn(2, 6)\n try { Native.setThreads(threads) } catch (_: Throwable) { }")}\n` + + `${param("oldString", " loading = true")}\n` + + `${param("path", "/proj/app/src/main/java/com/example/app/engine/Model.kt")}\n` + + `\n`, + }, + { + seq: 5647, + finish: "stop", + text: + `The declaration may have landed inside the wrong scope. Let me check its position.\n\n` + + `\nVerify the declaration sits at file scope, not inside a block.\n\n\n` + + `\n` + + `${param("command", "cd /proj/app/src/main/cpp\ngrep -n 'namespace rt' engine_jni.cpp | head")}\n` + + `\n`, + }, + { + seq: 5663, + finish: "stop", + text: + `The declaration didn't apply. Let me add it right after the include.\n\n` + + `\nInsert after the capi include with a unique anchor.\n\n\n` + + `\n` + + `${param("newString", '#include "engine_capi.h"\n\nnamespace rt { void set_num_threads(int n); }')}\n` + + `${param("oldString", '#include "engine_capi.h"')}\n` + + `${param("path", "/proj/app/src/main/cpp/engine_jni.cpp")}\n` + + `\n`, + }, + { + seq: 5679, + finish: "stop", + text: + `Odd scoping at the call site. Let me check line numbers around it.\n\n` + + `\nCheck declaration location versus call site ordering.\n\n\n` + + `\n` + + `${param("command", "cd /proj/app/src/main/cpp\ngrep -n 'set_num_threads' engine_jni.cpp")}\n` + + `\n`, + }, + { + seq: 5695, + finish: "stop", + text: + `The declaration is still missing. Let me write it directly.\n\n` + + `\nInsert with a precise unique anchor this time.\n\n\n` + + `\n` + + `${param("newString", '#include "engine_capi.h"\n\nnamespace rt { void set_num_threads(int n); }')}\n` + + `${param("oldString", '#include "engine_capi.h"')}\n` + + `${param("path", "/proj/app/src/main/cpp/engine_jni.cpp")}\n` + + `\n`, + }, +] + +// Doubled-bar shape (`<||DSML||…>`) is covered in test/stream.test.ts and +// test/parse.test.ts with runtime-built markers. + +console.log(JSON.stringify(messages)) diff --git a/test/golden/db-messages.json b/test/golden/db-messages.json new file mode 100644 index 0000000..97af372 --- /dev/null +++ b/test/golden/db-messages.json @@ -0,0 +1 @@ +[{"seq":5177,"finish":"stop","text":"Now collect the player state reactively and add the seek callback. Let me update the screen.\n\n\nCollect state in onCreate and pass it into the settings screen.\n\n\n\n<|DSML|parameter name=\"newString\" string=\"true\"> // Mirror the player state into a snapshot the UI reads.\n playerState.collect { state.value = it }\n<|DSML|parameter name=\"oldString\" string=\"true\"> playerState.collect { state.value = it }\n<|DSML|parameter name=\"path\" string=\"true\">/proj/app/src/main/java/com/example/app/MainScreen.kt\n\n"},{"seq":5425,"finish":"stop","text":"The renderer joins pieces when the text buffer grows. Let me find where that happens.\n\n\nFind the append call sites in the render loop.\n\n\n\n<|DSML|parameter name=\"command\" string=\"true\">cd /proj/native\ngrep -n 'append' src/render.cpp | head -20\n\n"},{"seq":5443,"finish":"stop","text":"The join rule handles the boundary marker. Let me inspect the decoder.\n\n\nInspect how boundary markers turn into separators.\n\n\n\n<|DSML|parameter name=\"command\" string=\"true\">cd /proj/native\ngrep -rn 'decode' src/*.cpp src/*.hpp | head\necho \"=== impl ===\"\ngrep -rn 'std::string decode' src/\n\n"},{"seq":5608,"finish":"tool-calls","text":"The worker pool defaults to 8 threads, which oversubscribes a phone. Let me expose a thread-count knob with a sensible default.\n\n\nAdd a small native setter and call it before first inference.\n\n\n<|DSML|parameter name=\"edit\">\n<|DSML|parameter name=\"newString\" string=\"true\">#include \"engine_capi.h\"\n\nnamespace rt { void set_num_threads(int n); }\n<|DSML|parameter name=\"oldString\" string=\"true\">#include \"engine_capi.h\"\n<|DSML|parameter name=\"path\" string=\"true\">/proj/app/src/main/cpp/engine_jni.cpp\n"},{"seq":5624,"finish":"stop","text":"Call the thread setter during preload, tuned to the fast cores.\n\n\nSet threads once before loading, capped to the big cluster.\n\n\n\n<|DSML|parameter name=\"newString\" string=\"true\"> loading = true\n val threads = (cores / 2).coerceIn(2, 6)\n try { Native.setThreads(threads) } catch (_: Throwable) { }\n<|DSML|parameter name=\"oldString\" string=\"true\"> loading = true\n<|DSML|parameter name=\"path\" string=\"true\">/proj/app/src/main/java/com/example/app/engine/Model.kt\n\n"},{"seq":5647,"finish":"stop","text":"The declaration may have landed inside the wrong scope. Let me check its position.\n\n\nVerify the declaration sits at file scope, not inside a block.\n\n\n\n<|DSML|parameter name=\"command\" string=\"true\">cd /proj/app/src/main/cpp\ngrep -n 'namespace rt' engine_jni.cpp | head\n\n"},{"seq":5663,"finish":"stop","text":"The declaration didn't apply. Let me add it right after the include.\n\n\nInsert after the capi include with a unique anchor.\n\n\n\n<|DSML|parameter name=\"newString\" string=\"true\">#include \"engine_capi.h\"\n\nnamespace rt { void set_num_threads(int n); }\n<|DSML|parameter name=\"oldString\" string=\"true\">#include \"engine_capi.h\"\n<|DSML|parameter name=\"path\" string=\"true\">/proj/app/src/main/cpp/engine_jni.cpp\n\n"},{"seq":5679,"finish":"stop","text":"Odd scoping at the call site. Let me check line numbers around it.\n\n\nCheck declaration location versus call site ordering.\n\n\n\n<|DSML|parameter name=\"command\" string=\"true\">cd /proj/app/src/main/cpp\ngrep -n 'set_num_threads' engine_jni.cpp\n\n"},{"seq":5695,"finish":"stop","text":"The declaration is still missing. Let me write it directly.\n\n\nInsert with a precise unique anchor this time.\n\n\n\n<|DSML|parameter name=\"newString\" string=\"true\">#include \"engine_capi.h\"\n\nnamespace rt { void set_num_threads(int n); }\n<|DSML|parameter name=\"oldString\" string=\"true\">#include \"engine_capi.h\"\n<|DSML|parameter name=\"path\" string=\"true\">/proj/app/src/main/cpp/engine_jni.cpp\n\n"}] diff --git a/test/parse.test.ts b/test/parse.test.ts new file mode 100644 index 0000000..29a4d0e --- /dev/null +++ b/test/parse.test.ts @@ -0,0 +1,255 @@ +/** + * Parser tests. One `test(...)` per row in docs/TEST-MATRIX.md. + * Fixtures are built at runtime (test/fixtures.ts) so no live DSML token is stored. + */ + +import { describe, expect, test } from "bun:test" +import { parseDsml, stripRanges } from "../src/parse.ts" +import { BAR, block, closeTag, eos, invoke, marker, markerAscii, markerDoubled, param, tag, text } from "./fixtures.ts" + +const args = (calls: { name: string; arguments: Record }[], i = 0) => calls[i]!.arguments +const one = (result: { calls: unknown[] }) => { + expect(result.calls).toHaveLength(1) + return result.calls[0] as { name: string; arguments: Record } +} + +describe("parseDsml — variants", () => { + test("v1-canonical", () => { + const src = text("before ", block([invoke("bash", [param("command", "ls -la")])]), " after") + const r = parseDsml(src) + expect(one(r).name).toBe("bash") + expect(args(r.calls)).toEqual({ command: "ls -la" }) + expect(r.ranges).toHaveLength(1) + expect(stripRanges(src, r.ranges)).toBe("before after") + }) + + test("v2-doubled-bars", () => { + const m = markerDoubled() + const r = parseDsml(block([invoke("bash", [param("command", "pwd", { m })], { m })], { m })) + expect(one(r).name).toBe("bash") + }) + + test("v3-ascii-pipes", () => { + const m = markerAscii() + const r = parseDsml(block([invoke("bash", [param("command", "pwd", { m })], { m })], { m })) + expect(one(r).name).toBe("bash") + }) + + test("v4-marker-space (space before mangled tag)", () => { + // + const open = `<${marker()} calls>` + const close = `` + const src = open + invoke("bash", [param("command", "pwd")]) + close + const r = parseDsml(src) + expect(one(r).name).toBe("bash") + expect(stripRanges(src, r.ranges)).toBe("") + }) + + test("v5-truncated-tag (bare `calls`)", () => { + const r = parseDsml(block([invoke("bash", [param("command", "pwd")])], { kind: "calls" })) + expect(one(r).name).toBe("bash") + }) + + test("v6-name-spacing", () => { + const src = `${tag("tool_calls")}<${marker()}invoke name = "bash">${param("command", "pwd")}${closeTag("invoke")}${closeTag("tool_calls")}` + const r = parseDsml(src) + expect(one(r).name).toBe("bash") + }) + + test("v7-tool-call-singular", () => { + const r = parseDsml(block([invoke("read", [param("path", "a.ts")])], { kind: "tool_call" })) + expect(one(r).name).toBe("read") + }) + + test("v8-function-calls (V3.2)", () => { + const r = parseDsml(block([invoke("glob", [param("pattern", "*.ts")])], { kind: "function_calls" })) + expect(one(r).name).toBe("glob") + }) + + test("v9-orphan-invoke (no outer block)", () => { + const src = text("prose ", invoke("edit", [param("path", "Main.kt")]), " tail") + const r = parseDsml(src) + expect(one(r).name).toBe("edit") + expect(stripRanges(src, r.ranges)).toBe("prose tail") + }) + + test("v10-orphan-parameter (no invoke) — strip, no call", () => { + const src = text("x ", tag("parameter", 'name="edit" string="true"'), "body", closeTag("parameter"), " y") + const r = parseDsml(src) + expect(r.calls).toHaveLength(0) + expect(stripRanges(src, r.ranges)).toBe("x y") + }) + + test("v11-misspelled-closer does not swallow the next parameter", () => { + // first second... + const a = `<${marker()}parameter name="alpha" string="true">first` + const b = param("beta", "second") + const src = block([invoke("record_item", [a, b])]) + const r = parseDsml(src) + expect(one(r).arguments).toEqual({ beta: "second" }) + }) + + test("v12-runaway-name — no call, strip", () => { + const src = text("x ", tag("invoke", 'name="record_item {"'), "\ncategory: Dexes") + const r = parseDsml(src) + expect(r.calls).toHaveLength(0) + expect(r.ranges.length).toBeGreaterThan(0) + }) + + test("v13-undeclared-tool is kept", () => { + const r = parseDsml(block([invoke("totally_made_up", [param("x", "y")])])) + expect(one(r).name).toBe("totally_made_up") + }) + + test("v14-empty-name — no trap", () => { + const r = parseDsml(block([invoke("", [param("x", "y")])])) + expect(r.calls).toHaveLength(0) + }) + + test("v15-unclosed block flushes, keeps complete invokes", () => { + const open = tag("tool_calls") + const src = text("p ", open, invoke("bash", [param("command", "pwd")]), " trailing") + const r = parseDsml(src) + expect(one(r).name).toBe("bash") + // Everything from the opener to the end is ranged away. + expect(stripRanges(src, r.ranges)).toBe("p") + }) + + test("v16-truncated-invoke keeps earlier calls", () => { + const open = tag("tool_calls") + const src = text(open, invoke("a", [param("x", "1")]), tag("invoke", 'name="b"'), param("y", "2")) + const r = parseDsml(src) + expect(r.calls.map((c) => c.name)).toEqual(["a"]) + }) + + test("v18-surrounding text preserved", () => { + const src = text("hello ", block([invoke("bash", [param("command", "pwd")])]), " world") + const r = parseDsml(src) + expect(stripRanges(src, r.ranges)).toBe("hello world") + }) + + test("v19-multiple invokes and blocks", () => { + const src = text( + block([invoke("a", [param("x", "1")]), invoke("b", [param("y", "2")])]), + " mid ", + block([invoke("c", [param("z", "3")]), invoke("d", [param("w", "4")])]), + ) + const r = parseDsml(src) + expect(r.calls.map((c) => c.name)).toEqual(["a", "b", "c", "d"]) + }) + + test("v20-code-fence ignored", () => { + const src = text("```\n", block([invoke("bash", [param("command", "rm -rf /")])]), "\n```") + const r = parseDsml(src) + expect(r.calls).toHaveLength(0) + expect(r.ranges).toHaveLength(0) + }) + + test("v21-eos stripped", () => { + const src = text(block([invoke("a", [param("x", "1")])]), eos()) + const r = parseDsml(src) + expect(one(r).name).toBe("a") + expect(stripRanges(src, r.ranges)).toBe("") + }) + + test("v22-entities decoded", () => { + const r = parseDsml(block([invoke("bash", [param("command", "a & b "q"")])])) + expect(one(r).arguments).toEqual({ command: 'a & b "q"' }) + }) + + test("v23-multiline value preserved", () => { + const value = "\nline1\n line2\n" + const r = parseDsml(block([invoke("edit", [param("content", value)])])) + expect(one(r).arguments).toEqual({ content: "line1\n line2" }) + }) + + test("v24-json parameter", () => { + const r = parseDsml(block([invoke("q", [param("questions", '[{"a":1}]', { json: true })])])) + expect(one(r).arguments).toEqual({ questions: [{ a: 1 }] }) + }) + + test("v25-raw-json-body", () => { + const src = `${tag("tool_calls")}<${marker()}invoke name="q">{"questions":[1,2]}${closeTag("invoke")}${closeTag("tool_calls")}` + const r = parseDsml(src) + expect(one(r).arguments).toEqual({ questions: [1, 2] }) + }) + + test("v26-wrapped-tool (markerless outer, DB 5177 shape)", () => { + const src = text( + "prose ", + ``, + param("newString", "a"), + param("oldString", "b"), + param("path", "f.kt"), + `${closeTag("invoke")}`, + ) + const r = parseDsml(src) + expect(one(r).name).toBe("edit") + expect(one(r).arguments).toEqual({ newString: "a", oldString: "b", path: "f.kt" }) + expect(stripRanges(src, r.ranges)).toBe("prose") + }) + + test("v26-wrapped-tool with marked outer (DB 5608 shape)", () => { + const m = marker() + const src = text( + `<${m}parameter name="shell">`, + param("command", "pwd"), + ``, + ) + const r = parseDsml(src) + expect(one(r).name).toBe("shell") + expect(one(r).arguments).toEqual({ command: "pwd" }) + }) + + test("v26-plain prose parameter untouched", () => { + const src = "the element holds a value" + const r = parseDsml(src) + expect(r.calls).toHaveLength(0) + expect(r.ranges).toHaveLength(0) + }) + + test("v26-nested incomplete inner → no call", () => { + const src = text( + ``, + tag("parameter", 'name="newString" string="true"'), + "half-written", + ) + const r = parseDsml(src) + expect(r.calls).toHaveLength(0) + }) +}) + +describe("parseDsml — reject arms", () => { + test("reject-no-marker", () => { + expect(parseDsml("hello world")).toEqual({ calls: [], ranges: [] }) + }) + + test("reject-not-dsml fast path", () => { + expect(parseDsml("a".repeat(10000))).toEqual({ calls: [], ranges: [] }) + }) + + test("reject-empty-args", () => { + expect(parseDsml(block([invoke("bash", [])])).calls).toHaveLength(0) + }) +}) + +describe("parseDsml — captured real shape", () => { + test("stream-multiline-edit (DB seq 5177 shape) recovers the wrapped call", () => { + // The real leak: an orphan `parameter name="edit"` wrapping complete inner + // params. V26 recovers it as a real call (V13 policy: runtime validates). + const src = text( + `\u2039/analysis\u203a\n\n`, + tag("parameter", 'name="edit"'), + "\n", + param("newString", "val x = 1"), + "\n", + param("oldString", "val x = 0"), + "\n", + closeTag("invoke"), + ) + const r = parseDsml(src) + expect(r.calls).toHaveLength(1) + expect(r.calls[0]!.name).toBe("edit") + expect(stripRanges(src, r.ranges)).toBe("\u2039/analysis\u203a") + }) +}) diff --git a/test/sse.test.ts b/test/sse.test.ts new file mode 100644 index 0000000..b5ab1a9 --- /dev/null +++ b/test/sse.test.ts @@ -0,0 +1,300 @@ +/** + * SSE rewriter tests. Mirror of test/stream.test.ts, asserting on the rewritten + * OpenAI-Chat SSE bytes — the exact contract the native protocol parser reads. + * Plus a golden run over the real DB failure messages serialized as SSE. + */ + +import { describe, expect, test } from "bun:test" +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider" +import { SseDsmlRewriter } from "../src/sse.ts" +import { createDsmlStreamMiddleware } from "../src/middleware.ts" +import { BAR, block, invoke, marker, param, tag, text } from "./fixtures.ts" +import raw from "./golden/db-messages.json" with { type: "json" } + +function sseText(delta: string): string { + return `data: ${JSON.stringify({ choices: [{ delta: { content: delta }, index: 0 }] })}\n\n` +} + +function sseFinish(reason: string): string { + return `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: reason, index: 0 }] })}\n\n` +} + +/** Feed whole frames; split the byte stream awkwardly to simulate chunking. */ +function runStream(bytes: string, split = 7): string[] { + const rewriter = new SseDsmlRewriter() + const out: string[] = [] + for (let i = 0; i < bytes.length; i += split) { + out.push(...rewriter.push(bytes.slice(i, i + split))) + } + out.push(...rewriter.flush()) + return out +} + +/** Parse emitted frames back into events for assertions. */ +function parseOut(frames: string[]): Array<{ content?: string; toolCall?: { name: string; args: string }; finish?: string }> { + const events: Array<{ content?: string; toolCall?: { name: string; args: string }; finish?: string }> = [] + const calls = new Map() + for (const frame of frames) { + for (const line of frame.split("\n")) { + const payload = line.startsWith("data:") ? line.slice(5).trimStart() : line + if (!payload || payload === "[DONE]") continue + const event = JSON.parse(payload) as { + choices?: Array<{ delta?: { content?: string; tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string } }> }; finish_reason?: string }> + } + const choice = event.choices?.[0] + if (!choice) continue + if (typeof choice.delta?.content === "string") events.push({ content: choice.delta.content }) + for (const tc of choice.delta?.tool_calls ?? []) { + const entry = calls.get(tc.index) ?? { args: "" } + if (tc.function?.name) entry.name = tc.function.name + if (tc.function?.arguments) entry.args += tc.function.arguments + calls.set(tc.index, entry) + } + if (typeof choice.finish_reason === "string") events.push({ finish: choice.finish_reason }) + } + } + for (const call of calls.values()) { + if (call.name) events.push({ toolCall: { name: call.name, args: call.args } }) + } + return events +} + +const textOf = (events: ReturnType) => + events.filter((e) => e.content !== undefined).map((e) => e.content as string).join("") +const callsOf = (events: ReturnType) => events.filter((e) => e.toolCall !== undefined) +const finishOf = (events: ReturnType) => events.find((e) => e.finish !== undefined)?.finish + +describe("sse rewriter", () => { + test("plain text passes through with identical bytes", () => { + const bytes = sseText("Hello, ") + sseText("world!") + sseFinish("stop") + "data: [DONE]\n\n" + const events = parseOut(runStream(bytes, 11)) + expect(textOf(events)).toBe("Hello, world!") + expect(callsOf(events)).toHaveLength(0) + expect(finishOf(events)).toBe("stop") + }) + + test("recovers a doubled-bar call split across chunks, flips finish", () => { + const doubles = marker(BAR, 2) + expect(doubles).toContain("DSML") + const body = `<${doubles}tool_calls><${doubles}invoke name="bash"><${doubles}parameter name="command" string="true">ls -la` + const bytes = sseText(body) + sseFinish("stop") + "data: [DONE]\n\n" + const events = parseOut(runStream(bytes, 13)) + const calls = callsOf(events) + expect(calls).toHaveLength(1) + expect(calls[0]!.toolCall!.name).toBe("bash") + expect(JSON.parse(calls[0]!.toolCall!.args)).toEqual({ command: "ls -la" }) + expect(textOf(events)).toBe("") + expect(textOf(events)).not.toContain("DSML") + expect(finishOf(events)).toBe("tool_calls") + }) + + test("recovers the wrapped-tool shape (DB degradation)", () => { + const bytes = sseText(text("pre ", ``, param("path", "Main.kt"), " post")) + sseFinish("stop") + "data: [DONE]\n\n" + const events = parseOut(runStream(bytes)) + const calls = callsOf(events) + expect(calls).toHaveLength(1) + expect(calls[0]!.toolCall!.name).toBe("edit") + expect(finishOf(events)).toBe("tool_calls") + }) + + test("unparseable markup passes through, finish untouched", () => { + const bytes = sseText(text(tag("tool_calls"), tag("invoke", 'name="x"'))) + sseFinish("stop") + "data: [DONE]\n\n" + const events = parseOut(runStream(bytes)) + expect(callsOf(events)).toHaveLength(0) + expect(finishOf(events)).toBe("stop") + }) + + test("non-SSE payloads pass through", () => { + const rewriter = new SseDsmlRewriter() + const out = [...rewriter.push('{"error":{"message":"boom"}}'), ...rewriter.flush()] + expect(out.join("")).toContain("boom") + }) + + test("usage chunks pass through", () => { + const bytes = sseText("hi") + `data: ${JSON.stringify({ usage: { prompt_tokens: 3 } })}\n\n` + sseFinish("stop") + "data: [DONE]\n\n" + const events = parseOut(runStream(bytes)) + expect(textOf(events)).toBe("hi") + }) + + test("combined content+finish in one event keeps both", () => { + // Some gateways terminate the stream with content and finish_reason together. + // Dropping either breaks the run ("stream ended without finish_reason"). + const bytes = + sseText("done") + + `data: ${JSON.stringify({ choices: [{ delta: { content: "!" }, finish_reason: "stop", index: 0 }] })}\n\n` + + "data: [DONE]\n\n" + const events = parseOut(runStream(bytes)) + expect(textOf(events)).toBe("done!") + expect(finishOf(events)).toBe("stop") + }) +}) + +describe("settled stats", () => { + test("sse rewriter reports recovery counts once", () => { + const seen: Array<{ calls: number; sawCandidate: boolean }> = [] + const rewriter = new SseDsmlRewriter({ onSettled: (s) => seen.push(s) }) + const bytes = sseText(block([invoke("bash", [param("command", "pwd")])])) + sseFinish("stop") + "data: [DONE]\n\n" + for (let i = 0; i < bytes.length; i += 23) rewriter.push(bytes.slice(i, i + 23)) + rewriter.flush() + rewriter.flush() + expect(seen).toHaveLength(1) + expect(seen[0]!.calls).toBe(1) + expect(seen[0]!.sawCandidate).toBe(true) + }) + + test("sse rewriter reports pure passthrough", () => { + const seen: Array<{ calls: number; sawCandidate: boolean }> = [] + const rewriter = new SseDsmlRewriter({ onSettled: (s) => seen.push(s) }) + rewriter.push(sseText("plain text here") + sseFinish("stop")) + rewriter.flush() + expect(seen).toHaveLength(1) + expect(seen[0]).toEqual({ calls: 0, sawCandidate: false }) + }) + + test("middleware reports settled stats", async () => { + const seen: Array<{ calls: number; sawCandidate: boolean }> = [] + const middleware = createDsmlStreamMiddleware({ onSettled: (s) => seen.push(s) }) + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: "stream-start", warnings: [] }) + c.enqueue({ type: "text-start", id: "t1" }) + c.enqueue({ type: "text-delta", id: "t1", delta: "just prose" }) + c.enqueue({ type: "text-end", id: "t1" }) + c.enqueue({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: {} as never }) + c.close() + }, + }) + const wrapped = await middleware.wrapStream!({ + doStream: async () => ({ stream: source }), + doGenerate: (async () => ({})) as never, + params: {} as never, + model: {} as never, + }) + const reader = wrapped.stream.getReader() + for (;;) { + const { done } = await reader.read() + if (done) break + } + expect(seen).toEqual([{ calls: 0, sawCandidate: false }]) + }) +}) + +describe("parallel safety", () => { + test("concurrent streams produce unique call ids", async () => { + const bodies = Array.from( + { length: 8 }, + (_, i) => block([invoke(`tool${i}`, [param("x", String(i))])]), + ) + const runOne = async (body: string) => { + const middleware = createDsmlStreamMiddleware() + const stream = new ReadableStream({ + start(c) { + c.enqueue({ type: "stream-start", warnings: [] }) + c.enqueue({ type: "text-start", id: "t1" }) + c.enqueue({ type: "text-delta", id: "t1", delta: body }) + c.enqueue({ type: "text-end", id: "t1" }) + c.enqueue({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: {} as never }) + c.close() + }, + }) + const wrapped = await middleware.wrapStream!({ + doStream: async () => ({ stream }), + doGenerate: (async () => ({})) as never, + params: {} as never, + model: {} as never, + }) + const ids: string[] = [] + const reader = wrapped.stream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (value.type === "tool-call") ids.push(value.toolCallId) + } + return ids + } + const all = (await Promise.all(bodies.map(runOne))).flat() + expect(all).toHaveLength(8) + expect(new Set(all).size).toBe(8) + }) + + test("concurrent sse rewriters keep separate state", () => { + const a = new SseDsmlRewriter() + const b = new SseDsmlRewriter() + const outA = a.push(sseText(block([invoke("ta", [param("x", "1")])])) ) + const outB = b.push(sseText("plain prose, no markup here") + sseFinish("stop")) + // A already resolved its complete block; B (plain prose) is unaffected. + expect(a.recoveredCalls).toBe(true) + expect(outA.join("")).not.toMatch(/DSML/) + expect(b.recoveredCalls).toBe(false) + const allB = outB.join("") + b.flush().join("") + expect(allB).toContain("plain prose") + }) +}) + +describe("sse golden: DB session leaks", () => { + interface GoldenMessage { + seq: number + finish: string + text: string + } + const messages = raw as GoldenMessage[] + + for (const m of messages) { + test(`seq ${m.seq}: recovers call end to end over SSE`, () => { + // One content delta per 64 chars (coarse chunking: structure mostly whole). + let bytes = "" + for (let i = 0; i < m.text.length; i += 64) bytes += sseText(m.text.slice(i, i + 64)) + bytes += sseFinish("stop") + "data: [DONE]\n\n" + const events = parseOut(runStream(bytes, 97)) + const calls = callsOf(events) + expect(calls.length).toBeGreaterThan(0) + expect(["edit", "shell"]).toContain(calls[0]!.toolCall!.name) + expect(finishOf(events)).toBe("tool_calls") + expect(textOf(events)).not.toMatch(/DSML/) + }) + } + + test("mixed native + leaked call keeps both with distinct indexes", () => { + // The provider emits a proper native call AND leaks a DSML block as text + // (the Bedrock #45600 shape). Both must survive with distinct index/id. + const nativeCall = `data: ${JSON.stringify({ + choices: [{ delta: { tool_calls: [{ index: 0, id: "call_native1", function: { name: "bash", arguments: '{"command":"pwd"}' } }] }, index: 0 }], + })}\n\n` + const leak = sseText(text("checking: ", block([invoke("read", [param("path", "a.ts")])]))) + const bytes = nativeCall + leak + sseFinish("stop") + "data: [DONE]\n\n" + const rewriter = new SseDsmlRewriter() + const frames = [...rewriter.push(bytes), ...rewriter.flush()].join("") + // Collect every (index, id/name, arguments) triple from the output stream. + const seen: Array<{ index: number; id?: string; name?: string; args?: string }> = [] + for (const line of frames.split("\n")) { + if (!line.startsWith("data:")) continue + const payload = line.slice(5).trimStart() + if (!payload || payload === "[DONE]") continue + const event = JSON.parse(payload) as { + choices?: Array<{ delta?: { tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string } }> } }> + } + for (const tc of event.choices?.[0]?.delta?.tool_calls ?? []) { + seen.push({ index: tc.index, id: tc.id, name: tc.function?.name, args: tc.function?.arguments }) + } + } + const byIndex = new Map() + for (const s of seen) byIndex.set(s.index, [...(byIndex.get(s.index) ?? []), s]) + // Native index 0 carries only the native call; synthetic lives elsewhere. + const nativeParts = (byIndex.get(0) ?? []).filter((s) => s.id === "call_native1") + expect(nativeParts.length).toBeGreaterThan(0) + expect(nativeParts.every((s) => s.name === undefined || s.name === "bash")).toBe(true) + const synthIndexes = [...byIndex.keys()].filter((i) => i !== 0) + expect(synthIndexes.length).toBe(1) + const synthParts = byIndex.get(synthIndexes[0]!)! + // Name chunk carries the generated id; continuation chunks share the index. + expect(synthParts.some((s) => s.name === "read")).toBe(true) + const synthArgs = synthParts.map((s) => s.args ?? "").join("") + expect(JSON.parse(synthArgs)).toEqual({ path: "a.ts" }) + // No native part may carry synthetic arguments and vice versa. + expect(nativeParts.map((s) => s.args ?? "").join("")).not.toContain("a.ts") + }) + + test("block helper sanity", () => { + expect(block([invoke("bash", [param("command", "pwd")])])).toContain("tool_calls") + }) +}) diff --git a/test/stream.test.ts b/test/stream.test.ts new file mode 100644 index 0000000..6cbf747 --- /dev/null +++ b/test/stream.test.ts @@ -0,0 +1,223 @@ +/** + * Streaming middleware tests. One `test(...)` per row in docs/TEST-MATRIX.md under + * "Streaming". We drive the middleware the same way `wrapLanguageModel` does, and + * assert on the emitted AI-SDK parts — the exact contract OpenCode's lowering reads. + */ + +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider" +import { describe, expect, test } from "bun:test" +import { createDsmlStreamMiddleware } from "../src/middleware.ts" +import { BAR, block, closeTag, invoke, marker, param, tag, text } from "./fixtures.ts" + +function source(deltas: string[], finish: "stop" | "tool-calls" = "stop") { + const parts: LanguageModelV3StreamPart[] = [ + { type: "stream-start", warnings: [] }, + { type: "text-start", id: "t1" }, + ...deltas.map((delta): LanguageModelV3StreamPart => ({ type: "text-delta", id: "t1", delta })), + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: finish, raw: finish }, usage: {} as never }, + ] + return new ReadableStream({ + start(controller) { + for (const part of parts) controller.enqueue(part) + controller.close() + }, + }) +} + +async function run(deltas: string[], finish: "stop" | "tool-calls" = "stop") { + const middleware = createDsmlStreamMiddleware() + const wrapped = await middleware.wrapStream!({ + doStream: async () => ({ stream: source(deltas, finish) }), + doGenerate: (async () => ({})) as never, + params: {} as never, + model: {} as never, + }) + const events: LanguageModelV3StreamPart[] = [] + const reader = wrapped.stream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +const streamedText = (events: LanguageModelV3StreamPart[]) => + events.filter((e): e is Extract => e.type === "text-delta").map((e) => e.delta).join("") +const toolCalls = (events: LanguageModelV3StreamPart[]) => + events.filter((e): e is Extract => e.type === "tool-call") +const finishOf = (events: LanguageModelV3StreamPart[]) => + events.find((e): e is Extract => e.type === "finish")! +const lifecycle = (events: LanguageModelV3StreamPart[]) => + events.filter((e) => e.type === "tool-input-start" || e.type === "tool-input-delta" || e.type === "tool-input-end").map((e) => e.type) + +describe("streaming middleware", () => { + test("stream-text-passthrough", async () => { + const events = await run(["Hello, ", "world!"]) + expect(streamedText(events)).toBe("Hello, world!") + expect(toolCalls(events)).toHaveLength(0) + expect(finishOf(events).finishReason.unified).toBe("stop") + }) + + test("stream-v2-recovered (doubled bars, chunked per token)", async () => { + // The captured CherryStudio SSE shape: every marker character split. + const doubles = marker(BAR, 2) + const deltas = [ + "<", + `${BAR}${BAR}`, + "DS", + "ML", + `${BAR}${BAR}`, + "tool", + "_c", + "alls", + ">", + "<", + `${BAR}${BAR}`, + "DS", + "ML", + `${BAR}${BAR}`, + 'invoke name="bash">', + "<", + `${BAR}${BAR}`, + "DS", + "ML", + `${BAR}${BAR}`, + 'parameter name="command" string="true">ls -la', + "", + "", + "", + ] + expect(doubles).toContain("DSML") + const events = await run(deltas) + const calls = toolCalls(events) + expect(calls).toHaveLength(1) + expect(calls[0]!.toolName).toBe("bash") + expect(JSON.parse(calls[0]!.input)).toEqual({ command: "ls -la" }) + expect(streamedText(events)).toBe("") + expect(streamedText(events)).not.toContain("DSML") + }) + + test("stream-finish-rewrite", async () => { + const events = await run([block([invoke("bash", [param("command", "pwd")])])]) + expect(finishOf(events).finishReason.unified).toBe("tool-calls") + }) + + test("stream-finish-untouched", async () => { + const events = await run(["no tools here"]) + expect(finishOf(events).finishReason.unified).toBe("stop") + }) + + test("stream-unclosed-outer-but-complete-invoke is recovered (V9/V15)", async () => { + // Outer block never closes, but the inner invoke is complete. Recovering the + // unambiguous call beats dropping it — this matches the DB degradation. + const deltas = [tag("tool_calls"), invoke("bash", [param("command", "pwd")])] + const events = await run(deltas) + expect(toolCalls(events)).toHaveLength(1) + expect(toolCalls(events)[0]!.toolName).toBe("bash") + }) + + test("stream-truncated-invoke falls back to text", async () => { + // Opener plus an invoke that never closes: nothing usable, so flush verbatim. + const deltas = [tag("tool_calls"), tag("invoke", 'name="bash"'), param("command", "pwd")] + const events = await run(deltas) + expect(toolCalls(events)).toHaveLength(0) + expect(streamedText(events)).toContain("DSML") + expect(finishOf(events).finishReason.unified).toBe("stop") + }) + + test("stream-empty-invoke-fallback", async () => { + const deltas = [block([tag("invoke", 'name="x"')])] + const events = await run(deltas) + expect(toolCalls(events)).toHaveLength(0) + expect(finishOf(events).finishReason.unified).toBe("stop") + }) + + test("stream-surrounding", async () => { + const events = await run(["a ", block([invoke("bash", [param("command", "pwd")])]), " b"]) + expect(toolCalls(events)).toHaveLength(1) + expect(streamedText(events)).toBe("a b") + }) + + test("stream-orphan-invoke (V9 degradation)", async () => { + const events = await run(["pre ", invoke("edit", [param("path", "Main.kt")]), " post"]) + const calls = toolCalls(events) + expect(calls).toHaveLength(1) + expect(calls[0]!.toolName).toBe("edit") + }) + + test("lifecycle ordering around each call", async () => { + const events = await run([block([invoke("a", [param("x", "1")])])]) + expect(lifecycle(events)).toEqual(["tool-input-start", "tool-input-delta", "tool-input-end"]) + }) + + test("stream-buffer-cap falls back to text", async () => { + const huge = tag("tool_calls") + "x".repeat(70 * 1024) + const events = await run([huge]) + expect(toolCalls(events)).toHaveLength(0) + expect(streamedText(events)).toContain("DSML") + }) + + test("stream-eager-stop: prose opener flushes early, nothing held", async () => { + // A wrapped-candidate opener that never develops call structure: the hold + // must release as text (eager stop), not wait for text-end. + const events = await run([' just prose, no structure here']) + expect(toolCalls(events)).toHaveLength(0) + // Text is emitted (possibly across two deltas: head + flushed hold), fully. + expect(streamedText(events)).toBe(' just prose, no structure here') + expect(finishOf(events).finishReason.unified).toBe("stop") + }) + + test("stream-transition: foreign part flushes held text first", async () => { + const middleware = createDsmlStreamMiddleware() + const parts: LanguageModelV3StreamPart[] = [ + { type: "stream-start", warnings: [] }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "hello " }, + { type: "reasoning-start", id: "r1" }, + { type: "reasoning-delta", id: "r1", delta: "thinking" }, + { type: "reasoning-end", id: "r1" }, + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: {} as never }, + ] + const stream = new ReadableStream({ + start(c) { + for (const p of parts) c.enqueue(p) + c.close() + }, + }) + const wrapped = await middleware.wrapStream!({ + doStream: async () => ({ stream }), + doGenerate: (async () => ({})) as never, + params: {} as never, + model: {} as never, + }) + const events: LanguageModelV3StreamPart[] = [] + const reader = wrapped.stream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + const order = events.map((e) => e.type) + // "hello " text must precede the reasoning block (no reordering). + expect(order.indexOf("text-delta")).toBeLessThan(order.indexOf("reasoning-start")) + expect(streamedText(events)).toBe("hello ") + }) +}) diff --git a/test/wrapper.test.ts b/test/wrapper.test.ts new file mode 100644 index 0000000..eaacced --- /dev/null +++ b/test/wrapper.test.ts @@ -0,0 +1,96 @@ +/** The dependency-free language-model wrapper: stream + generate recovery. */ + +import type { LanguageModelV3, LanguageModelV3CallOptions } from "@ai-sdk/provider" +import { describe, expect, test } from "bun:test" +import { recoverGenerateResult, wrapDsmlLanguageModel } from "../src/middleware.ts" +import { block, invoke, param } from "./fixtures.ts" + +function fakeModel(deltas: string[], finish: "stop" | "tool-calls" = "stop"): LanguageModelV3 { + return { + specificationVersion: "v3", + provider: "opencode-go", + modelId: "deepseek-v4.1-flash", + supportedUrls: {}, + async doGenerate() { + return { + content: [{ type: "text", text: deltas.join("") }], + finishReason: { unified: finish, raw: finish }, + usage: { inputTokens: { total: 1 }, outputTokens: { total: 1 } }, + warnings: [], + } + }, + async doStream() { + const parts = [ + { type: "stream-start", warnings: [] }, + { type: "text-start", id: "t1" }, + ...deltas.map((delta) => ({ type: "text-delta", id: "t1", delta })), + { type: "text-end", id: "t1" }, + { type: "finish", finishReason: { unified: finish, raw: finish }, usage: {} }, + ] + return { + stream: new ReadableStream({ + start(c) { + for (const p of parts) c.enqueue(p) + c.close() + }, + }), + } + }, + } as unknown as LanguageModelV3 +} + +const opts = {} as unknown as LanguageModelV3CallOptions + +async function collect(stream: ReadableStream) { + const out: Array<{ type: string; [k: string]: unknown }> = [] + const reader = stream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + out.push(value) + } + return out +} + +describe("wrapDsmlLanguageModel", () => { + test("stream recovers a call and flips finish", async () => { + const wrapped = wrapDsmlLanguageModel(fakeModel([block([invoke("bash", [param("command", "pwd")])])])) + const result = await wrapped.doStream(opts) + const events = await collect(result.stream as ReadableStream) + expect(events.some((e) => e.type === "tool-call")).toBe(true) + const finish = events.find((e) => e.type === "finish") as unknown as { finishReason: { unified: string } } + expect(finish.finishReason.unified).toBe("tool-calls") + }) + + test("plain text passes through untouched", async () => { + const wrapped = wrapDsmlLanguageModel(fakeModel(["hello world"])) + const result = await wrapped.doStream(opts) + const events = await collect(result.stream as ReadableStream) + expect(events.some((e) => e.type === "tool-call")).toBe(false) + expect(events.filter((e) => e.type === "text-delta").map((e) => e.delta).join("")).toBe("hello world") + }) + + test("generate recovers a call", async () => { + const wrapped = wrapDsmlLanguageModel(fakeModel(["lead ", block([invoke("q", [param("a", "1")])]), " tail"])) + const result = await wrapped.doGenerate(opts) + expect(result.content.some((p) => p.type === "tool-call")).toBe(true) + expect(result.finishReason.unified).toBe("tool-calls") + }) + + test("delegates identity fields", () => { + const wrapped = wrapDsmlLanguageModel(fakeModel([])) + expect(wrapped.provider).toBe("opencode-go") + expect(wrapped.modelId).toBe("deepseek-v4.1-flash") + expect(wrapped.specificationVersion).toBe("v3") + }) +}) + +describe("recoverGenerateResult", () => { + test("unparseable block returns input unchanged", () => { + const content = [{ type: "text", text: "plain" }] + const finishReason = { unified: "stop", raw: "stop" } as const + const r = recoverGenerateResult(content as never, finishReason) + expect(r.recovered).toBe(0) + expect(r.finishReason.unified).toBe("stop") + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..409fc25 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["bun-types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true + }, + "include": ["src", "test"] +}