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.
This commit is contained in:
bendtherules
2026-10-05 11:47:57 +05:30
commit 52b7776d5a
26 changed files with 4600 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tgz
+110
View File
@@ -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 `<parameter name="X">` 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)
+604
View File
@@ -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=="],
}
}
+124
View File
@@ -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>
< MARKER parameter name="KEY" string="false">JSON</ MARKER parameter>
</ MARKER invoke>
</ MARKER tool_calls>
```
- 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 DSML BAR >` (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 | `…</…tool_calls>‹EOS›` | strip EOS token | `v21-eos` |
| V22 | HTML entities in values | `&quot;` `&amp;` `&lt;` | 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 `<parameter name="X">` (marker optional) containing ≥1 complete inner `<MARKER parameter>` | 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 (`<`, `<BAR`, `<parameter name="sh`, …), or a wrapped-tool
candidate (`<parameter name="X">` 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 (`</invoke>` / `</calls>` arriving after an already
recovered call) is dropped, not leaked.
+207
View File
@@ -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 `<DSML tool_calls>` 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<AISDKHooks>`). 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
(`<`, `<BAR`, `<BAR BAR DS`) as text instead of holding them — fixed with
`partialOpenerSuffix` hold-back (V17). Streaming `emitResolved` no longer strips
when zero calls were recovered — unclosed/unparseable tails emit verbatim.
## 7. Design decisions (locked)
- Parser is a **strict acceptor**: on doubt, strip noise or leave text; never execute a
guessed tool. `v13-undeclared-tool` is intentionally kept (runtime answers
"tool not found", a better feedback loop).
- `v10` orphan parameter → **strip only, no call** (no tool name). Do not emit `unknown`.
- `v11` mis-closed param → drop the runaway argument, keep the neighbour (vLLM rule).
- `v12` runaway invoke name (`name="tool {`) → no call, strip.
- Code fences are never parsed or stripped.
- Message that already has native tool calls → strip leaked text only, do not re-run.
- Strategy A test-only for now: on idle, if last assistant text ends in DSML, send a
synthetic correction nudge, capped via `ctx.storage`.
## 8. The correction prompt (user's latest requirement)
When the parser **cannot** recover a call, and as preventive guidance, inject a
directive telling the model to always emit tool calls in the correct format **with the
outer XML layer**. Text to be finalised in `src/prompt.ts`, shape:
- Preventive: added via `ctx.session.hook("context")` or a system instruction, telling
the model the exact expected envelope (canonical single-bar, complete outer block,
one `<invoke>` per call, `<parameter>` 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 `<DSML tool_calls>` 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.
+112
View File
@@ -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 `</invoke>` | 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 &amp; b &quot;q&quot;` | 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
+27
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Plugin server entrypoint (resolved by OpenCode's local-directory loader).
* Implementation lives in `src/`.
*/
export { default } from "./src/index.ts"
+176
View File
@@ -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 `<parameter name="X">` 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)
}
+131
View File
@@ -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<HookMessage>): 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<HookMessage>,
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
}
+136
View File
@@ -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:
*
* <BAR DSML BAR tool_calls>
* <BAR DSML BAR invoke name="bash">
* <BAR DSML BAR parameter name="command" string="true">ls</BAR DSML BAR parameter>
* </BAR DSML BAR invoke>
* </BAR DSML BAR tool_calls>
*
* 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)"
/** `<MARKERtool_calls …>` and friends. */
export const blockOpenRe = (): RegExp => new RegExp(`<${MARKER}${BLOCK_TAG}\\s*>`, "giu")
/** `</MARKERtool_calls>` and friends, tolerant of an omitted closing marker. (V11) */
export const blockCloseRe = (): RegExp => new RegExp(`</${MARKER}${BLOCK_TAG}\\s*>`, "giu")
/** `<MARKERinvoke name="x">` with flexible spacing and quote style. (V6) */
export const invokeOpenRe = (): RegExp =>
new RegExp(`<${MARKER}invoke\\s+name\\s*=\\s*["']?([^"'\\s>]+)["']?\\s*>`, "giu")
/** `</MARKERinvoke>`. */
export const invokeCloseRe = (): RegExp => new RegExp(`</${MARKER}invoke\\s*>`, "giu")
/** `<MARKERparameter name="k" string="true">value</MARKERparameter>`. (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]*?)</${MARKER}parameter\\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 DSML BAR>` (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]*?)` +
`(?=</${MARKER}(?:parameter|invoke|${BLOCK_TAG})\\b` +
`|<${MARKER}parameter\\b` +
`|</${MARKER}>` +
`|$)`,
"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]*?)` +
`(?=</${MARKER}(?:parameter|invoke|${BLOCK_TAG})\\b` +
`|<${MARKER}parameter\\b` +
`|</${MARKER}>` +
`|$)`,
"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]*?)` +
`(?=</${MARKER}invoke\\b|<${MARKER}invoke\\b|</${MARKER}${BLOCK_TAG}\\s*>|$)`,
"giu",
)
/**
* A wrapped tool call (V26): an orphan `<parameter name="X">` — marker optional —
* whose body contains at least one COMPLETE inner `<MARKER parameter>` element,
* closed by a mismatched `</invoke>` / `</calls>` 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 `<parameter>` 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]*?)` +
`(?=</${MARKER}(?:invoke|${BLOCK_TAG})\\b|$)`,
"giu",
)
/**
* Any DSML-looking tag at all — used to strip fragments that never formed a full
* call (stray opens, orphan closers, a bare `parameter` with no invoke). (V9, V10, V12)
*/
export const danglingMarkerRe = (): RegExp =>
new RegExp(`</?${MARKER}${ANY_TAG}(?:\\s+[^>\\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)
+275
View File
@@ -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<string, { turn: string; attempts: number }>()
// --- 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<Parameters<typeof Plugin.define>[0]["setup"]>[0]
type Config = ReturnType<typeof resolveConfig>
async function maybeWake(
ctx: Ctx,
config: Config,
log: (...args: unknown[]) => void,
sessionID: string,
): Promise<void> {
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 }
+447
View File
@@ -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 (`<`, `<BAR`, `<parameter name="sh`, …)
* must be held back, not emitted as text, until the next chunk arrives. Only the
* text after the LAST `<` is considered, it must contain no `>`, and it must
* match the opener-prefix grammar. (V17)
*/
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
}
/** 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 (`<BAR DSML …`).
* 2. A wrapped-tool candidate: `<parameter name="X">` 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<string, unknown> }>; 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<LanguageModelV3StreamPart>, 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<LanguageModelV3StreamPart>, 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
// `</invoke>` / `</calls>` 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 `</invoke>` / `</calls>` 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<LanguageModelV3StreamPart>, 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<LanguageModelV3StreamPart, LanguageModelV3StreamPart>({
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<LanguageModelV3Content>,
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
},
}
}
+362
View File
@@ -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<string, unknown>
}
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 `<parameter name="X">` (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<ParseOptions> = {
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 `</invoke>`
// 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 `<parameter name="edit">`.
*
* 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<ParseOptions>): Orphan[] {
const orphans: Orphan[] = []
// Orphan invokes with a body: `<BAR DSML BAR invoke name="x">…`.
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 `<parameter name="X">` 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<ParseOptions>): 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<ParseOptions>): Record<string, unknown> {
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<string, unknown> {
const args: Record<string, unknown> = {}
// Positions of every mis-spelled closer `</BAR DSML BAR>` 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("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&")
}
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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
+53
View File
@@ -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)
}
+396
View File
@@ -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, unknown>): string | undefined {
const choices = event["choices"]
if (!Array.isArray(choices)) return undefined
const delta = (choices[0] as Record<string, unknown> | undefined)?.["delta"] as Record<string, unknown> | undefined
const content = delta?.["content"]
return typeof content === "string" ? content : undefined
}
function finishOf(event: Record<string, unknown>): string | undefined {
const choices = event["choices"]
if (!Array.isArray(choices)) return undefined
const reason = (choices[0] as Record<string, unknown> | 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<string, unknown>
try {
const parsed: unknown = JSON.parse(payload)
if (typeof parsed !== "object" || parsed === null) {
out.push(sseFrame(payload))
continue
}
event = parsed as Record<string, unknown>
} 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<string, unknown> | undefined) ?? {}) as Record<string, unknown>
// 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<string, unknown> | undefined)?.["tool_calls"]
if (Array.isArray(nativeList)) {
for (const tc of nativeList) {
const idx = (tc as Record<string, unknown>)?.["index"]
if (typeof idx === "number" && idx > this.maxNativeIndex) this.maxNativeIndex = idx
}
}
const firstDelta = (first["delta"] as Record<string, unknown>) ?? {}
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 (`</calls>` 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<Uint8Array>({
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 })
}
+236
View File
@@ -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</\uFF5CDSML\uFF5C>`
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 <b>c", [{ start: 2, end: 7 }], "[removed malformed edit call]")).toBe(
"a [removed malformed edit call]",
)
expect(stripRangesWithNote("a <b>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)
})
})
+58
View File
@@ -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: `<MARKERname attrs...>`. */
export const tag = (name: string, attrs = "", m = marker()) => `<${m}${name}${attrs ? " " + attrs : ""}>`
/** A closing DSML tag. */
export const closeTag = (name: string, m = marker()) => `</${m}${name}>`
/** 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("")
+118
View File
@@ -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<LanguageModelV3StreamPart>({
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<LanguageModelV3StreamPart, { type: "text-delta" }> => 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")
})
})
+128
View File
@@ -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}</${m}parameter>`
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` +
`<analysis>\nCollect state in onCreate and pass it into the settings screen.\n</analysis>\n\n` +
`<parameter name="edit">\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` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5425,
finish: "stop",
text:
`The renderer joins pieces when the text buffer grows. Let me find where that happens.\n\n` +
`<analysis>\nFind the append call sites in the render loop.\n</analysis>\n\n` +
`<parameter name="shell">\n` +
`${param("command", "cd /proj/native\ngrep -n 'append' src/render.cpp | head -20")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5443,
finish: "stop",
text:
`The join rule handles the boundary marker. Let me inspect the decoder.\n\n` +
`<analysis>\nInspect how boundary markers turn into separators.\n</analysis>\n\n` +
`<parameter name="shell">\n` +
`${param("command", "cd /proj/native\ngrep -rn 'decode' src/*.cpp src/*.hpp | head\necho \"=== impl ===\"\ngrep -rn 'std::string decode' src/")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
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` +
`<analysis>\nAdd a small native setter and call it before first inference.\n</analysis>\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` +
`</${M}invoke>`,
},
{
seq: 5624,
finish: "stop",
text:
`Call the thread setter during preload, tuned to the fast cores.\n\n` +
`<analysis>\nSet threads once before loading, capped to the big cluster.\n</analysis>\n\n` +
`<parameter name="edit">\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` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5647,
finish: "stop",
text:
`The declaration may have landed inside the wrong scope. Let me check its position.\n\n` +
`<analysis>\nVerify the declaration sits at file scope, not inside a block.\n</analysis>\n\n` +
`<parameter name="shell">\n` +
`${param("command", "cd /proj/app/src/main/cpp\ngrep -n 'namespace rt' engine_jni.cpp | head")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5663,
finish: "stop",
text:
`The declaration didn't apply. Let me add it right after the include.\n\n` +
`<analysis>\nInsert after the capi include with a unique anchor.\n</analysis>\n\n` +
`<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` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5679,
finish: "stop",
text:
`Odd scoping at the call site. Let me check line numbers around it.\n\n` +
`<analysis>\nCheck declaration location versus call site ordering.\n</analysis>\n\n` +
`<parameter name="shell">\n` +
`${param("command", "cd /proj/app/src/main/cpp\ngrep -n 'set_num_threads' engine_jni.cpp")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5695,
finish: "stop",
text:
`The declaration is still missing. Let me write it directly.\n\n` +
`<analysis>\nInsert with a precise unique anchor this time.\n</analysis>\n\n` +
`<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` +
`</${M} invoke>\n</${M} calls>`,
},
]
// 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))
File diff suppressed because one or more lines are too long
+255
View File
@@ -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<string, unknown> }[], i = 0) => calls[i]!.arguments
const one = (result: { calls: unknown[] }) => {
expect(result.calls).toHaveLength(1)
return result.calls[0] as { name: string; arguments: Record<string, unknown> }
}
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)", () => {
// <BAR DSML BAR calls>
const open = `<${marker()} calls>`
const close = `</${marker()} calls>`
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", () => {
// <BAR DSML BAR parameter name="alpha" string="true">first</BAR DSML BAR> <BAR DSML BAR parameter name="beta" ...>second...
const a = `<${marker()}parameter name="alpha" string="true">first</${marker()}>`
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</plan>")
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 &amp; b &quot;q&quot;")])]))
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 ",
`<parameter name="edit">`,
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"),
`</${m}invoke>`,
)
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 <parameter> 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(
`<parameter name="edit">`,
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")
})
})
+300
View File
@@ -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<number, { name?: string; args: string }>()
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<typeof parseOut>) =>
events.filter((e) => e.content !== undefined).map((e) => e.content as string).join("")
const callsOf = (events: ReturnType<typeof parseOut>) => events.filter((e) => e.toolCall !== undefined)
const finishOf = (events: ReturnType<typeof parseOut>) => 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</${doubles}parameter></${doubles}invoke></${doubles}tool_calls>`
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 ", `<parameter name="edit">`, 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<LanguageModelV3StreamPart>({
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<LanguageModelV3StreamPart>({
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<number, typeof seen>()
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")
})
})
+223
View File
@@ -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<LanguageModelV3StreamPart>({
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<LanguageModelV3StreamPart, { type: "text-delta" }> => e.type === "text-delta").map((e) => e.delta).join("")
const toolCalls = (events: LanguageModelV3StreamPart[]) =>
events.filter((e): e is Extract<LanguageModelV3StreamPart, { type: "tool-call" }> => e.type === "tool-call")
const finishOf = (events: LanguageModelV3StreamPart[]) =>
events.find((e): e is Extract<LanguageModelV3StreamPart, { type: "finish" }> => 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',
"</",
`${BAR}${BAR}`,
"DS",
"ML",
`${BAR}${BAR}`,
"parameter>",
"</",
`${BAR}${BAR}`,
"DS",
"ML",
`${BAR}${BAR}`,
"invoke>",
"</",
`${BAR}${BAR}`,
"DS",
"ML",
`${BAR}${BAR}`,
"tool_calls>",
]
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(['<parameter name="oops"> 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('<parameter name="oops"> 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<LanguageModelV3StreamPart>({
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 ")
})
})
+96
View File
@@ -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")
})
})
+17
View File
@@ -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"]
}