mirror of
https://github.com/bendtherules/opencode-plugin-dsml-fix.git
synced 2026-10-17 16:56:50 +00:00
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:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tgz
|
||||||
@@ -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)
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 | `"` `&` `<` | 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
@@ -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.
|
||||||
@@ -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 & b "q"` | decoded `a & b "q"` |
|
||||||
|
| `v23-multiline-value` | `param` value with `\n` and indent | value preserved, only hug newlines removed |
|
||||||
|
| `v24-json-param` | `param` `string="false"` value `{"a":1}` | args[{a:1}] as object |
|
||||||
|
| `v25-raw-json-body` | invoke body `{"a":1}` no params | args `{a:1}` |
|
||||||
|
| `v26-wrapped-tool` etc. | markerless/markered outer + complete inners | call X recovered; plain prose / incomplete nesting untouched |
|
||||||
|
| `reject-no-marker` | `"hello world"` | 0 calls, 0 ranges |
|
||||||
|
| `reject-not-dsml` | long prose, no marker | fast path (looksLikeDsml false) |
|
||||||
|
| `reject-empty-args` | `invoke("bash",[])` | 0 calls (nothing to run) |
|
||||||
|
|
||||||
|
**Note on `v10-orphan-parameter`:** a bare `parameter` with no enclosing invoke has no
|
||||||
|
tool name, so it cannot become a call. Decision: **strip the noise, emit no call**
|
||||||
|
(not `unknown`). Executing an `unknown` tool is worse than dropping an unusable
|
||||||
|
fragment. This row therefore expects `calls: []`, `ranges: [the parameter markup]`.
|
||||||
|
|
||||||
|
## Streaming: `createDsmlStreamMiddleware()`
|
||||||
|
|
||||||
|
| ID | Fixture | Expect |
|
||||||
|
|----|---------|--------|
|
||||||
|
| `stream-text-passthrough` | plain text deltas | all `text-delta` emitted, no tool parts |
|
||||||
|
| `stream-v2-recovered` | doubled-bar block split per token | `tool-input-start/delta/end` + `tool-call`, no leaked text |
|
||||||
|
| `stream-v17-chunk-split` | opener split `<`/`BAR`/`DSML`/… | recovered, no partial marker leaked |
|
||||||
|
| `stream-finish-rewrite` | recovered call, source finish `stop` | finish `tool-calls` |
|
||||||
|
| `stream-finish-untouched` | no DSML | finish stays `stop` |
|
||||||
|
| `stream-unclosed-fallback` | open block, then `text-end` | buffered text emitted verbatim, finish `stop` |
|
||||||
|
| `stream-empty-invoke-fallback` | closed block, unparseable | block re-emitted as text, finish `stop` |
|
||||||
|
| `stream-surrounding` | `"a " + block + " b"` | text `"a b"`, 1 call |
|
||||||
|
| `stream-multiline-edit` | real captured edit shape (DB V9) | 1 `edit` call with correct args |
|
||||||
|
| `stream-orphan-invoke` | V9 degradation mid-stream | 1 call |
|
||||||
|
| `stream-buffer-cap` | >64 KiB unclosed | fallback to text, no unbounded growth |
|
||||||
|
| `stream-eager-stop` | prose opener, no structure | flushed early as text, nothing held |
|
||||||
|
| `stream-transition` | reasoning part mid-text | held text flushed first, order preserved |
|
||||||
|
|
||||||
|
## Repair: `recoverStoredMessage(message)`
|
||||||
|
|
||||||
|
| ID | Fixture | Expect |
|
||||||
|
|----|---------|--------|
|
||||||
|
| `repair-strip-only` | assistant msg with native tool-call + leaked text | text stripped, calls **not** appended |
|
||||||
|
| `repair-append` | assistant msg with leaked call, no native | text stripped, call appended, `stopReason:"toolUse"` |
|
||||||
|
| `repair-thinking` | leaked block in a `thinking` part | thinking stripped |
|
||||||
|
| `repair-idempotent` | run twice | second run is a no-op |
|
||||||
|
|
||||||
|
> Status: the repair *parser* (`parseDsml` + `stripRanges`) is fully tested. The
|
||||||
|
> message-level append wrapper is intentionally **not** built: OpenCode persists
|
||||||
|
> assistant messages durably and the plugin has no safe write path for them, so
|
||||||
|
> repair happens at request time via history sanitization in the `context` hook
|
||||||
|
> (`src/index.ts`) instead of mutating stored messages.
|
||||||
|
|
||||||
|
## Config / modes
|
||||||
|
|
||||||
|
| ID | Fixture | Expect |
|
||||||
|
|----|---------|--------|
|
||||||
|
| `config-defaults` | `resolveConfig()` | both Zen providers, relaxed, resume 3 |
|
||||||
|
| `config-strict` | `mode:"strict"` | every rescue off |
|
||||||
|
| `config-granular` | one rescue off | others stay on |
|
||||||
|
| `strict-block` | complete block + strict flags | parses |
|
||||||
|
| `strict-orphan` | orphan invoke + strict flags | no call |
|
||||||
|
| `prompt-no-marker` | both directives | no live marker bytes, outer-layer rule stated |
|
||||||
|
| `fallback-*` | decide/messageText/attempts | nudge policy + cap |
|
||||||
|
|
||||||
|
## Wrapper: `wrapDsmlLanguageModel`
|
||||||
|
|
||||||
|
| ID | Fixture | Expect |
|
||||||
|
|----|---------|--------|
|
||||||
|
| `wrapper-stream` | fake model, block in stream | 1 tool-call, finish `tool-calls` |
|
||||||
|
| `wrapper-passthrough` | plain text | untouched |
|
||||||
|
| `wrapper-generate` | block in generate text | tool-call content part, finish `tool-calls` |
|
||||||
|
|
||||||
|
## Sources / provenance
|
||||||
|
|
||||||
|
Fixtures marked with a source are byte-faithful captures from that source; others are
|
||||||
|
constructed from the grammar. Provenance is recorded in `test/fixtures.ts` beside each.
|
||||||
|
|
||||||
|
- `[DB]` shapes observed in a real long-context DeepSeek session: wrapped
|
||||||
|
edit/shell leaks with O(1000)-char multiline values and shell metachars
|
||||||
|
- `[vLLM]` vLLM #54686 leak classes (runaway name, mis-closed param, misspelled opener)
|
||||||
|
- `[OpenClaw]` openclaw #128858 doubled full-width bars
|
||||||
|
- `[Pi]` pi-dsml grammar (mangled ` calls>` variant)
|
||||||
|
- `[CS]` CherryStudio #14747 chunk-split SSE sample
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||||
@@ -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
@@ -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(""", '"')
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Match extends Range {
|
||||||
|
groups: (string | undefined)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchAll(text: string, re: RegExp): Match[] {
|
||||||
|
const matches: Match[] = []
|
||||||
|
for (const match of text.matchAll(re)) {
|
||||||
|
if (match.index === undefined || match[0].length === 0) continue
|
||||||
|
matches.push({ start: match.index, end: match.index + match[0].length, groups: match.slice(1) })
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMatch(text: string, re: RegExp, from: number): Match | undefined {
|
||||||
|
return matchAll(text.slice(from), re).map((m) => offset(m, from))[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastMatch(text: string, re: RegExp, from: number): Match | undefined {
|
||||||
|
const matches = matchAll(text.slice(from), re)
|
||||||
|
return matches.length > 0 ? offset(matches[matches.length - 1]!, from) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function offset(match: Match, by: number): Match {
|
||||||
|
return { start: match.start + by, end: match.end + by, groups: match.groups }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort, drop ranges contained in earlier ones, and coalesce overlaps. */
|
||||||
|
function mergeRanges(ranges: Range[]): Range[] {
|
||||||
|
const sorted = [...ranges].sort((a, b) => a.start - b.start || b.end - a.end)
|
||||||
|
const merged: Range[] = []
|
||||||
|
for (const range of sorted) {
|
||||||
|
const last = merged[merged.length - 1]
|
||||||
|
if (last && range.start <= last.end) {
|
||||||
|
last.end = Math.max(last.end, range.end)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.push({ ...range })
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An odd number of fences before `index` means we are inside a code block. (V20) */
|
||||||
|
function insideCodeFence(text: string, index: number): boolean {
|
||||||
|
const fences = text.slice(0, index).match(/```/g)
|
||||||
|
return fences !== null && fences.length % 2 === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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("")
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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 & b "q"")])]))
|
||||||
|
expect(one(r).arguments).toEqual({ command: 'a & b "q"' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("v23-multiline value preserved", () => {
|
||||||
|
const value = "\nline1\n line2\n"
|
||||||
|
const r = parseDsml(block([invoke("edit", [param("content", value)])]))
|
||||||
|
expect(one(r).arguments).toEqual({ content: "line1\n line2" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("v24-json parameter", () => {
|
||||||
|
const r = parseDsml(block([invoke("q", [param("questions", '[{"a":1}]', { json: true })])]))
|
||||||
|
expect(one(r).arguments).toEqual({ questions: [{ a: 1 }] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("v25-raw-json-body", () => {
|
||||||
|
const src = `${tag("tool_calls")}<${marker()}invoke name="q">{"questions":[1,2]}${closeTag("invoke")}${closeTag("tool_calls")}`
|
||||||
|
const r = parseDsml(src)
|
||||||
|
expect(one(r).arguments).toEqual({ questions: [1, 2] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("v26-wrapped-tool (markerless outer, DB 5177 shape)", () => {
|
||||||
|
const src = text(
|
||||||
|
"prose ",
|
||||||
|
`<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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 ")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user