diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 60e9bc3..c6f6cc5 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -99,6 +99,7 @@ All keys below are currently **ACTIVE**. | `toolBashDefaultTimeout` | number | `60` | 🟢 ACTIVE | Default `bash` tool timeout in seconds when the model omits it. | | `toolOutputMaxBytes` | number | `200000` | 🟢 ACTIVE | Hard byte cap on tool result text. | | `throttleRetry` | boolean \| object | `true` | 🟢 ACTIVE | Auto-retry provider token rate-limit errors with progressive backoff. | +| `compressionModelId` | string | *(unset)* | 🟢 ACTIVE | How `compress` summaries are produced: `session` (the session model, shared prefix) or a `models.json` id (`provider/id`). Usually set via `/acp compact [session\|]`. | **Delegate keys** @@ -340,6 +341,40 @@ On `anthropic` / `claude-sonnet-4-5` the effective thresholds become `maxContext --- +## Compression Model + +The `compressionModelId` key designates **how** the `compress` tool's summaries are produced, so your main model spends no output tokens on them and the summary-writing reasoning stays out of its own turn. Two modes: **`session`** (the session's own model, in a separate call that reuses the session prompt prefix for prompt-cache efficiency) or a **`models.json` model id** (a cheaper model, reusing its `baseUrl`/`apiKey` already defined in `~/.pi/agent/models.json`). + +### `compressionModelId` + +- **Type:** `string` +- **Default:** *(unset — the main model writes summaries)* +- **Status:** 🟢 ACTIVE +- **Description:** How summaries are produced. `session` uses the session's own model in a separate call that reuses the session prompt prefix (system prompt + active tools + the messages Pi just sent) so the provider prompt cache keeps the input cheap. A `models.json` id (`qwen-mini` or `provider/id`) uses that model instead (a different cache namespace — no prefix sharing — but a cheaper per-token price). In both modes the main model's own summary is kept only as a fallback. When unset, the main model writes the summaries itself (the default). + + **Recommended way to set it:** the `/acp compact` command, which validates the id against `models.json` and persists it for you: + + ``` + /acp compact # show current + list models.json models + /acp compact session # use the session model (shared prefix → prompt-cache friendly) + /acp compact # set (e.g. /acp compact qwen-mini or /acp compact openai/gpt-4o-mini) + /acp compact reset # clear → fall back to the main model + ``` + + You can also set it directly in `acp.json`: + + ```json + { "compressionModelId": "openai/gpt-4o-mini" } + ``` + + **Resolution:** `session` resolves to the session's current model and reuses the captured session prefix; if no prefix has been captured yet (first turn) it falls back to a fresh prompt carrying the range's content. A bare id (`qwen-mini`) is matched against the models in `models.json`; if the same id exists under several providers the `provider/id` form is required. A `models.json` model must have a working API key (in `models.json` or `~/.pi/agent/auth.json`); `session` reuses the session's existing credentials. + + **Fallback (guaranteed):** if the configured model cannot be resolved, or its API call fails (network error, timeout, API error, empty response), the extension logs a warning and uses the **main model's summary** for that range instead. Compression never blocks or interrupts the session. + + **Scope:** read like any other `acp.json` key (global `~/.pi/acp.json`, with a project `/.pi/acp.json` overriding it per-field). The `/acp compact` command writes to the **global** file. + +--- + ## Prompts Customization The `prompts` object overrides acp-kernel's **load-bearing** compression prompt rules — the verbatim instructions the model receives about *how* to write summaries (keep full file paths, function signatures, decisions and rationale; drop verbose logs, etc.). These four fields are embedded into the system prompt and the compression nudge text: diff --git a/README.md b/README.md index 0344dff..6029c8d 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,29 @@ Blocks: 3 active (3.7K summary, 15.2K original compressed) b3 (T2) 3.3K→1.0K age=1m "Architecture review" ``` +## `/acp compact` — dedicated compression model + +By default the `compress` tool's summaries are written by your **main model** — it spends output tokens producing them, and the summary-writing reasoning runs inside the main model's own turn. `/acp compact` offloads summary-writing to a **separate call** in one of two modes: + +- **`session`** — use the **same model as your session**, but in a separate call that **reuses the session's prompt prefix** (system prompt + active tools + the exact messages Pi just sent). Because that prefix matches what the main model already sent, the provider's **prompt cache** makes the input cheap, and the compression reasoning stays out of the main model's context. Recommended way to get isolation without switching models. +- **``** — a model from Pi's `~/.pi/agent/models.json` (a cheaper small Qwen, `gpt-4o-mini`, …). No extra `baseUrl`/`apiKey` to duplicate. A different model has its own cache namespace, so it does **not** share the session prefix cache (input is billed in full), but you save on the model's per-token price. + +``` +/acp compact # show the current compression model + list available models +/acp compact session # use the session model (shared prefix → prompt-cache friendly) +/acp compact # set (or /) from models.json as the compression model +/acp compact reset # clear it — fall back to the main model +``` + +- **No argument** (`/acp compact`): shows the currently configured compression model (or "not set — the main model writes summaries") and lists the model ids available in `models.json` for reference. +- **`/acp compact session`**: sets the compression model to the session model (shared-prefix mode). +- **`/acp compact `**: sets a `models.json` model. Accepts a bare id (`qwen-mini`) or an explicit `provider/id` (`openai/gpt-4o-mini`). The choice is persisted to `~/.pi/acp.json` (`compressionModelId`) and applies across sessions. +- **`/acp compact reset`**: clears the setting; compression falls back to the main model (the default behavior). + +When a compression model is set, each `compress` call produces the summary in a separate call, so the **main model spends no output tokens** on summaries. If the call fails (network error, API error, empty response), the extension **falls back to the main model's summary** for that range — compression never blocks the session. + +> For `` mode the model must have a working API key (in `models.json` or `~/.pi/agent/auth.json`); `session` mode reuses your session's existing credentials. See [CONFIGURATION.md](./CONFIGURATION.md#compressionmodelid) for details. + ## `/acp-subagents` command **Optional, one-time setup — only if you also use [pi-subagents](https://github.com/nicobailon/pi-subagents).** diff --git a/package-lock.json b/package-lock.json index 5971481..49006b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.52", "license": "MIT", "devDependencies": { + "@earendil-works/pi-ai": "0.83.0", "@earendil-works/pi-coding-agent": "0.83.0", "@types/node": "^26.1.2", "acp-kernel": "0.0.46", @@ -24,6 +25,529 @@ "typebox": "*" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-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" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz", + "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.83.0.tgz", + "integrity": "sha512-m3IZD4g3er0V8TC9+Vpgw/sjTKqcJlkcIBy/JvsgRubuuik3tAVzyugUg4rVrShIkkOT69mEd34NEqKUIsl6JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "dev": true, + "license": "MIT" + }, "node_modules/@earendil-works/pi-coding-agent": { "version": "0.83.0", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.83.0.tgz", @@ -2425,6 +2949,31 @@ "node": ">=18" } }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2464,6 +3013,113 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", @@ -2800,19 +3456,147 @@ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", - "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", - "cpu": [ - "x64" - ], + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } }, "node_modules/@types/estree": { "version": "1.0.9", @@ -2831,6 +3615,13 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -3194,6 +3985,16 @@ "node": ">=20" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -3201,6 +4002,51 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -3270,6 +4116,16 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3288,6 +4144,16 @@ } } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3330,6 +4196,13 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3337,52 +4210,222 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" } }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "dev": true, "license": "MIT", "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" + "bignumber.js": "^9.0.0" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=16" } }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" } }, "node_modules/lilconfig": { @@ -3415,6 +4458,13 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3457,6 +4507,46 @@ "thenify-all": "^1.0.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3467,6 +4557,49 @@ "node": ">=0.10.0" } }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3559,6 +4692,30 @@ } } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3583,6 +4740,16 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rollup": { "version": "4.62.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", @@ -3628,6 +4795,27 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -3718,6 +4906,13 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -3725,6 +4920,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -4336,6 +5538,58 @@ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 90eb909..79b1f72 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "typebox": "*" }, "devDependencies": { + "@earendil-works/pi-ai": "0.83.0", "@earendil-works/pi-coding-agent": "0.83.0", "@types/node": "^26.1.2", "acp-kernel": "0.0.46", diff --git a/src/commands.ts b/src/commands.ts index 3b00445..ddc14a5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -6,11 +6,21 @@ import { collectCoveredMessageIds, estimateTokens, calibrateTokens, collectImage import { buildStatusPanel } from "acp-kernel/panel"; import { getDelegateUsage } from "./delegate-tool.js"; import { ensureSubagentAcpTools } from "./setup-subagent-tools.js"; +import { SESSION_MODEL_REF } from "./compress-model.js"; declare const CURRENT_VERSION: string; type CommandOptions = Omit; +/** Command handlers are typed `(args: string, ...)`. Real pi passes a string; + * some tests pass an array. Normalize both to a string. */ +function commandArgString(args: string): string { + const a: unknown = args; + if (typeof a === "string") return a; + if (Array.isArray(a)) return (a as string[]).join(" "); + return ""; +} + /** Extract per-request prompt-cache usage from assistant messages' provider * reported usage (footer of each entry). Requests without cache reporting * stay 0/0 — cacheHitStats excludes them from the average. */ @@ -30,8 +40,18 @@ export function makeCommands(runtime: AcpRuntime): Array<{ name: string; options { name: "acp", options: { - description: "Show ACP context usage, token breakdown, and compression status.", - handler: async (_args, ctx) => ctx.ui.notify(await statusReport(runtime, ctx)), + description: + "Show ACP context usage, token breakdown, and compression status. " + + "Subcommand: /acp compact [session|model-id|reset] to manage the dedicated compression model.", + handler: async (args, ctx) => { + const argStr = commandArgString(args); + const first = argStr.trim().split(/\s+/)[0]; + if (first === "compact") { + ctx.ui.notify(await handleCompact(argStr, runtime, ctx)); + return; + } + ctx.ui.notify(await statusReport(runtime, ctx)); + }, }, }, { @@ -163,3 +183,64 @@ async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext): } return text; } + +/** `/acp compact [model-id|reset]` — manage the dedicated compression model. + * No arg: show status + list models.json. `reset`: clear. ``: set (validated). */ +async function handleCompact(args: string, runtime: AcpRuntime, ctx: ExtensionCommandContext): Promise { + const parts = args.trim().split(/\s+/).filter(Boolean); + const rest = parts.slice(1); // drop leading "compact" + const client = runtime.compressionModel; + + if (rest.length === 0) { + const current = runtime.getCompressionModelRef(); + if (!current) { + const models = await client.listModels(); + const list = models.length > 0 + ? models.map((m) => ` ${m.provider}/${m.id}${m.name ? ` — ${m.name}` : ""}`).join("\n") + : " (no models found in models.json)"; + return ( + "Compression model: NOT SET — the main model writes summaries (default).\n\n" + + "Options:\n" + + ` /acp compact ${SESSION_MODEL_REF} — use the session's own model, reusing its prompt prefix (prompt-cache friendly)\n` + + `Available models in models.json:\n${list}\n\n` + + "Set one with: /acp compact " + ); + } + if (current === SESSION_MODEL_REF) { + return "Compression model: session — the session's own model writes summaries, reusing its prompt prefix (prompt-cache friendly). Falls back to the main model on error.\nReset with: /acp compact reset"; + } + const resolved = await client.resolveModel(current); + if (resolved.model) { + return `Compression model: ${resolved.model.provider}/${resolved.model.id} — a dedicated model writes summaries (falls back to the main model on error).\nReset with: /acp compact reset`; + } + return `Compression model: ${current} — configured but NOT resolvable in models.json, so compress will fall back to the main model.\nReset with: /acp compact reset`; + } + + const target = rest.join(" "); + if (target === "reset") { + await runtime.setCompressionModelRef(null); + return "Compression model cleared — reverting to main-model compression."; + } + if (target === SESSION_MODEL_REF) { + await runtime.setCompressionModelRef(SESSION_MODEL_REF); + return "Compression model set to session — the session's own model writes summaries, reusing its prompt prefix (prompt-cache friendly). Falls back to the main model on error."; + } + + const resolved = await client.resolveModel(target); + if (resolved.ambiguous.length > 0) { + return ( + `Ambiguous model id "${target}". Use "provider/id" instead:\n` + + resolved.ambiguous.map((m) => ` ${m.provider}/${m.id}`).join("\n") + ); + } + if (!resolved.model) { + const models = await client.listModels(); + const list = models.length > 0 + ? models.map((m) => ` ${m.provider}/${m.id}`).join("\n") + : " (no models found in models.json)"; + return `Model "${target}" not found in models.json.\nAvailable:\n${list}`; + } + const canonical = `${resolved.model.provider}/${resolved.model.id}`; + await runtime.setCompressionModelRef(canonical); + return `Compression model set to ${canonical}. Compress summaries will now be written by this model (falls back to the main model on error).`; +} diff --git a/src/compress-model.ts b/src/compress-model.ts new file mode 100644 index 0000000..6aef1fc --- /dev/null +++ b/src/compress-model.ts @@ -0,0 +1,186 @@ +import { promises as fs } from "node:fs"; +import * as path from "node:path"; +import { ModelRuntime, getAgentDir } from "@earendil-works/pi-coding-agent"; +import type { Api, AssistantMessage, Context, Model, ModelsApiStreamOptions } from "@earendil-works/pi-ai"; + +/** Sentinel compressionModelId value meaning "use the session's own model". + * The compression call reuses the session's prompt prefix (system prompt + + * tools + messages) so it hits the provider's prompt cache — isolation without + * a cheaper model. Distinct from a models.json ref (which is "provider/id"). */ +export const SESSION_MODEL_REF = "session"; + +/** A model defined in models.json, addressed by provider + id. */ +export interface CompressionModelInfo { + provider: string; + id: string; + name?: string; +} + +/** A resolved model ready for an LLM call. */ +export interface ResolvedCompressionModel { + provider: string; + id: string; + model: Model; +} + +/** Result of resolving a user-supplied ref. `model` is set when the ref is + * unambiguous; `ambiguous` lists candidates when a bare id matches several. */ +export interface ResolveResult { + model: ResolvedCompressionModel | null; + ambiguous: CompressionModelInfo[]; +} + +/** Injectable LLM call. Defaults to ModelRuntime.complete (streams SSE). */ +export type CompleteFn = (model: Model, context: Context, options?: ModelsApiStreamOptions) => Promise; + +export interface CompressionModelClientOptions { + /** Override the models.json path (default: ~/.pi/agent/models.json). */ + modelsPath?: string; + /** Injectable LLM call for tests. */ + complete?: CompleteFn; + /** Injectable runtime factory for tests. */ + createRuntime?: (opts: { modelsPath?: string }) => Promise; +} + +/** Default models.json path — Pi's own agent dir (homedir/.pi/agent by default, + * honors the PI_CODING_AGENT_DIR override). */ +export function defaultModelsPath(): string { + return path.join(getAgentDir(), "models.json"); +} + +interface ModelsJsonProvider { + name?: string; + baseUrl?: string; + apiKey?: string; + api?: string; + models?: Array<{ id: string; name?: string }>; +} +type ModelsJson = { providers?: Record }; + +async function readModelsJson(modelsPath: string): Promise> { + try { + const raw = await fs.readFile(modelsPath, "utf8"); + const parsed = JSON.parse(raw) as ModelsJson; + return parsed?.providers ?? {}; + } catch { + return {}; + } +} + +/** + * Client for the dedicated compression model. Reads ~/.pi/agent/models.json for + * model discovery and uses a ModelRuntime (same source Pi uses) for the actual + * LLM call, so provider-specific request/auth handling is reused rather than + * re-implemented. + */ +export class CompressionModelClient { + private readonly modelsPath: string; + private readonly complete: CompleteFn; + private readonly createRuntime: (opts: { modelsPath?: string }) => Promise; + private runtimePromise: Promise | null = null; + + constructor(options: CompressionModelClientOptions = {}) { + this.modelsPath = options.modelsPath ?? defaultModelsPath(); + this.createRuntime = options.createRuntime ?? ((opts) => ModelRuntime.create({ modelsPath: opts.modelsPath, allowModelNetwork: false })); + this.complete = options.complete ?? (async (model, context, opts) => (await this.getRuntime()).complete(model, context, opts)); + } + + private getRuntime(): Promise { + if (!this.runtimePromise) this.runtimePromise = this.createRuntime({ modelsPath: this.modelsPath }); + return this.runtimePromise; + } + + /** Models the user defined in models.json (NOT built-in providers). */ + async listModels(): Promise { + const providers = await readModelsJson(this.modelsPath); + const out: CompressionModelInfo[] = []; + for (const [provider, cfg] of Object.entries(providers)) { + for (const m of cfg.models ?? []) out.push({ provider, id: m.id, name: m.name }); + } + return out; + } + + /** Resolve a ref: "provider/id" (explicit) or a bare id (searched across + * models.json first, then built-in providers). */ + async resolveModel(ref: string): Promise { + const refTrim = ref.trim(); + if (!refTrim) return { model: null, ambiguous: [] }; + const rt = await this.getRuntime(); + if (refTrim.includes("/")) { + const slash = refTrim.indexOf("/"); + const provider = refTrim.slice(0, slash); + const id = refTrim.slice(slash + 1); + const m = rt.getModel(provider, id); + if (m) return { model: { provider, id, model: m }, ambiguous: [] }; + return { model: null, ambiguous: [] }; + } + const jsonMatches = (await this.listModels()).filter((m) => m.id === refTrim); + const rtMatches = rt.getModels().filter((m) => m.id === refTrim).map((m) => ({ provider: m.provider, id: m.id, name: m.name })); + const seen = new Set(); + const all: CompressionModelInfo[] = []; + for (const m of [...jsonMatches, ...rtMatches]) { + const key = `${m.provider}/${m.id}`; + if (seen.has(key)) continue; + seen.add(key); + all.push(m); + } + if (all.length === 0) return { model: null, ambiguous: [] }; + if (all.length === 1) { + const m = all[0]!; + const model = rt.getModel(m.provider, m.id)!; + return { model: { provider: m.provider, id: m.id, model }, ambiguous: [] }; + } + return { model: null, ambiguous: all }; + } + + /** Generate a summary with the resolved model from a FULLY BUILT context + * (the caller controls systemPrompt/messages/tools — used for both the + * fresh single-message prompt and the shared-prefix prompt). Throws on API + * error or an empty response — the caller falls back to the main model. */ + async summarizeContext(resolved: ResolvedCompressionModel, context: Context, maxTokens: number): Promise { + const msg = await this.complete(resolved.model, context, { maxTokens }); + if (msg.stopReason === "error") throw new Error(msg.errorMessage ?? "compression model returned an error"); + const text = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + const trimmed = text.trim(); + if (!trimmed) throw new Error("compression model returned an empty summary"); + return trimmed; + } + + /** Generate a summary of `content` with the resolved model using a fresh + * single-message prompt (no session prefix reuse). */ + async summarize(resolved: ResolvedCompressionModel, content: string, systemPrompt: string, maxTokens: number): Promise { + const context: Context = { + systemPrompt, + messages: [{ role: "user", content, timestamp: Date.now() }], + }; + return this.summarizeContext(resolved, context, maxTokens); + } +} + +/** System prompt for the compression model: reuse the kernel's tier-1 rules so + * its output matches the quality/format the main model would produce. */ +export function buildSummarizeSystemPrompt(prompts: { compressPhilosophy: string; howToCompressRules: string }, topic: string | undefined): string { + const topicLine = topic ? `\nTopic for this range: ${topic}\n` : ""; + return ( + "You are a dedicated context-compression model. You receive a range of conversation " + + "messages and must write the single summary that replaces them. Follow the rules exactly.\n" + + `${topicLine}\n` + + `${prompts.compressPhilosophy}\n\n` + + `${prompts.howToCompressRules}\n\n` + + "Output ONLY the summary text (no preamble, no code fences)." + ); +} + +/** Truncate very large content, keeping the head (goal/early context) and tail + * (most recent state) with a marker in the middle. */ +export function truncateContent(content: string, maxChars: number): string { + if (content.length <= maxChars) return content; + const marker = "\n\n[... truncated ...]\n\n"; + const budget = maxChars - marker.length; + const head = Math.ceil(budget / 2); + const tail = budget - head; + return content.slice(0, head) + marker + content.slice(content.length - tail); +} diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 9f0b579..3d6008b 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -1,14 +1,19 @@ import { Type, type Static } from "typebox"; -import type { - AgentToolResult, - ExtensionContext, - ToolDefinition, +import { + convertToLlm, + type AgentToolResult, + type ExtensionAPI, + type ExtensionContext, + type ToolDefinition, + type ToolInfo, } from "@earendil-works/pi-coding-agent"; +import type { Api, Context, Message, Model, Tool } from "@earendil-works/pi-ai"; import type { AcpRuntime } from "./runtime.js"; import { debug, logError, logInfo, logThrow, logWarn } from "./log.js"; import { estimateTokens, collectCoveredMessageIds, calibrateTokens, collectImageTokens, modelSupportsImages } from "./tokens.js"; -import { defaultCountTokens, parseCompressArgs, type CompressionBlock, type CompressParseDiagnostics } from "acp-kernel"; +import { defaultCountTokens, parseCompressArgs, resolveBoundaries, type CompressionBlock, type CompressParseDiagnostics, type CoreMessage, type CompressionState } from "acp-kernel"; import { getSystemPromptText } from "./compat.js"; +import { buildSummarizeSystemPrompt, truncateContent, SESSION_MODEL_REF, type ResolvedCompressionModel } from "./compress-model.js"; function formatK(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n); @@ -38,12 +43,33 @@ const CompressParams = Type.Object({ type CompressArgs = Static; -export function makeCompressTool(runtime: AcpRuntime): ToolDefinition { +/** Tool description is dynamic: when a dedicated compression model is + * configured, the main model only chooses ranges and passes a minimal + * placeholder summary (the compression model writes the real one) — saving the + * main model's output tokens. Without one, the main model writes full summaries. */ +function compressDescription(ref: string | undefined): string { + if (ref) { + return ( + "Replace older conversation ranges with summaries. A dedicated compression model (" + ref + + ") writes the summaries — you only choose the ranges. Pass a MINIMAL placeholder for each range's `summary` " + + "(e.g. \"compressed\"); it will be replaced by the compression model. Do NOT write a full summary. " + + "Single range: compress({ content: [{ startId, endId, summary: \"compressed\" }] }). " + + "Batch: compress({ content: [{ topic, startId, endId, summary: \"compressed\" }, ...] })." + ); + } + return ( + "Replace older conversation ranges with detailed summaries you write. Single range: compress({ content: [{ startId, endId, summary }] }). " + + "Batch: compress({ content: [{ topic, startId, endId, summary }, ...] }) — each entry gets its own summary." + ); +} + +export function makeCompressTool(runtime: AcpRuntime, pi: ExtensionAPI): ToolDefinition { return { name: "compress", label: "Compress", - description: - "Replace older conversation ranges with detailed summaries you write. Single range: compress({ content: [{ startId, endId, summary }] }). Batch: compress({ content: [{ topic, startId, endId, summary }, ...] }) — each entry gets its own summary.", + get description() { + return compressDescription(runtime.getCompressionModelRef()); + }, promptSnippet: "compress({ content: [{ startId, endId, summary }] }) or batch multiple ranges", promptGuidelines: [ "Each message has an acp tag with its mNNNNN ref, token size, and type. Compress ranges by their refs.", @@ -55,7 +81,7 @@ export function makeCompressTool(runtime: AcpRuntime): ToolDefinition> { let result: string; try { - result = await handleCompress(params as CompressArgs, runtime, ctx, toolCallId); + result = await handleCompress(params as CompressArgs, runtime, ctx, toolCallId, collectActiveTools(pi)); } catch (e) { logThrow("compress", e, { sid: ctx.sessionManager.getSessionId(), ranges: typeof (params as CompressArgs).content === "string" ? "string" : ((params as CompressArgs).content?.length ?? 0) }); throw e; @@ -65,6 +91,22 @@ export function makeCompressTool(runtime: AcpRuntime): ToolDefinition active.has(t.name)) + .map((t: ToolInfo) => ({ name: t.name, description: t.description, parameters: t.parameters })); + } catch { + return []; + } +} + type RangeEntry = Static; // Normalize the compress args via the kernel's lenient parser (fenced / @@ -138,7 +180,7 @@ function tier3OnlyRewrite(newBlocks: CompressionBlock[], allBlocks: CompressionB return spans; } -async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: ExtensionContext, toolCallId?: string): Promise { +async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: ExtensionContext, toolCallId: string | undefined, tools: Tool[]): Promise { const maybeRanges = normalizeRanges(args); // Argument errors throw (not return): pi-agent-core only sets isError:true // on THROWN tool errors, and the failure counter keys off isError. A @@ -172,6 +214,85 @@ async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: Exte const summaryMaxChars = args.summaryMaxChars; const topLevelTopic = args.topic; + // Dedicated compression model: if configured and resolvable, generate each + // range's summary with the external model (the main model only passed a + // placeholder). On any failure, fall back to the main model's summary for + // that range so the session is never interrupted. + const compressionRef = runtime.getCompressionModelRef(); + let compressionNote: string | null = null; + let compressionWarn = false; + if (compressionRef) { + const sid = ctx.sessionManager.getSessionId(); + const maxTokens = Math.max(256, Math.ceil((summaryMaxChars ?? 20000) / 3)); + let used = 0; + if (compressionRef === SESSION_MODEL_REF) { + // "session": summarize with the session's OWN model, reusing its prompt + // prefix (system prompt + active tools + the exact transformed messages + // Pi just sent) so the call hits the provider's prompt cache — isolation + // without a cheaper model. Falls back to the main model's summary per + // range on any failure, so the session is never interrupted. + const sessionModel = ctx.model as Model | undefined; + if (!sessionModel) { + compressionNote = 'compression model "session" has no active session model — using main model summaries'; + compressionWarn = true; + } else { + const resolved: ResolvedCompressionModel = { provider: sessionModel.provider, id: sessionModel.id, model: sessionModel }; + const prefix = runtime.getLastSentMessages(sid); + const llmPrefix: Message[] | null = prefix ? convertToLlm(prefix) : null; + const systemPrompt = getSystemPromptText(ctx); + for (const r of ranges) { + const instruction = buildRangeInstruction(r, r.topic ?? topLevelTopic); + // With a captured prefix: reuse it (prompt-cache hit) + a short + // instruction pointing at the range by ref. Without one (first turn / + // no context round yet): fall back to a fresh prompt with the + // extracted range content. + const context: Context = llmPrefix + ? { systemPrompt, messages: [...llmPrefix, { role: "user", content: instruction, timestamp: Date.now() }], tools } + : { systemPrompt: buildSummarizeSystemPrompt(runtime.prompts, r.topic ?? topLevelTopic), messages: [{ role: "user", content: `${instruction}\n\n\n${extractRangeContent(messages, state, r.startId, r.endId) ?? "(range could not be extracted)"}\n`, timestamp: Date.now() }] }; + try { + r.summary = await runtime.compressionModel.summarizeContext(resolved, context, maxTokens); + used += 1; + } catch (e) { + logWarn("compress", { sid, event: "compression-model-failed", ref: compressionRef, error: e instanceof Error ? e.message : String(e) }); + } + } + if (used > 0) { + compressionNote = `summaries written by session model ${resolved.provider}/${resolved.id} (${used}/${ranges.length} ranges${llmPrefix ? ", shared prefix" : ", no prefix captured"})`; + } else { + compressionNote = 'compression model "session" produced no summaries — using main model summaries'; + compressionWarn = true; + } + } + } else { + // A models.json model: fresh single-message prompt (no prefix sharing — a + // different model lives in a different cache namespace). + const resolved = await runtime.compressionModel.resolveModel(compressionRef); + if (!resolved.model) { + compressionNote = `compression model "${compressionRef}" not resolvable in models.json — using main model summaries`; + compressionWarn = true; + logWarn("compress", { sid, event: "compression-model-unresolved", ref: compressionRef }); + } else { + const systemPrompt = buildSummarizeSystemPrompt(runtime.prompts, topLevelTopic); + for (const r of ranges) { + const content = extractRangeContent(messages, state, r.startId, r.endId); + if (!content) continue; // cannot extract — keep the main model's summary + try { + r.summary = await runtime.compressionModel.summarize(resolved.model, truncateContent(content, 120000), systemPrompt, maxTokens); + used += 1; + } catch (e) { + logWarn("compress", { sid, event: "compression-model-failed", ref: compressionRef, error: e instanceof Error ? e.message : String(e) }); + } + } + if (used > 0) { + compressionNote = `summaries written by ${resolved.model.provider}/${resolved.model.id} (${used}/${ranges.length} ranges)`; + } else { + compressionNote = `compression model "${compressionRef}" produced no summaries — using main model summaries`; + compressionWarn = true; + } + } + } + } + debug.event("compress-in", { sid: ctx.sessionManager.getSessionId(), modelId, @@ -255,7 +376,41 @@ async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: Exte } const lines = [`▣ ACP | ${formatK(beforeTokens)} → ${formatK(afterTokens)} tokens (~${formatK(reclaimed)} reclaimed, ${blocksCreated} block${blocksCreated > 1 ? "s" : ""})`]; + if (compressionNote) lines.push((compressionWarn ? "⚠️ " : "ℹ️ ") + compressionNote); if (warnings.length > 0) lines.push("⚠️ " + warnings.join("; ")); if (errors.length > 0) lines.push("Errors: " + errors.join("; ")); return lines.join("\n"); } + +function formatCoreMessage(m: CoreMessage): string { + const text = m.text ?? ""; + if (m.contentType === "tool-call") return `[${m.role}:${m.toolName ?? "tool"} call] ${text}`; + if (m.contentType === "tool-result") return `[${m.role}:${m.toolName ?? "tool"} result] ${text}`; + return `[${m.role}] ${text}`; +} + +/** Instruction for the dedicated compression model to summarize one range. + * For the "session" ref the ACP compression rules already live in the reused + * session system prompt, so this only points at the range (by ref) and the + * output contract. */ +function buildRangeInstruction(r: RangeEntry, topic: string | undefined): string { + const topicLine = topic ? ` Topic: ${topic}.` : ""; + return ( + `Write the single detailed summary that replaces the conversation range from ${r.startId} to ${r.endId} ` + + `(the messages tagged [${r.startId}] through [${r.endId}]).${topicLine} ` + + `Follow the compression rules in your system prompt exactly. Output ONLY the summary text (no preamble, no code fences).` + ); +} + +/** Raw transcript of the messages in [startId, endId] (fed to the compression + * model). Returns null when the range cannot be resolved. */ +function extractRangeContent(messages: CoreMessage[], state: CompressionState, startId: string, endId: string): string | null { + try { + const range = resolveBoundaries({ startRef: startId, endRef: endId, messages, state }); + const slice = messages.slice(range.startIndex, range.endIndex + 1); + if (slice.length === 0) return null; + return slice.map(formatCoreMessage).join("\n\n"); + } catch { + return null; + } +} diff --git a/src/config.ts b/src/config.ts index e19e411..ec4eb92 100644 --- a/src/config.ts +++ b/src/config.ts @@ -111,6 +111,10 @@ export interface AdapterConfig { * replacing the kernel's tuned compression rules may reduce summary quality * (lost paths/signatures/decisions → worse retrieval). */ acknowledgePromptsRisk?: boolean; + /** Model ref (bare id or "provider/id") from models.json used to write + * compression summaries with a dedicated (usually cheaper) model. Absent = + * the main model writes summaries (default). Set via `/acp compact `. */ + compressionModelId?: string; coreOverrides?: Partial; } diff --git a/src/index.ts b/src/index.ts index 6464fdc..dcbd326 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,7 @@ export function createAcpExtension(adapter: AdapterConfig = {}): ExtensionFactor wireToolGuardrails(pi, runtime); wireOverflowSelfHeal(pi, runtime); wireThrottleRetry(pi, runtime); - pi.registerTool(makeCompressTool(runtime)); + pi.registerTool(makeCompressTool(runtime, pi)); pi.registerTool(makeDecompressTool(runtime)); pi.registerTool(makeSearchTool(runtime)); pi.registerTool(makeStatusTool(runtime)); @@ -359,6 +359,12 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { if (ctx.hasUI) ctx.ui.notify(msg); }); if (!ctx.hasUI) await updateCheck; + // Capture the exact transformed messages Pi is about to send so the + // dedicated compression model (the "session" ref) can reuse them as a + // prompt prefix and hit the provider's prompt cache. Stored by session id; + // read by the compress tool during the same turn (before the next context + // round overwrites it). + runtime.setLastSentMessages(sid, rebuilt); return { messages: rebuilt }; } catch (e) { logThrow("context", e, { sid, phase: "transform" }); diff --git a/src/runtime.ts b/src/runtime.ts index 908b5bb..5a1804b 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -12,7 +12,8 @@ import { resolveConfig, type AdapterConfig } from "./config.js"; import { DensityEstimator } from "./density.js"; import { entriesToCoreMessages, extractText, matchesStoredText, messageIdentity, messageRef } from "./messages.js"; import { SessionStateStore, type LiveRefOrigin } from "./state.js"; -import { loadUserConfig, applyUserConfig } from "./user-config.js"; +import { loadUserConfig, applyUserConfig, saveCompressionModelId } from "./user-config.js"; +import { CompressionModelClient } from "./compress-model.js"; import { ThrottleEpisode } from "./throttle-retry.js"; import { logInfo, logWarn, setDebugEnabled } from "./log.js"; import { findUniqueLongestRun, type MatchRange } from "./sequence-match.js"; @@ -97,6 +98,20 @@ export interface AcpRuntime { * the map entry so a long-lived process cycling through many sessions * doesn't accumulate them. */ overflowDrop(sid: string): void; + /** Client for the dedicated compression model (reads models.json). */ + compressionModel: CompressionModelClient; + /** The configured compression model ref, or undefined (main model writes). */ + getCompressionModelRef(): string | undefined; + /** Persist the compression model ref to ~/.pi/acp.json and update the + * in-memory adapter so it takes effect immediately. null clears it. */ + setCompressionModelRef(value: string | null): Promise; + /** Store the exact transformed AgentMessage[] Pi sent in the last context + * round. The dedicated compression model reuses this as a prompt prefix so + * its call hits the provider's prompt cache (same system prompt + tools + + * messages the main model just saw). */ + setLastSentMessages(sid: string, messages: AgentMessage[]): void; + /** The transformed messages from the last context round, or null. */ + getLastSentMessages(sid: string): AgentMessage[] | null; } // omp fires the context event before the current user message is persisted to // the session branch, so merge event.messages (exact messages about to be sent, @@ -245,7 +260,9 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { countTokens: (text) => density.estimateWithDensity(countModelId, text), }); const store = new SessionStateStore(); + const compressionModel = new CompressionModelClient(); const lastActiveBlockIds = new Map>(); + const lastSentMessages = new Map(); const locks = new Map>(); const factoryAdapter = adapter; let adapterRef = adapter; @@ -339,6 +356,21 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { return resolveConfig(adapterRef, liveContextLimit(ctx), m?.provider, m?.id); } + function getCompressionModelRef(): string | undefined { + return adapterRef.compressionModelId; + } + + async function setCompressionModelRef(value: string | null): Promise { + await saveCompressionModelId(value); + const next: AdapterConfig = { ...adapterRef }; + if (value === null) delete next.compressionModelId; + else next.compressionModelId = value; + adapterRef = next; + // Force the next reloadConfig to re-read (the file now differs from the + // cached key), keeping disk and memory in sync across sessions. + lastUserConfigKey = undefined; + } + async function reloadConfig(cwd: string): Promise { let user; try { @@ -401,6 +433,14 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { } function clearSessionTracking(sid: string): void { lastActiveBlockIds.delete(sid); + lastSentMessages.delete(sid); + } + + function setLastSentMessages(sid: string, messages: AgentMessage[]): void { + lastSentMessages.set(sid, messages); + } + function getLastSentMessages(sid: string): AgentMessage[] | null { + return lastSentMessages.get(sid) ?? null; } - return { core, store, density, setCountModel: (m) => { countModelId = m; }, noteActiveBlocks, clearSessionTracking, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, throttleFor, throttleDrop };} + return { core, store, density, setCountModel: (m) => { countModelId = m; }, noteActiveBlocks, clearSessionTracking, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, throttleFor, throttleDrop, compressionModel, getCompressionModelRef, setCompressionModelRef, setLastSentMessages, getLastSentMessages };} diff --git a/src/user-config.ts b/src/user-config.ts index 46dd035..4133469 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -22,6 +22,9 @@ export interface UserAcpConfig { displayUsage?: "merged" | "separate"; prompts?: Partial; acknowledgePromptsRisk?: boolean; + /** Model ref (bare id or "provider/id") from models.json used for compression + * summaries. Absent = main model writes summaries (default). */ + compressionModelId?: string; } /** Read global + project acp.json, project overrides global. Returns {} on any @@ -56,7 +59,7 @@ const KNOWN = new Set([ "debug", "autoUpdate", "modelContextLimit", "toolBashDefaultTimeout", "toolOutputMaxBytes", "delegate", "compress", "displayUsage", "throttleRetry", - "prompts", "acknowledgePromptsRisk", + "prompts", "acknowledgePromptsRisk", "compressionModelId", ]); function pickKnown(parsed: Record): UserAcpConfig { @@ -78,3 +81,21 @@ export function applyUserConfig(adapter: AdapterConfig, user: UserAcpConfig): Ad preserveRecentMessages: adapter.preserveRecentMessages, }; } + +/** Persist compressionModelId to the GLOBAL ~/.pi/acp.json (other keys + * preserved). Pass null to clear it (reverts to main-model compression). */ +export async function saveCompressionModelId(value: string | null): Promise { + const file = join(homedir(), CONFIG_DIR_NAME, "acp.json"); + let current: Record = {}; + try { + const raw = await fs.readFile(file, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") current = parsed as Record; + } catch { + // missing or unreadable — start fresh + } + if (value === null) delete current.compressionModelId; + else current.compressionModelId = value; + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(current, null, 2) + "\n", "utf8"); +} diff --git a/tests/acp-compact.test.ts b/tests/acp-compact.test.ts new file mode 100644 index 0000000..3f4f2e9 --- /dev/null +++ b/tests/acp-compact.test.ts @@ -0,0 +1,373 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as http from "node:http"; +import { createAcpExtension } from "../src/index.js"; + +// ─── helpers (mirror compress-tool.test.ts) ───────────────────────────────── + +function captureApi() { + const handlers = new Map any)[]>(); + const api = { + on(event: string, handler: (e: any, ctx: any) => any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + tools: [] as any[], + commands: new Map(), + registerTool(tool: any) { this.tools.push(tool); }, + registerCommand(name: string, options: any) { this.commands.set(name, options); }, + }; + return { api, handlers }; +} + +function userMsg(id: string, text: string) { + return { type: "message", id, parentId: null, timestamp: "", message: { role: "user", content: text, timestamp: Date.now() } }; +} + +function fakeCtx(entries: any[], stateFile: string, cwd: string, onNotify?: (s: string) => void, model?: any) { + return { + mode: "rpc", + hasUI: false, + cwd, + ui: { notify: (s: string) => onNotify?.(s) ?? undefined, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: model ?? { contextWindow: 200_000, id: "test-model" }, + getContextUsage: () => null, + sessionManager: { + buildContextEntries: () => entries, + getSessionId: () => "test-session", + getSessionFile: () => stateFile, + }, + }; +} + +// A full pi-ai Model pointing at a local mock SSE server, used as the +// "session" compression model (ctx.model) so the shared-prefix call is a real +// request we can inspect and stub. +function sessionModel(baseUrl: string): any { + return { + id: "session-model", + name: "Session Model", + provider: "testprov", + api: "openai-completions", + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4000, + maxTokens: 2000, + headers: { Authorization: "Bearer sk-test" }, + }; +} + +async function readBlockSummaries(stateFile: string): Promise { + const raw = await fs.readFile(`${stateFile}.acp.json`, "utf8"); + const state = JSON.parse(raw); + return (state.blocks ?? []).map((b: { summary: string }) => b.summary); +} + +// The kernel protects the last 5 messages AND the trailing ~5000 tokens +// (preserveRecentMessages=5, preserveRecentTokens=5000). To make the OLDEST +// message (m00001) compressible: it must be >= minCompressRange (5000 chars) +// AND the fillers after it must sum to >= 5000 tokens so the token-protection +// window stops before reaching m00001. +const LONG = "lorem ipsum dolor sit amet ".repeat(400); // ~8800 chars (target) +const FILLER = "filler text for padding purposes ".repeat(150); // ~4200 chars ≈ 1050 tokens +function compressibleEntries(): any[] { + const out = [userMsg("e1", LONG)]; + for (let i = 2; i <= 7; i++) out.push(userMsg(`e${i}`, FILLER)); // 6 × ~1050 = ~6300 tokens + return out; // m00001 = LONG (compressible), m00002..m00007 = fillers +} + +// ─── env + models.json setup ──────────────────────────────────────────────── + +let tmp: string; +let agentDir: string; +let modelsJsonPath: string; +let acpJsonPath: string; +const prevHome = process.env.HOME; +const prevAgentDir = process.env.PI_CODING_AGENT_DIR; + +before(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "acp-compact-")); + agentDir = path.join(tmp, ".pi", "agent"); + await fs.mkdir(agentDir, { recursive: true }); + modelsJsonPath = path.join(agentDir, "models.json"); + acpJsonPath = path.join(tmp, ".pi", "acp.json"); + process.env.HOME = tmp; + process.env.PI_CODING_AGENT_DIR = agentDir; +}); + +after(async () => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + await fs.rm(tmp, { recursive: true, force: true }); +}); + +function writeModelsJson(baseUrl: string): void { + void fs.writeFile( + modelsJsonPath, + JSON.stringify({ + providers: { + testprov: { + baseUrl, + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "mini-summarizer", name: "Mini Summarizer", contextWindow: 4000, maxTokens: 2000 }], + }, + }, + }), + ); +} + +// ─── /acp compact command ─────────────────────────────────────────────────── + +test("/acp compact (no args) lists models.json models when unset", async () => { + await writeModelsJson("http://127.0.0.1:1/v1"); + await fs.rm(acpJsonPath, { force: true }); + const { api } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "list-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + let out = ""; + const ctx = fakeCtx([userMsg("e1", "hello")], stateFile, tmp, (s) => (out = s)); + await api.commands.get("acp").handler("compact", ctx); + assert.ok(out.includes("NOT SET"), `expected NOT SET status, got: ${out}`); + assert.ok(out.includes("testprov/mini-summarizer"), `expected model listed, got: ${out}`); +}); + +test("/acp compact sets the model (in-memory ref + acp.json)", async () => { + await writeModelsJson("http://127.0.0.1:1/v1"); + await fs.rm(acpJsonPath, { force: true }); + const { api } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "set-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + let out = ""; + const ctx = fakeCtx([userMsg("e1", "hello")], stateFile, tmp, (s) => (out = s)); + await api.commands.get("acp").handler("compact testprov/mini-summarizer", ctx); + assert.ok(out.includes("set to testprov/mini-summarizer"), `expected set confirmation, got: ${out}`); + const written = JSON.parse(await fs.readFile(acpJsonPath, "utf8")); + assert.equal(written.compressionModelId, "testprov/mini-summarizer"); +}); + +test("/acp compact reports not found + lists available", async () => { + await writeModelsJson("http://127.0.0.1:1/v1"); + const { api } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "unknown-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + let out = ""; + const ctx = fakeCtx([userMsg("e1", "hello")], stateFile, tmp, (s) => (out = s)); + await api.commands.get("acp").handler("compact no-such-model", ctx); + assert.ok(out.includes("not found"), `expected not-found, got: ${out}`); + assert.ok(out.includes("testprov/mini-summarizer"), `expected available list, got: ${out}`); +}); + +test("/acp compact reset clears the model", async () => { + await writeModelsJson("http://127.0.0.1:1/v1"); + await fs.writeFile(acpJsonPath, JSON.stringify({ compressionModelId: "testprov/mini-summarizer" })); + const { api } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "reset-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + let out = ""; + const ctx = fakeCtx([userMsg("e1", "hello")], stateFile, tmp, (s) => (out = s)); + await api.commands.get("acp").handler("compact reset", ctx); + assert.ok(out.includes("cleared"), `expected cleared, got: ${out}`); + const written = JSON.parse(await fs.readFile(acpJsonPath, "utf8")); + assert.equal(written.compressionModelId, undefined); +}); + +// ─── handleCompress routing ───────────────────────────────────────────────── + +// >= 50 chars (kernel minSummaryLength) so the generated summary is accepted. +// (summarize() trims, so no trailing space — matches what gets stored.) +const MOCK_SUMMARY = "MOCK-SUMMARY: dedicated compression model output for this compressed range."; + +test("compress: dedicated model writes the summary (mock SSE server)", async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (o: unknown) => res.write("data: " + JSON.stringify(o) + "\n\n"); + chunk({ id: "c1", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: MOCK_SUMMARY }, finish_reason: null }] }); + chunk({ id: "c1", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } }); + res.write("data: [DONE]\n\n"); + res.end(); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as { port: number }).port; + try { + await writeModelsJson(`http://127.0.0.1:${port}/v1`); + await fs.rm(acpJsonPath, { force: true }); + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "success-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + const entries = compressibleEntries(); + const ctx = fakeCtx(entries, stateFile, tmp); + // Assign mNNNNN refs (the context transform does this each turn). + await handlers.get("context")![0]!({ type: "context", messages: [] }, ctx); + + // Set the compression model via the command (updates in-memory ref). + await api.commands.get("acp").handler("compact testprov/mini-summarizer", ctx); + + const compressTool = api.tools.find((t: any) => t.name === "compress")!; + const out = await compressTool.execute( + "tc1", + { content: [{ startId: "m00001", endId: "m00001", summary: "placeholder-from-main-model" }] }, + undefined, undefined, ctx, + ); + const text = typeof out === "string" ? out : out.content?.[0]?.text ?? String(out); + assert.ok(text.includes("summaries written by testprov/mini-summarizer"), `expected compression-model note, got: ${text}`); + + const summaries = await readBlockSummaries(stateFile); + assert.ok(summaries.some((s) => s === MOCK_SUMMARY), `expected block summary from mock model, got: ${JSON.stringify(summaries)}`); + assert.ok(!summaries.some((s) => s === "placeholder-from-main-model"), "placeholder must be overridden"); + } finally { + server.close(); + } +}); + +test("compress: falls back to main model when the compression model is unreachable", async () => { + // Port 1 → connection refused → complete() fails → fallback. + await writeModelsJson("http://127.0.0.1:1/v1"); + await fs.rm(acpJsonPath, { force: true }); + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "fallback-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + const entries = compressibleEntries(); + const ctx = fakeCtx(entries, stateFile, tmp); + await handlers.get("context")![0]!({ type: "context", messages: [] }, ctx); + + await api.commands.get("acp").handler("compact testprov/mini-summarizer", ctx); + + const FALLBACK_SUMMARY = "main-model fallback summary used because the compression model was unreachable. "; + const compressTool = api.tools.find((t: any) => t.name === "compress")!; + const out = await compressTool.execute( + "tc1", + { content: [{ startId: "m00001", endId: "m00001", summary: FALLBACK_SUMMARY }] }, + undefined, undefined, ctx, + ); + const text = typeof out === "string" ? out : out.content?.[0]?.text ?? String(out); + // Compression still succeeds (no interruption) using the main model's summary. + assert.ok(text.includes("▣ ACP |"), `expected a success panel, got: ${text}`); + + const summaries = await readBlockSummaries(stateFile); + assert.ok(summaries.some((s) => s === FALLBACK_SUMMARY), `expected main-model fallback summary, got: ${JSON.stringify(summaries)}`); +}); + +// ─── /acp compact session (shared-prefix path) ────────────────────────────── + +// >= 50 chars (kernel minSummaryLength), no trailing space (summarize trims). +const SESSION_SUMMARY = "SESSION-SUMMARY: shared-prefix compression model output for this range."; + +test("/acp compact session sets compressionModelId to 'session'", async () => { + await fs.rm(acpJsonPath, { force: true }); + const { api } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "sess-set-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + let out = ""; + const ctx = fakeCtx([userMsg("e1", "hello")], stateFile, tmp, (s) => (out = s)); + await api.commands.get("acp").handler("compact session", ctx); + assert.ok(out.includes("set to session"), `expected session confirmation, got: ${out}`); + const written = JSON.parse(await fs.readFile(acpJsonPath, "utf8")); + assert.equal(written.compressionModelId, "session"); +}); + +test("/acp compact (no args) shows current 'session' model", async () => { + await fs.writeFile(acpJsonPath, JSON.stringify({ compressionModelId: "session" })); + const { api } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "sess-status-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + let out = ""; + const ctx = fakeCtx([userMsg("e1", "hello")], stateFile, tmp, (s) => (out = s)); + await api.commands.get("acp").handler("compact", ctx); + assert.ok(out.includes("session"), `expected session status, got: ${out}`); +}); + +test("compress: session model writes the summary reusing the shared prefix", async () => { + let receivedBody = ""; + const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + receivedBody = body; + res.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (o: unknown) => res.write("data: " + JSON.stringify(o) + "\n\n"); + chunk({ id: "c1", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: SESSION_SUMMARY }, finish_reason: null }] }); + chunk({ id: "c1", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } }); + res.write("data: [DONE]\n\n"); + res.end(); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as { port: number }).port; + try { + const baseUrl = `http://127.0.0.1:${port}/v1`; + // Both the session model (ctx.model) and the registry point at the mock. + await writeModelsJson(baseUrl); + await fs.rm(acpJsonPath, { force: true }); + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "sess-success-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + const entries = compressibleEntries(); + const ctx = fakeCtx(entries, stateFile, tmp, undefined, sessionModel(baseUrl)); + // Capture the transformed messages (the shared prefix) via a context round. + await handlers.get("context")![0]!({ type: "context", messages: [] }, ctx); + + await api.commands.get("acp").handler("compact session", ctx); + + const compressTool = api.tools.find((t: any) => t.name === "compress")!; + const out = await compressTool.execute( + "tc1", + { content: [{ startId: "m00001", endId: "m00001", summary: "placeholder-from-main-model" }] }, + undefined, undefined, ctx, + ); + const text = typeof out === "string" ? out : out.content?.[0]?.text ?? String(out); + assert.ok(text.includes("summaries written by session model"), `expected session note, got: ${text}`); + + const summaries = await readBlockSummaries(stateFile); + assert.ok(summaries.some((s) => s === SESSION_SUMMARY), `expected session-model summary, got: ${JSON.stringify(summaries)}`); + // The shared prefix (captured transformed messages) must be in the request. + assert.ok(receivedBody.includes("lorem ipsum"), `expected shared prefix in request body, got: ${receivedBody.slice(0, 200)}`); + } finally { + server.close(); + } +}); + +test("compress: session model falls back to main model on error", async () => { + // Unreachable session model (port 1) → complete() fails → main-model fallback. + await writeModelsJson("http://127.0.0.1:1/v1"); + await fs.rm(acpJsonPath, { force: true }); + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const stateFile = path.join(tmp, "sess-fallback-state.json"); + await fs.rm(`${stateFile}.acp.json`, { force: true }); + const entries = compressibleEntries(); + const ctx = fakeCtx(entries, stateFile, tmp, undefined, sessionModel("http://127.0.0.1:1/v1")); + await handlers.get("context")![0]!({ type: "context", messages: [] }, ctx); + + await api.commands.get("acp").handler("compact session", ctx); + + const FALLBACK = "session-model fallback summary used because the session compression call failed. "; + const compressTool = api.tools.find((t: any) => t.name === "compress")!; + const out = await compressTool.execute( + "tc1", + { content: [{ startId: "m00001", endId: "m00001", summary: FALLBACK }] }, + undefined, undefined, ctx, + ); + const text = typeof out === "string" ? out : out.content?.[0]?.text ?? String(out); + assert.ok(text.includes("▣ ACP |"), `expected a success panel, got: ${text}`); + + const summaries = await readBlockSummaries(stateFile); + assert.ok(summaries.some((s) => s === FALLBACK), `expected main-model fallback summary, got: ${JSON.stringify(summaries)}`); +}); diff --git a/tests/compress-model.test.ts b/tests/compress-model.test.ts new file mode 100644 index 0000000..ff23e62 --- /dev/null +++ b/tests/compress-model.test.ts @@ -0,0 +1,172 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + CompressionModelClient, + buildSummarizeSystemPrompt, + truncateContent, + type CompleteFn, +} from "../src/compress-model.js"; +import { saveCompressionModelId, loadUserConfig } from "../src/user-config.js"; + +// models.json with two providers; "test-unique-a" appears in BOTH (for the +// ambiguity case), "test-unique-b" only in testprov. +const MODELS_JSON = { + providers: { + testprov: { + name: "Test Provider", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [ + { id: "test-unique-a", name: "Test A", contextWindow: 1000, maxTokens: 100 }, + { id: "test-unique-b", name: "Test B", contextWindow: 1000, maxTokens: 100 }, + ], + }, + testprov2: { + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "test-unique-a", name: "Test A (2)", contextWindow: 1000, maxTokens: 100 }], + }, + }, +}; + +function mockComplete(response: { text?: string; stopReason?: string; errorMessage?: string }): CompleteFn { + return async () => + ({ + role: "assistant", + content: response.text !== undefined ? [{ type: "text", text: response.text }] : [], + api: "openai-completions", + provider: "testprov", + model: "test-unique-a", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: response.stopReason ?? "stop", + timestamp: Date.now(), + ...(response.errorMessage !== undefined ? { errorMessage: response.errorMessage } : {}), + }) as never; +} + +let tmp: string; +let modelsPath: string; + +before(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "acp-compress-model-")); + modelsPath = path.join(tmp, "models.json"); + await fs.writeFile(modelsPath, JSON.stringify(MODELS_JSON, null, 2)); +}); + +after(async () => { + await fs.rm(tmp, { recursive: true, force: true }); +}); + +test("listModels reads only models.json providers (not built-ins)", async () => { + const client = new CompressionModelClient({ modelsPath }); + const models = await client.listModels(); + assert.deepEqual( + models.map((m) => `${m.provider}/${m.id}`).sort(), + ["testprov/test-unique-a", "testprov/test-unique-b", "testprov2/test-unique-a"].sort(), + ); +}); + +test("resolveModel: explicit provider/id resolves", async () => { + const client = new CompressionModelClient({ modelsPath }); + const r = await client.resolveModel("testprov/test-unique-b"); + assert.ok(r.model, "should resolve"); + assert.equal(r.model!.provider, "testprov"); + assert.equal(r.model!.id, "test-unique-b"); + assert.deepEqual(r.ambiguous, []); +}); + +test("resolveModel: unique bare id resolves", async () => { + const client = new CompressionModelClient({ modelsPath }); + const r = await client.resolveModel("test-unique-b"); + assert.ok(r.model, "should resolve"); + assert.equal(r.model!.id, "test-unique-b"); + assert.deepEqual(r.ambiguous, []); +}); + +test("resolveModel: ambiguous bare id returns candidates", async () => { + const client = new CompressionModelClient({ modelsPath }); + const r = await client.resolveModel("test-unique-a"); + assert.equal(r.model, null, "ambiguous → no single model"); + assert.deepEqual( + r.ambiguous.map((m) => `${m.provider}/${m.id}`).sort(), + ["testprov/test-unique-a", "testprov2/test-unique-a"].sort(), + ); +}); + +test("resolveModel: unknown ref → null, no ambiguity", async () => { + const client = new CompressionModelClient({ modelsPath }); + const r = await client.resolveModel("definitely-not-a-model-xyz"); + assert.equal(r.model, null); + assert.deepEqual(r.ambiguous, []); +}); + +test("summarize: returns the compression model's text", async () => { + const client = new CompressionModelClient({ modelsPath, complete: mockComplete({ text: " THE-SUMMARY " }) }); + const resolved = (await client.resolveModel("testprov/test-unique-b")).model!; + const out = await client.summarize(resolved, "[user] hello", "sys prompt", 500); + assert.equal(out, "THE-SUMMARY"); +}); + +test("summarize: API error stopReason throws (triggers fallback)", async () => { + const client = new CompressionModelClient({ modelsPath, complete: mockComplete({ stopReason: "error", errorMessage: "boom" }) }); + const resolved = (await client.resolveModel("testprov/test-unique-b")).model!; + await assert.rejects(() => client.summarize(resolved, "content", "sys", 500), /boom/); +}); + +test("summarize: empty/whitespace response throws (triggers fallback)", async () => { + const client = new CompressionModelClient({ modelsPath, complete: mockComplete({ text: " " }) }); + const resolved = (await client.resolveModel("testprov/test-unique-b")).model!; + await assert.rejects(() => client.summarize(resolved, "content", "sys", 500), /empty/); +}); + +test("buildSummarizeSystemPrompt includes topic and tier-1 rules", () => { + const p = buildSummarizeSystemPrompt({ compressPhilosophy: "PHIL", howToCompressRules: "RULES" }, "Auth Work"); + assert.ok(p.includes("PHIL")); + assert.ok(p.includes("RULES")); + assert.ok(p.includes("Auth Work")); +}); + +test("truncateContent keeps head and tail when over the cap", () => { + const content = "H".repeat(50) + "M".repeat(200) + "T".repeat(50); + const out = truncateContent(content, 120); + assert.ok(out.length <= 120, `length ${out.length} > 120`); + assert.ok(out.startsWith("H".repeat(20)), "keeps head"); + assert.ok(out.endsWith("T".repeat(20)), "keeps tail"); + assert.ok(out.includes("[... truncated ...]"), "has marker"); +}); + +test("truncateContent is a no-op under the cap", () => { + assert.equal(truncateContent("short", 100), "short"); +}); + +// ─── persistence (saveCompressionModelId ↔ loadUserConfig) ────────────────── + +test("saveCompressionModelId writes, reads back, and clears (isolated HOME)", async () => { + const prevHome = process.env.HOME; + const home = await fs.mkdtemp(path.join(os.tmpdir(), "acp-home-")); + process.env.HOME = home; + try { + await saveCompressionModelId("testprov/test-unique-b"); + const acpJson = path.join(home, ".pi", "acp.json"); + const written = JSON.parse(await fs.readFile(acpJson, "utf8")); + assert.equal(written.compressionModelId, "testprov/test-unique-b"); + + // loadUserConfig (cwd outside home) picks up the global value. + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acp-cwd-")); + const cfg = await loadUserConfig(cwd); + assert.equal(cfg.compressionModelId, "testprov/test-unique-b"); + + await saveCompressionModelId(null); + const cleared = JSON.parse(await fs.readFile(acpJson, "utf8")); + assert.equal(cleared.compressionModelId, undefined); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + await fs.rm(home, { recursive: true, force: true }); + } +});