From e9208f27af1e26d9aabf43c339859d2c9efd56c8 Mon Sep 17 00:00:00 2001 From: DK09876 Date: Thu, 2 Apr 2026 11:04:49 -0700 Subject: [PATCH 1/4] feat: add OpenCode persistent memory plugin Add hindsight-opencode integration with: - Three custom tools: hindsight_retain, hindsight_recall, hindsight_reflect - Auto-retain on session.idle with document_id deduplication - Memory injection on session start via system transform hook - Memory preservation during context window compaction - Sliding window retain with retainOverlapTurns support - 4-level config hierarchy (defaults, user file, plugin options, env vars) - Dynamic bank ID derivation (agent, project, channel, user dimensions) - CI job, release script entry, docs page 79 tests across 6 test files. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 35 + hindsight-docs/docs-integrations/opencode.md | 141 + hindsight-docs/src/data/integrations.json | 10 + hindsight-docs/static/img/icons/opencode.svg | 4 + hindsight-integrations/opencode/README.md | 142 + .../opencode/package-lock.json | 2686 +++++++++++++++++ hindsight-integrations/opencode/package.json | 63 + .../opencode/src/bank.test.ts | 169 ++ hindsight-integrations/opencode/src/bank.ts | 94 + .../opencode/src/config.test.ts | 102 + hindsight-integrations/opencode/src/config.ts | 166 + .../opencode/src/content.test.ts | 194 ++ .../opencode/src/content.ts | 178 ++ .../opencode/src/hooks.test.ts | 302 ++ hindsight-integrations/opencode/src/hooks.ts | 296 ++ hindsight-integrations/opencode/src/index.ts | 80 + .../opencode/src/plugin.test.ts | 102 + .../opencode/src/tools.test.ts | 294 ++ hindsight-integrations/opencode/src/tools.ts | 101 + hindsight-integrations/opencode/tsconfig.json | 18 + .../opencode/tsup.config.ts | 11 + .../opencode/vitest.config.ts | 9 + scripts/release-integration.sh | 2 +- 23 files changed, 5198 insertions(+), 1 deletion(-) create mode 100644 hindsight-docs/docs-integrations/opencode.md create mode 100644 hindsight-docs/static/img/icons/opencode.svg create mode 100644 hindsight-integrations/opencode/README.md create mode 100644 hindsight-integrations/opencode/package-lock.json create mode 100644 hindsight-integrations/opencode/package.json create mode 100644 hindsight-integrations/opencode/src/bank.test.ts create mode 100644 hindsight-integrations/opencode/src/bank.ts create mode 100644 hindsight-integrations/opencode/src/config.test.ts create mode 100644 hindsight-integrations/opencode/src/config.ts create mode 100644 hindsight-integrations/opencode/src/content.test.ts create mode 100644 hindsight-integrations/opencode/src/content.ts create mode 100644 hindsight-integrations/opencode/src/hooks.test.ts create mode 100644 hindsight-integrations/opencode/src/hooks.ts create mode 100644 hindsight-integrations/opencode/src/index.ts create mode 100644 hindsight-integrations/opencode/src/plugin.test.ts create mode 100644 hindsight-integrations/opencode/src/tools.test.ts create mode 100644 hindsight-integrations/opencode/src/tools.ts create mode 100644 hindsight-integrations/opencode/tsconfig.json create mode 100644 hindsight-integrations/opencode/tsup.config.ts create mode 100644 hindsight-integrations/opencode/vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dc34b4d54..db1d8060f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,6 +45,7 @@ jobs: integrations-ag2: ${{ steps.filter.outputs.integrations-ag2 }} integrations-hermes: ${{ steps.filter.outputs.integrations-hermes }} integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }} + integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }} dev: ${{ steps.filter.outputs.dev }} ci: ${{ steps.filter.outputs.ci }} # Secrets are available for internal PRs, pull_request_review, and workflow_dispatch. @@ -117,6 +118,8 @@ jobs: - 'hindsight-integrations/hermes/**' integrations-llamaindex: - 'hindsight-integrations/llamaindex/**' + integrations-opencode: + - 'hindsight-integrations/opencode/**' dev: - 'hindsight-dev/**' ci: @@ -326,6 +329,37 @@ jobs: working-directory: ./hindsight-integrations/ai-sdk run: npm run test:deno + build-opencode-integration: + needs: [detect-changes] + if: >- + github.event_name != 'pull_request_review' && + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-opencode == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || '' }} + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Install dependencies + working-directory: ./hindsight-integrations/opencode + run: npm ci + + - name: Run tests + working-directory: ./hindsight-integrations/opencode + run: npm test + + - name: Build + working-directory: ./hindsight-integrations/opencode + run: npm run build + build-chat-integration: needs: [detect-changes] if: >- @@ -2427,6 +2461,7 @@ jobs: - test-codex-integration - build-ai-sdk-integration - test-ai-sdk-integration-deno + - build-opencode-integration - build-chat-integration - build-control-plane - build-docs diff --git a/hindsight-docs/docs-integrations/opencode.md b/hindsight-docs/docs-integrations/opencode.md new file mode 100644 index 000000000..93b772a8d --- /dev/null +++ b/hindsight-docs/docs-integrations/opencode.md @@ -0,0 +1,141 @@ +--- +sidebar_position: 20 +title: "OpenCode Persistent Memory with Hindsight | Integration" +description: "Add long-term memory to OpenCode with Hindsight. Automatically captures conversations and recalls relevant context across coding sessions." +--- + +# OpenCode + +Persistent long-term memory plugin for [OpenCode](https://opencode.ai) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations, recalls relevant context on session start, and provides retain/recall/reflect tools the agent can call directly. + +## Quick Start + +```bash +# 1. Install the plugin +npm install @vectorize-io/opencode-hindsight +``` + +Add to your `opencode.json`: + +```json +{ + "plugin": ["@vectorize-io/opencode-hindsight"] +} +``` + +```bash +# 2. Configure your Hindsight server +export HINDSIGHT_API_URL="http://localhost:8888" + +# Optional: API key for Hindsight Cloud +export HINDSIGHT_API_TOKEN="your-api-key" + +# 3. Start OpenCode — the plugin activates automatically +opencode +``` + +## Features + +### Custom Tools + +The plugin registers three tools the agent can call explicitly: + +| Tool | Description | +|---|---| +| `hindsight_retain` | Store information in long-term memory | +| `hindsight_recall` | Search long-term memory for relevant information | +| `hindsight_reflect` | Generate a synthesized answer from long-term memory | + +### Auto-Retain + +When the session goes idle (`session.idle` event), the plugin automatically retains the conversation transcript to Hindsight. Configurable via `retainEveryNTurns` to control frequency. + +### Session Recall + +When a new session starts, the plugin recalls relevant project context and injects it into the system prompt, giving the agent access to memories from prior sessions. + +### Compaction Hook + +When OpenCode compacts the context window, the plugin: +1. Retains the current conversation before compaction +2. Recalls relevant memories and injects them into the compaction context + +This ensures memories survive context window trimming. + +## Configuration + +### Plugin Options + +```json +{ + "plugin": [ + ["@vectorize-io/opencode-hindsight", { + "hindsightApiUrl": "http://localhost:8888", + "hindsightApiToken": "your-api-key", + "bankId": "my-project", + "autoRecall": true, + "autoRetain": true, + "recallBudget": "mid", + "retainEveryNTurns": 10, + "debug": false + }] + ] +} +``` + +### Config File + +Create `~/.hindsight/opencode.json` for persistent configuration that applies across all projects: + +```json +{ + "hindsightApiUrl": "http://localhost:8888", + "hindsightApiToken": "your-api-key", + "recallBudget": "mid" +} +``` + +### Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `HINDSIGHT_API_URL` | Hindsight API base URL | *(required)* | +| `HINDSIGHT_API_TOKEN` | API key for authentication | | +| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` | +| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` | +| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` | +| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` | +| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` | +| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` | +| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` | +| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` | +| `HINDSIGHT_BANK_MISSION` | Bank mission/context for reflect | | +| `HINDSIGHT_DEBUG` | Enable debug logging to stderr | `false` | + +Configuration priority (later wins): defaults < `~/.hindsight/opencode.json` < plugin options < env vars. + +## Dynamic Bank IDs + +For multi-project isolation, enable dynamic bank ID derivation: + +```bash +export HINDSIGHT_DYNAMIC_BANK_ID=true +``` + +The bank ID is composed from granularity fields (default: `agent::project`). Supported fields: `agent`, `project`, `session`, `channel`, `user`. + +For multi-user scenarios (e.g., shared agent serving multiple users): + +```bash +export HINDSIGHT_CHANNEL_ID="slack-general" +export HINDSIGHT_USER_ID="user123" +``` + +## How It Works + +1. **Plugin loads** when OpenCode starts — creates a `HindsightClient`, derives the bank ID, and registers tools + hooks +2. **Session starts** — `session.created` event triggers, plugin marks session for recall injection +3. **System transform** — on the first LLM call, recalled memories are injected into the system prompt +4. **Agent works** — can call `hindsight_recall` and `hindsight_retain` explicitly during the session +5. **Session idles** — `session.idle` event triggers auto-retain of the conversation +6. **Compaction** — if the context window fills up, memories are preserved through the compaction diff --git a/hindsight-docs/src/data/integrations.json b/hindsight-docs/src/data/integrations.json index d2faeb8eb..88b0b08d2 100644 --- a/hindsight-docs/src/data/integrations.json +++ b/hindsight-docs/src/data/integrations.json @@ -180,6 +180,16 @@ "link": "/sdks/integrations/autogen", "icon": "/img/icons/autogen.svg" }, + { + "id": "opencode", + "name": "OpenCode", + "description": "Persistent long-term memory plugin for OpenCode. Auto-retains conversations, recalls context on session start, and provides retain/recall/reflect tools.", + "type": "official", + "by": "hindsight", + "category": "tool", + "link": "/sdks/integrations/opencode", + "icon": "/img/icons/opencode.svg" + }, { "id": "hindclaw", "name": "HindClaw", diff --git a/hindsight-docs/static/img/icons/opencode.svg b/hindsight-docs/static/img/icons/opencode.svg new file mode 100644 index 000000000..c3d1d35ba --- /dev/null +++ b/hindsight-docs/static/img/icons/opencode.svg @@ -0,0 +1,4 @@ + + + OC + diff --git a/hindsight-integrations/opencode/README.md b/hindsight-integrations/opencode/README.md new file mode 100644 index 000000000..2d3b6628c --- /dev/null +++ b/hindsight-integrations/opencode/README.md @@ -0,0 +1,142 @@ +# @vectorize-io/opencode-hindsight + +Hindsight memory plugin for [OpenCode](https://opencode.ai) — give your AI coding agent persistent long-term memory across sessions. + +## Features + +- **Custom tools**: `hindsight_retain`, `hindsight_recall`, `hindsight_reflect` — the agent calls these explicitly +- **Auto-retain**: Captures conversation on `session.idle` and stores to Hindsight +- **Memory injection**: Recalls relevant memories when a new session starts +- **Compaction hook**: Injects memories during context compaction so they survive window trimming + +## Quick Start + +### 1. Install + +```bash +npm install @vectorize-io/opencode-hindsight +``` + +### 2. Configure + +Add to your `opencode.json`: + +```json +{ + "plugin": ["@vectorize-io/opencode-hindsight"] +} +``` + +### 3. Set Environment Variables + +```bash +# Required: Hindsight API URL +export HINDSIGHT_API_URL="http://localhost:8888" + +# Optional: API key for Hindsight Cloud +export HINDSIGHT_API_TOKEN="your-api-key" + +# Optional: Override the memory bank ID +export HINDSIGHT_BANK_ID="my-project" +``` + +## Configuration + +### Plugin Options + +Pass options directly in `opencode.json`: + +```json +{ + "plugin": [ + ["@vectorize-io/opencode-hindsight", { + "hindsightApiUrl": "http://localhost:8888", + "bankId": "my-project", + "autoRecall": true, + "autoRetain": true, + "recallBudget": "mid" + }] + ] +} +``` + +### Config File + +Create `~/.hindsight/opencode.json` for persistent configuration: + +```json +{ + "hindsightApiUrl": "http://localhost:8888", + "hindsightApiToken": "your-api-key", + "recallBudget": "mid", + "retainEveryNTurns": 10, + "debug": false +} +``` + +### Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `HINDSIGHT_API_URL` | Hindsight API base URL | (required) | +| `HINDSIGHT_API_TOKEN` | API key for authentication | (none) | +| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` | +| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` | +| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` | +| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` | +| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` | +| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` | +| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` | +| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` | +| `HINDSIGHT_BANK_MISSION` | Bank mission/context | (none) | +| `HINDSIGHT_DEBUG` | Enable debug logging | `false` | + +### Configuration Priority + +Settings are loaded in this order (later wins): + +1. Built-in defaults +2. `~/.hindsight/opencode.json` +3. Plugin options from `opencode.json` +4. Environment variables + +## Tools + +### `hindsight_retain` + +Store information in long-term memory. The agent uses this to save important facts, user preferences, project context, and decisions. + +### `hindsight_recall` + +Search long-term memory. The agent uses this proactively before answering questions where prior context would help. + +### `hindsight_reflect` + +Generate a synthesized answer from long-term memory. Unlike recall (raw memories), reflect produces a coherent summary. + +## Dynamic Bank IDs + +For multi-project setups, enable dynamic bank ID derivation: + +```bash +export HINDSIGHT_DYNAMIC_BANK_ID=true +``` + +The bank ID is composed from granularity fields (default: `agent::project`). For multi-user scenarios: + +```bash +export HINDSIGHT_CHANNEL_ID="slack-general" +export HINDSIGHT_USER_ID="user123" +``` + +## Development + +```bash +npm install +npm test # Run tests +npm run build # Build to dist/ +``` + +## License + +MIT diff --git a/hindsight-integrations/opencode/package-lock.json b/hindsight-integrations/opencode/package-lock.json new file mode 100644 index 000000000..dc49c72ea --- /dev/null +++ b/hindsight-integrations/opencode/package-lock.json @@ -0,0 +1,2686 @@ +{ + "name": "@vectorize-io/opencode-hindsight", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@vectorize-io/opencode-hindsight", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@vectorize-io/hindsight-client": "^0.4.19" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.3.13", + "@types/node": "^22.0.0", + "tsup": "^8.5.1", + "typescript": "^5.7.0", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.5.tgz", + "integrity": "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.5.tgz", + "integrity": "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.5.tgz", + "integrity": "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.5.tgz", + "integrity": "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.5.tgz", + "integrity": "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.5.tgz", + "integrity": "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.5.tgz", + "integrity": "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.5.tgz", + "integrity": "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.5.tgz", + "integrity": "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.5.tgz", + "integrity": "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.5.tgz", + "integrity": "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.5.tgz", + "integrity": "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.5.tgz", + "integrity": "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.5.tgz", + "integrity": "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.5.tgz", + "integrity": "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.5.tgz", + "integrity": "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.5.tgz", + "integrity": "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.5.tgz", + "integrity": "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.5.tgz", + "integrity": "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.5.tgz", + "integrity": "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.5.tgz", + "integrity": "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.5.tgz", + "integrity": "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.5.tgz", + "integrity": "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.5.tgz", + "integrity": "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.5.tgz", + "integrity": "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.5.tgz", + "integrity": "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.13.tgz", + "integrity": "sha512-zHgtWfdDz8Wu8srE8f8HUtPT9i6c3jTmgQKoFZUZ+RR5CMQF1kAlb1cxeEe9Xm2DRNFVJog9Cv/G1iUHYgXSUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.3.13", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.95", + "@opentui/solid": ">=0.1.95" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.13.tgz", + "integrity": "sha512-/M6HlNnba+xf1EId6qFb2tG0cvq0db3PCQDug1glrf8wYOU57LYNF8WvHX9zoDKPTMv0F+O4pcP/8J+WvDaxHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vectorize-io/hindsight-client": { + "version": "0.4.22", + "resolved": "https://registry.npmjs.org/@vectorize-io/hindsight-client/-/hindsight-client-0.4.22.tgz", + "integrity": "sha512-Z15ONaVranek2Awqq1qvitc42EQsnUxhzwxQPZBawQ6tpQz2gPNh3wQLYg4Pe+dCIPB4WmeQu9R8grmu5IBFfA==", + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.5.tgz", + "integrity": "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.5", + "@esbuild/android-arm": "0.27.5", + "@esbuild/android-arm64": "0.27.5", + "@esbuild/android-x64": "0.27.5", + "@esbuild/darwin-arm64": "0.27.5", + "@esbuild/darwin-x64": "0.27.5", + "@esbuild/freebsd-arm64": "0.27.5", + "@esbuild/freebsd-x64": "0.27.5", + "@esbuild/linux-arm": "0.27.5", + "@esbuild/linux-arm64": "0.27.5", + "@esbuild/linux-ia32": "0.27.5", + "@esbuild/linux-loong64": "0.27.5", + "@esbuild/linux-mips64el": "0.27.5", + "@esbuild/linux-ppc64": "0.27.5", + "@esbuild/linux-riscv64": "0.27.5", + "@esbuild/linux-s390x": "0.27.5", + "@esbuild/linux-x64": "0.27.5", + "@esbuild/netbsd-arm64": "0.27.5", + "@esbuild/netbsd-x64": "0.27.5", + "@esbuild/openbsd-arm64": "0.27.5", + "@esbuild/openbsd-x64": "0.27.5", + "@esbuild/openharmony-arm64": "0.27.5", + "@esbuild/sunos-x64": "0.27.5", + "@esbuild/win32-arm64": "0.27.5", + "@esbuild/win32-ia32": "0.27.5", + "@esbuild/win32-x64": "0.27.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/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/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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "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", + "optional": true + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/hindsight-integrations/opencode/package.json b/hindsight-integrations/opencode/package.json new file mode 100644 index 000000000..aa0105c49 --- /dev/null +++ b/hindsight-integrations/opencode/package.json @@ -0,0 +1,63 @@ +{ + "name": "@vectorize-io/opencode-hindsight", + "version": "0.1.0", + "description": "Hindsight memory plugin for OpenCode - Give your AI coding agent persistent long-term memory", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "keywords": [ + "opencode", + "ai", + "memory", + "hindsight", + "agents", + "llm", + "long-term-memory", + "coding-agent" + ], + "author": "Vectorize ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/vectorize-io/hindsight.git", + "directory": "hindsight-integrations/opencode" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup", + "dev": "tsc --watch", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "npm run clean && npm run build" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.0.0" + }, + "dependencies": { + "@vectorize-io/hindsight-client": "^0.4.19" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.3.13", + "@types/node": "^22.0.0", + "tsup": "^8.5.1", + "typescript": "^5.7.0", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22" + }, + "overrides": { + "rollup": "^4.59.0", + "picomatch": ">=2.3.2 <3.0.0 || >=4.0.4" + } +} diff --git a/hindsight-integrations/opencode/src/bank.test.ts b/hindsight-integrations/opencode/src/bank.test.ts new file mode 100644 index 000000000..9ded28c50 --- /dev/null +++ b/hindsight-integrations/opencode/src/bank.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { deriveBankId, ensureBankMission } from './bank.js'; +import type { HindsightConfig } from './config.js'; + +function makeConfig(overrides: Partial = {}): HindsightConfig { + return { + autoRecall: true, + recallBudget: 'mid', + recallMaxTokens: 1024, + recallTypes: ['world', 'experience'], + recallContextTurns: 1, + recallMaxQueryChars: 800, + recallPromptPreamble: '', + autoRetain: true, + retainMode: 'full-session', + retainEveryNTurns: 10, + retainOverlapTurns: 2, + retainContext: 'opencode', + retainTags: [], + retainMetadata: {}, + hindsightApiUrl: null, + hindsightApiToken: null, + bankId: null, + bankIdPrefix: '', + dynamicBankId: false, + dynamicBankGranularity: ['agent', 'project'], + bankMission: '', + retainMission: null, + agentName: 'opencode', + debug: false, + ...overrides, + }; +} + +describe('deriveBankId', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns default bank name in static mode', () => { + expect(deriveBankId(makeConfig(), '/home/user/project')).toBe('opencode'); + }); + + it('returns configured bankId in static mode', () => { + const config = makeConfig({ bankId: 'my-bank' }); + expect(deriveBankId(config, '/home/user/project')).toBe('my-bank'); + }); + + it('adds prefix in static mode', () => { + const config = makeConfig({ bankIdPrefix: 'dev', bankId: 'my-bank' }); + expect(deriveBankId(config, '/home/user/project')).toBe('dev-my-bank'); + }); + + it('composes from granularity fields in dynamic mode', () => { + const config = makeConfig({ + dynamicBankId: true, + dynamicBankGranularity: ['agent', 'project'], + agentName: 'opencode', + }); + expect(deriveBankId(config, '/home/user/my-project')).toBe('opencode::my-project'); + }); + + it('uses default granularity when not specified', () => { + const config = makeConfig({ + dynamicBankId: true, + dynamicBankGranularity: [], + }); + expect(deriveBankId(config, '/home/user/proj')).toBe('opencode::proj'); + }); + + it('URL-encodes special characters', () => { + const config = makeConfig({ + dynamicBankId: true, + dynamicBankGranularity: ['project'], + }); + expect(deriveBankId(config, '/home/user/my project')).toBe('my%20project'); + }); + + it('uses channel/user from env vars', () => { + process.env.HINDSIGHT_CHANNEL_ID = 'slack-general'; + process.env.HINDSIGHT_USER_ID = 'user123'; + const config = makeConfig({ + dynamicBankId: true, + dynamicBankGranularity: ['agent', 'channel', 'user'], + }); + expect(deriveBankId(config, '/home/user/proj')).toBe('opencode::slack-general::user123'); + }); + + it('uses defaults for missing env vars', () => { + delete process.env.HINDSIGHT_CHANNEL_ID; + delete process.env.HINDSIGHT_USER_ID; + const config = makeConfig({ + dynamicBankId: true, + dynamicBankGranularity: ['channel', 'user'], + }); + expect(deriveBankId(config, '/home/user/proj')).toBe('default::anonymous'); + }); + + it('adds prefix in dynamic mode', () => { + const config = makeConfig({ + dynamicBankId: true, + bankIdPrefix: 'dev', + dynamicBankGranularity: ['agent'], + }); + expect(deriveBankId(config, '/home/user/proj')).toBe('dev-opencode'); + }); +}); + +describe('ensureBankMission', () => { + it('calls createBank on first use', async () => { + const client = { createBank: vi.fn().mockResolvedValue({}) } as any; + const missionsSet = new Set(); + const config = makeConfig({ bankMission: 'Test mission' }); + + await ensureBankMission(client, 'test-bank', config, missionsSet); + + expect(client.createBank).toHaveBeenCalledWith('test-bank', { + reflectMission: 'Test mission', + retainMission: undefined, + }); + expect(missionsSet.has('test-bank')).toBe(true); + }); + + it('skips if already set', async () => { + const client = { createBank: vi.fn() } as any; + const missionsSet = new Set(['test-bank']); + const config = makeConfig({ bankMission: 'Test mission' }); + + await ensureBankMission(client, 'test-bank', config, missionsSet); + + expect(client.createBank).not.toHaveBeenCalled(); + }); + + it('skips if no mission configured', async () => { + const client = { createBank: vi.fn() } as any; + const missionsSet = new Set(); + const config = makeConfig({ bankMission: '' }); + + await ensureBankMission(client, 'test-bank', config, missionsSet); + + expect(client.createBank).not.toHaveBeenCalled(); + }); + + it('does not throw on client error', async () => { + const client = { createBank: vi.fn().mockRejectedValue(new Error('Network error')) } as any; + const missionsSet = new Set(); + const config = makeConfig({ bankMission: 'Mission' }); + + await expect( + ensureBankMission(client, 'test-bank', config, missionsSet), + ).resolves.not.toThrow(); + expect(missionsSet.has('test-bank')).toBe(false); + }); + + it('passes retainMission when configured', async () => { + const client = { createBank: vi.fn().mockResolvedValue({}) } as any; + const missionsSet = new Set(); + const config = makeConfig({ bankMission: 'Reflect', retainMission: 'Extract carefully' }); + + await ensureBankMission(client, 'test-bank', config, missionsSet); + + expect(client.createBank).toHaveBeenCalledWith('test-bank', { + reflectMission: 'Reflect', + retainMission: 'Extract carefully', + }); + }); +}); diff --git a/hindsight-integrations/opencode/src/bank.ts b/hindsight-integrations/opencode/src/bank.ts new file mode 100644 index 000000000..28cb3668b --- /dev/null +++ b/hindsight-integrations/opencode/src/bank.ts @@ -0,0 +1,94 @@ +/** + * Bank ID derivation and mission management. + * + * Port of Claude Code plugin's bank.py, adapted for OpenCode's context model. + * + * Dimensions for dynamic bank IDs: + * - agent → configured name or "opencode" + * - project → derived from working directory basename + */ + +import { basename } from 'node:path'; +import type { HindsightConfig } from './config.js'; +import { debugLog } from './config.js'; +import type { HindsightClient } from '@vectorize-io/hindsight-client'; + +const DEFAULT_BANK_NAME = 'opencode'; +const VALID_FIELDS = new Set(['agent', 'project', 'channel', 'user']); + +/** + * Derive a bank ID from context and config. + * + * Static mode: returns config.bankId or DEFAULT_BANK_NAME. + * Dynamic mode: composes from granularity fields joined by '::'. + */ +export function deriveBankId(config: HindsightConfig, directory: string): string { + const prefix = config.bankIdPrefix; + + if (!config.dynamicBankId) { + const base = config.bankId || DEFAULT_BANK_NAME; + return prefix ? `${prefix}-${base}` : base; + } + + const fields = config.dynamicBankGranularity?.length + ? config.dynamicBankGranularity + : ['agent', 'project']; + + for (const f of fields) { + if (!VALID_FIELDS.has(f)) { + console.error( + `[Hindsight] Unknown dynamicBankGranularity field "${f}" — ` + + `valid: ${[...VALID_FIELDS].sort().join(', ')}`, + ); + } + } + + const channelId = process.env.HINDSIGHT_CHANNEL_ID || ''; + const userId = process.env.HINDSIGHT_USER_ID || ''; + + const fieldMap: Record = { + agent: config.agentName || 'opencode', + project: directory ? basename(directory) : 'unknown', + channel: channelId || 'default', + user: userId || 'anonymous', + }; + + const segments = fields.map((f) => encodeURIComponent(fieldMap[f] || 'unknown')); + const baseBankId = segments.join('::'); + + return prefix ? `${prefix}-${baseBankId}` : baseBankId; +} + +/** + * Set bank mission on first use, skip if already set. + * Uses an in-memory Set (plugin is long-lived, unlike Claude Code's ephemeral hooks). + */ +export async function ensureBankMission( + client: HindsightClient, + bankId: string, + config: HindsightConfig, + missionsSet: Set, +): Promise { + const mission = config.bankMission; + if (!mission?.trim()) return; + if (missionsSet.has(bankId)) return; + + try { + await client.createBank(bankId, { + reflectMission: mission, + retainMission: config.retainMission || undefined, + }); + missionsSet.add(bankId); + // Cap tracked banks + if (missionsSet.size > 10000) { + const keys = [...missionsSet].sort(); + for (const k of keys.slice(0, keys.length >> 1)) { + missionsSet.delete(k); + } + } + debugLog(config, `Set mission for bank: ${bankId}`); + } catch (e) { + // Don't fail if mission set fails — bank may not exist yet + debugLog(config, `Could not set bank mission for ${bankId}: ${e}`); + } +} diff --git a/hindsight-integrations/opencode/src/config.test.ts b/hindsight-integrations/opencode/src/config.test.ts new file mode 100644 index 000000000..13c6f1ae3 --- /dev/null +++ b/hindsight-integrations/opencode/src/config.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { loadConfig, type HindsightConfig } from './config.js'; + +describe('loadConfig', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear all HINDSIGHT_ env vars + for (const key of Object.keys(process.env)) { + if (key.startsWith('HINDSIGHT_')) { + delete process.env[key]; + } + } + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns defaults when no config sources exist', () => { + const config = loadConfig(); + expect(config.autoRecall).toBe(true); + expect(config.autoRetain).toBe(true); + expect(config.recallBudget).toBe('mid'); + expect(config.recallMaxTokens).toBe(1024); + expect(config.retainContext).toBe('opencode'); + expect(config.agentName).toBe('opencode'); + expect(config.dynamicBankId).toBe(false); + expect(config.debug).toBe(false); + expect(config.hindsightApiUrl).toBeNull(); + expect(config.hindsightApiToken).toBeNull(); + expect(config.bankId).toBeNull(); + }); + + it('env vars override defaults', () => { + process.env.HINDSIGHT_API_URL = 'https://example.com'; + process.env.HINDSIGHT_API_TOKEN = 'secret-token'; + process.env.HINDSIGHT_BANK_ID = 'my-bank'; + process.env.HINDSIGHT_AUTO_RECALL = 'false'; + process.env.HINDSIGHT_AUTO_RETAIN = '0'; + process.env.HINDSIGHT_RECALL_MAX_TOKENS = '2048'; + process.env.HINDSIGHT_DEBUG = 'true'; + + const config = loadConfig(); + expect(config.hindsightApiUrl).toBe('https://example.com'); + expect(config.hindsightApiToken).toBe('secret-token'); + expect(config.bankId).toBe('my-bank'); + expect(config.autoRecall).toBe(false); + expect(config.autoRetain).toBe(false); + expect(config.recallMaxTokens).toBe(2048); + expect(config.debug).toBe(true); + }); + + it('plugin options override defaults', () => { + const config = loadConfig({ + bankId: 'plugin-bank', + autoRecall: false, + recallBudget: 'high', + }); + expect(config.bankId).toBe('plugin-bank'); + expect(config.autoRecall).toBe(false); + expect(config.recallBudget).toBe('high'); + }); + + it('env vars override plugin options', () => { + process.env.HINDSIGHT_BANK_ID = 'env-bank'; + const config = loadConfig({ bankId: 'plugin-bank' }); + expect(config.bankId).toBe('env-bank'); + }); + + it('boolean env var parsing', () => { + process.env.HINDSIGHT_AUTO_RECALL = 'true'; + expect(loadConfig().autoRecall).toBe(true); + + process.env.HINDSIGHT_AUTO_RECALL = '1'; + expect(loadConfig().autoRecall).toBe(true); + + process.env.HINDSIGHT_AUTO_RECALL = 'yes'; + expect(loadConfig().autoRecall).toBe(true); + + process.env.HINDSIGHT_AUTO_RECALL = 'false'; + expect(loadConfig().autoRecall).toBe(false); + + process.env.HINDSIGHT_AUTO_RECALL = 'no'; + expect(loadConfig().autoRecall).toBe(false); + }); + + it('integer env var parsing', () => { + process.env.HINDSIGHT_RECALL_MAX_TOKENS = '4096'; + expect(loadConfig().recallMaxTokens).toBe(4096); + + // Invalid integer keeps default + process.env.HINDSIGHT_RECALL_MAX_TOKENS = 'not-a-number'; + expect(loadConfig().recallMaxTokens).toBe(1024); + }); + + it('null plugin options are ignored', () => { + const config = loadConfig({ bankId: null, debug: undefined }); + expect(config.bankId).toBeNull(); // stays default null + expect(config.debug).toBe(false); // stays default + }); +}); diff --git a/hindsight-integrations/opencode/src/config.ts b/hindsight-integrations/opencode/src/config.ts new file mode 100644 index 000000000..026bb0061 --- /dev/null +++ b/hindsight-integrations/opencode/src/config.ts @@ -0,0 +1,166 @@ +/** + * Configuration management for the Hindsight OpenCode plugin. + * + * Loading order (later entries win): + * 1. Built-in defaults + * 2. User config file (~/.hindsight/opencode.json) + * 3. Plugin options (from opencode.json plugin tuple) + * 4. Environment variable overrides + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +export interface HindsightConfig { + // Recall + autoRecall: boolean; + recallBudget: string; + recallMaxTokens: number; + recallTypes: string[]; + recallContextTurns: number; + recallMaxQueryChars: number; + recallPromptPreamble: string; + + // Retain + autoRetain: boolean; + retainMode: string; + retainEveryNTurns: number; + retainOverlapTurns: number; + retainContext: string; + retainTags: string[]; + retainMetadata: Record; + + // Connection + hindsightApiUrl: string | null; + hindsightApiToken: string | null; + + // Bank + bankId: string | null; + bankIdPrefix: string; + dynamicBankId: boolean; + dynamicBankGranularity: string[]; + bankMission: string; + retainMission: string | null; + agentName: string; + + // Misc + debug: boolean; +} + +const DEFAULTS: HindsightConfig = { + // Recall + autoRecall: true, + recallBudget: 'mid', + recallMaxTokens: 1024, + recallTypes: ['world', 'experience'], + recallContextTurns: 1, + recallMaxQueryChars: 800, + recallPromptPreamble: + 'Relevant memories from past conversations (prioritize recent when ' + + 'conflicting). Only use memories that are directly useful to continue ' + + 'this conversation; ignore the rest:', + + // Retain + autoRetain: true, + retainMode: 'full-session', + retainEveryNTurns: 10, + retainOverlapTurns: 2, + retainContext: 'opencode', + retainTags: [], + retainMetadata: {}, + + // Connection + hindsightApiUrl: null, + hindsightApiToken: null, + + // Bank + bankId: null, + bankIdPrefix: '', + dynamicBankId: false, + dynamicBankGranularity: ['agent', 'project'], + bankMission: '', + retainMission: null, + agentName: 'opencode', + + // Misc + debug: false, +}; + +/** Env var → config key + type mapping */ +const ENV_OVERRIDES: Record = { + HINDSIGHT_API_URL: ['hindsightApiUrl', 'string'], + HINDSIGHT_API_TOKEN: ['hindsightApiToken', 'string'], + HINDSIGHT_BANK_ID: ['bankId', 'string'], + HINDSIGHT_AGENT_NAME: ['agentName', 'string'], + HINDSIGHT_AUTO_RECALL: ['autoRecall', 'bool'], + HINDSIGHT_AUTO_RETAIN: ['autoRetain', 'bool'], + HINDSIGHT_RETAIN_MODE: ['retainMode', 'string'], + HINDSIGHT_RECALL_BUDGET: ['recallBudget', 'string'], + HINDSIGHT_RECALL_MAX_TOKENS: ['recallMaxTokens', 'int'], + HINDSIGHT_RECALL_MAX_QUERY_CHARS: ['recallMaxQueryChars', 'int'], + HINDSIGHT_RECALL_CONTEXT_TURNS: ['recallContextTurns', 'int'], + HINDSIGHT_DYNAMIC_BANK_ID: ['dynamicBankId', 'bool'], + HINDSIGHT_BANK_MISSION: ['bankMission', 'string'], + HINDSIGHT_DEBUG: ['debug', 'bool'], +}; + +function castEnv(value: string, typ: 'string' | 'bool' | 'int'): string | boolean | number | null { + if (typ === 'bool') return ['true', '1', 'yes'].includes(value.toLowerCase()); + if (typ === 'int') { + const n = parseInt(value, 10); + return isNaN(n) ? null : n; + } + return value; +} + +function loadSettingsFile(path: string): Record { + try { + const raw = readFileSync(path, 'utf-8'); + return JSON.parse(raw); + } catch { + return {}; + } +} + +export function loadConfig(pluginOptions?: Record): HindsightConfig { + // 1. Start with defaults + const config: Record = { ...DEFAULTS }; + + // 2. User config file (~/.hindsight/opencode.json) + const userConfigPath = join(homedir(), '.hindsight', 'opencode.json'); + const fileConfig = loadSettingsFile(userConfigPath); + for (const [key, value] of Object.entries(fileConfig)) { + if (value !== null && value !== undefined) { + config[key] = value; + } + } + + // 3. Plugin options (from opencode.json: ["@vectorize-io/opencode-hindsight", { ... }]) + if (pluginOptions) { + for (const [key, value] of Object.entries(pluginOptions)) { + if (value !== null && value !== undefined) { + config[key] = value; + } + } + } + + // 4. Environment variable overrides (highest priority) + for (const [envName, [key, typ]] of Object.entries(ENV_OVERRIDES)) { + const val = process.env[envName]; + if (val !== undefined) { + const castVal = castEnv(val, typ); + if (castVal !== null) { + config[key] = castVal; + } + } + } + + return config as unknown as HindsightConfig; +} + +export function debugLog(config: HindsightConfig, ...args: unknown[]): void { + if (config.debug) { + console.error('[Hindsight]', ...args); + } +} diff --git a/hindsight-integrations/opencode/src/content.test.ts b/hindsight-integrations/opencode/src/content.test.ts new file mode 100644 index 000000000..ea8505f55 --- /dev/null +++ b/hindsight-integrations/opencode/src/content.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { + stripMemoryTags, + formatMemories, + formatCurrentTime, + composeRecallQuery, + truncateRecallQuery, + sliceLastTurnsByUserBoundary, + prepareRetentionTranscript, +} from './content.js'; + +describe('stripMemoryTags', () => { + it('removes blocks', () => { + const input = 'before secret after'; + expect(stripMemoryTags(input)).toBe('before after'); + }); + + it('removes blocks', () => { + const input = 'before \nmultiline\n after'; + expect(stripMemoryTags(input)).toBe('before after'); + }); + + it('removes multiple blocks', () => { + const input = 'a middle b'; + expect(stripMemoryTags(input)).toBe(' middle '); + }); + + it('returns unchanged if no tags', () => { + expect(stripMemoryTags('hello world')).toBe('hello world'); + }); +}); + +describe('formatMemories', () => { + it('formats recall results with type and date', () => { + const results = [ + { text: 'User likes Python', type: 'world', mentioned_at: '2025-01-01' }, + { text: 'Met at conference', type: 'experience', mentioned_at: '2025-03-15' }, + ]; + const formatted = formatMemories(results); + expect(formatted).toContain('- User likes Python [world] (2025-01-01)'); + expect(formatted).toContain('- Met at conference [experience] (2025-03-15)'); + }); + + it('handles missing type and date', () => { + const results = [{ text: 'Some fact' }]; + expect(formatMemories(results)).toBe('- Some fact'); + }); + + it('returns empty string for empty array', () => { + expect(formatMemories([])).toBe(''); + }); + + it('separates entries with double newlines', () => { + const results = [{ text: 'A' }, { text: 'B' }]; + expect(formatMemories(results)).toBe('- A\n\n- B'); + }); +}); + +describe('formatCurrentTime', () => { + it('returns UTC time in YYYY-MM-DD HH:MM format', () => { + const time = formatCurrentTime(); + expect(time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); +}); + +describe('composeRecallQuery', () => { + const messages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' }, + { role: 'user', content: 'What is my name?' }, + ]; + + it('returns latest query when contextTurns <= 1', () => { + expect(composeRecallQuery('What is my name?', messages, 1)).toBe('What is my name?'); + }); + + it('returns latest query when messages empty', () => { + expect(composeRecallQuery('query', [], 3)).toBe('query'); + }); + + it('includes prior context when contextTurns > 1', () => { + const result = composeRecallQuery('What is my name?', messages, 3); + expect(result).toContain('Prior context:'); + expect(result).toContain('user: Hello'); + expect(result).toContain('assistant: Hi there'); + expect(result).toContain('What is my name?'); + }); + + it('does not duplicate latest query in context', () => { + const result = composeRecallQuery('What is my name?', messages, 3); + // "What is my name?" should appear once at the end, not also as "user: What is my name?" + const matches = result.match(/What is my name\?/g); + expect(matches?.length).toBe(1); + }); +}); + +describe('truncateRecallQuery', () => { + it('returns query unchanged if within limit', () => { + expect(truncateRecallQuery('short', 'short', 100)).toBe('short'); + }); + + it('truncates to latest when no prior context', () => { + const latest = 'my query'; + expect(truncateRecallQuery(latest, latest, 5)).toBe('my qu'); + }); + + it('drops oldest context lines first', () => { + const query = 'Prior context:\n\nuser: old\nassistant: older\nuser: recent\n\nlatest'; + const result = truncateRecallQuery(query, 'latest', 50); + expect(result).toContain('latest'); + // Should have dropped some old context + expect(result.length).toBeLessThanOrEqual(50); + }); +}); + +describe('sliceLastTurnsByUserBoundary', () => { + const messages = [ + { role: 'user', content: 'A' }, + { role: 'assistant', content: 'B' }, + { role: 'user', content: 'C' }, + { role: 'assistant', content: 'D' }, + { role: 'user', content: 'E' }, + ]; + + it('returns last N turns', () => { + const result = sliceLastTurnsByUserBoundary(messages, 2); + expect(result.length).toBe(3); // user:C, assistant:D, user:E + expect(result[0].content).toBe('C'); + }); + + it('returns all messages if turns > available', () => { + const result = sliceLastTurnsByUserBoundary(messages, 10); + expect(result.length).toBe(5); + }); + + it('returns empty for zero turns', () => { + expect(sliceLastTurnsByUserBoundary(messages, 0)).toEqual([]); + }); + + it('returns empty for empty messages', () => { + expect(sliceLastTurnsByUserBoundary([], 2)).toEqual([]); + }); +}); + +describe('prepareRetentionTranscript', () => { + const messages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' }, + { role: 'user', content: 'How are you?' }, + { role: 'assistant', content: 'I am doing well' }, + ]; + + it('retains last turn by default', () => { + const { transcript, messageCount } = prepareRetentionTranscript(messages); + expect(messageCount).toBe(2); + expect(transcript).toContain('[role: user]'); + expect(transcript).toContain('How are you?'); + expect(transcript).toContain('I am doing well'); + expect(transcript).not.toContain('Hello'); + }); + + it('retains full window when requested', () => { + const { transcript, messageCount } = prepareRetentionTranscript(messages, true); + expect(messageCount).toBe(4); + expect(transcript).toContain('Hello'); + expect(transcript).toContain('How are you?'); + }); + + it('returns null for empty messages', () => { + const { transcript, messageCount } = prepareRetentionTranscript([]); + expect(transcript).toBeNull(); + expect(messageCount).toBe(0); + }); + + it('strips memory tags from content', () => { + const msgs = [ + { role: 'user', content: 'Query data' }, + { role: 'assistant', content: 'Response' }, + ]; + const { transcript } = prepareRetentionTranscript(msgs); + expect(transcript).not.toContain('hindsight_memories'); + expect(transcript).toContain('Query'); + }); + + it('skips messages with empty content after stripping', () => { + const msgs = [ + { role: 'user', content: 'only tags' }, + { role: 'assistant', content: 'Response' }, + ]; + const { transcript, messageCount } = prepareRetentionTranscript(msgs, true); + expect(messageCount).toBe(1); // only assistant message + expect(transcript).toContain('Response'); + }); +}); diff --git a/hindsight-integrations/opencode/src/content.ts b/hindsight-integrations/opencode/src/content.ts new file mode 100644 index 000000000..208d6dc54 --- /dev/null +++ b/hindsight-integrations/opencode/src/content.ts @@ -0,0 +1,178 @@ +/** + * Content processing utilities. + * + * Port of the Claude Code plugin's content.py: + * - Memory tag stripping (anti-feedback-loop) + * - Recall query composition and truncation + * - Memory formatting for context injection + * - Retention transcript formatting + */ + +/** Strip and blocks to prevent retain feedback loops. */ +export function stripMemoryTags(content: string): string { + content = content.replace(/[\s\S]*?<\/hindsight_memories>/g, ''); + content = content.replace(/[\s\S]*?<\/relevant_memories>/g, ''); + return content; +} + +export interface RecallResult { + text: string; + type?: string | null; + mentioned_at?: string | null; +} + +/** Format recall results into human-readable text for context injection. */ +export function formatMemories(results: RecallResult[]): string { + if (!results.length) return ''; + return results + .map((r) => { + const typeStr = r.type ? ` [${r.type}]` : ''; + const dateStr = r.mentioned_at ? ` (${r.mentioned_at})` : ''; + return `- ${r.text}${typeStr}${dateStr}`; + }) + .join('\n\n'); +} + +/** Format current UTC time for recall context. */ +export function formatCurrentTime(): string { + const now = new Date(); + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, '0'); + const d = String(now.getUTCDate()).padStart(2, '0'); + const h = String(now.getUTCHours()).padStart(2, '0'); + const min = String(now.getUTCMinutes()).padStart(2, '0'); + return `${y}-${m}-${d} ${h}:${min}`; +} + +export interface Message { + role: string; + content: string; +} + +/** + * Compose a multi-turn recall query from conversation history. + * + * When recallContextTurns > 1, includes prior context above the latest query. + */ +export function composeRecallQuery( + latestQuery: string, + messages: Message[], + recallContextTurns: number, +): string { + const latest = latestQuery.trim(); + if (recallContextTurns <= 1 || !messages.length) return latest; + + const contextual = sliceLastTurnsByUserBoundary(messages, recallContextTurns); + const contextLines: string[] = []; + + for (const msg of contextual) { + const content = stripMemoryTags(msg.content).trim(); + if (!content) continue; + if (msg.role === 'user' && content === latest) continue; + contextLines.push(`${msg.role}: ${content}`); + } + + if (!contextLines.length) return latest; + + return ['Prior context:', contextLines.join('\n'), latest].join('\n\n'); +} + +/** + * Truncate a composed recall query to maxChars. + * Preserves the latest user message, drops oldest context lines first. + */ +export function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string { + if (maxChars <= 0 || query.length <= maxChars) return query; + + const latest = latestQuery.trim(); + const latestOnly = latest.length > maxChars ? latest.slice(0, maxChars) : latest; + + if (!query.includes('Prior context:')) return latestOnly; + + const contextMarker = 'Prior context:\n\n'; + const markerIndex = query.indexOf(contextMarker); + if (markerIndex === -1) return latestOnly; + + const suffix = '\n\n' + latest; + const suffixIndex = query.lastIndexOf(suffix); + if (suffixIndex === -1) return latestOnly; + if (suffix.length >= maxChars) return latestOnly; + + const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex); + const contextLines = contextBody.split('\n').filter(Boolean); + + const kept: string[] = []; + for (let i = contextLines.length - 1; i >= 0; i--) { + kept.unshift(contextLines[i]); + const candidate = `${contextMarker}${kept.join('\n')}${suffix}`; + if (candidate.length > maxChars) { + kept.shift(); + break; + } + } + + if (kept.length) return `${contextMarker}${kept.join('\n')}${suffix}`; + return latestOnly; +} + +/** Slice messages to the last N turns, where a turn starts at a user message. */ +export function sliceLastTurnsByUserBoundary(messages: Message[], turns: number): Message[] { + if (!messages.length || turns <= 0) return []; + + let userTurnsSeen = 0; + let startIndex = -1; + + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'user') { + userTurnsSeen++; + if (userTurnsSeen >= turns) { + startIndex = i; + break; + } + } + } + + return startIndex === -1 ? [...messages] : messages.slice(startIndex); +} + +/** + * Format messages into a retention transcript. + * + * Uses [role: ...]...[role:end] markers for structured retention. + */ +export function prepareRetentionTranscript( + messages: Message[], + retainFullWindow: boolean = false, +): { transcript: string | null; messageCount: number } { + if (!messages.length) return { transcript: null, messageCount: 0 }; + + let targetMessages: Message[]; + if (retainFullWindow) { + targetMessages = messages; + } else { + // Default: retain only the last turn + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'user') { + lastUserIdx = i; + break; + } + } + if (lastUserIdx === -1) return { transcript: null, messageCount: 0 }; + targetMessages = messages.slice(lastUserIdx); + } + + const parts: string[] = []; + for (const msg of targetMessages) { + const content = stripMemoryTags(msg.content).trim(); + if (!content) continue; + parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`); + } + + if (!parts.length) return { transcript: null, messageCount: 0 }; + + const transcript = parts.join('\n\n'); + if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 }; + + return { transcript, messageCount: parts.length }; +} diff --git a/hindsight-integrations/opencode/src/hooks.test.ts b/hindsight-integrations/opencode/src/hooks.test.ts new file mode 100644 index 000000000..7cd2f6594 --- /dev/null +++ b/hindsight-integrations/opencode/src/hooks.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHooks, type PluginState } from './hooks.js'; +import type { HindsightConfig } from './config.js'; + +function makeConfig(overrides: Partial = {}): HindsightConfig { + return { + autoRecall: true, + recallBudget: 'mid', + recallMaxTokens: 1024, + recallTypes: ['world', 'experience'], + recallContextTurns: 1, + recallMaxQueryChars: 800, + recallPromptPreamble: 'Relevant memories:', + autoRetain: true, + retainMode: 'full-session', + retainEveryNTurns: 1, + retainOverlapTurns: 2, + retainContext: 'opencode', + retainTags: [], + retainMetadata: {}, + hindsightApiUrl: 'http://localhost:8888', + hindsightApiToken: null, + bankId: null, + bankIdPrefix: '', + dynamicBankId: false, + dynamicBankGranularity: ['agent', 'project'], + bankMission: '', + retainMission: null, + agentName: 'opencode', + debug: false, + ...overrides, + }; +} + +function makeState(): PluginState { + return { + turnCount: 0, + missionsSet: new Set(), + recalledSessions: new Set(), + lastRetainedTurn: new Map(), + }; +} + +function makeClient() { + return { + retain: vi.fn().mockResolvedValue({}), + recall: vi.fn().mockResolvedValue({ results: [] }), + reflect: vi.fn().mockResolvedValue({ text: '' }), + createBank: vi.fn().mockResolvedValue({}), + } as any; +} + +function makeOpencodeClient(messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }> = []) { + return { + session: { + messages: vi.fn().mockResolvedValue({ data: messages }), + }, + }; +} + +describe('createHooks', () => { + it('returns all required hooks', () => { + const hooks = createHooks(makeClient(), 'bank', makeConfig(), makeState(), makeOpencodeClient()); + expect(hooks.event).toBeDefined(); + expect(hooks['experimental.session.compacting']).toBeDefined(); + expect(hooks['experimental.chat.system.transform']).toBeDefined(); + }); +}); + +describe('event hook — session.idle', () => { + it('auto-retains conversation on session.idle with document_id', async () => { + const client = makeClient(); + const messages = [ + { role: 'user', parts: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', parts: [{ type: 'text', text: 'Hi there' }] }, + ]; + const opencodeClient = makeOpencodeClient(messages); + const state = makeState(); + const hooks = createHooks(client, 'bank', makeConfig(), state, opencodeClient); + + await hooks.event({ + event: { type: 'session.idle', properties: { sessionID: 'sess-1' } }, + }); + + expect(client.retain).toHaveBeenCalledTimes(1); + expect(client.retain.mock.calls[0][0]).toBe('bank'); + // Full-session mode uses session ID as document_id + const opts = client.retain.mock.calls[0][2]; + expect(opts.documentId).toBe('sess-1'); + expect(opts.metadata.session_id).toBe('sess-1'); + }); + + it('skips retain when autoRetain is false', async () => { + const client = makeClient(); + const messages = [ + { role: 'user', parts: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] }, + ]; + const hooks = createHooks( + client, + 'bank', + makeConfig({ autoRetain: false }), + makeState(), + makeOpencodeClient(messages), + ); + + await hooks.event({ + event: { type: 'session.idle', properties: { sessionID: 'sess-1' } }, + }); + + expect(client.retain).not.toHaveBeenCalled(); + }); + + it('uses chunked document_id with overlap in last-turn mode', async () => { + const client = makeClient(); + const messages = [ + { role: 'user', parts: [{ type: 'text', text: 'Turn 1' }] }, + { role: 'assistant', parts: [{ type: 'text', text: 'Reply 1' }] }, + { role: 'user', parts: [{ type: 'text', text: 'Turn 2' }] }, + { role: 'assistant', parts: [{ type: 'text', text: 'Reply 2' }] }, + ]; + const config = makeConfig({ retainMode: 'last-turn', retainEveryNTurns: 1, retainOverlapTurns: 1 }); + const state = makeState(); + const hooks = createHooks(client, 'bank', config, state, makeOpencodeClient(messages)); + + await hooks.event({ + event: { type: 'session.idle', properties: { sessionID: 'sess-1' } }, + }); + + expect(client.retain).toHaveBeenCalledTimes(1); + const opts = client.retain.mock.calls[0][2]; + // Chunked mode uses session-timestamp format + expect(opts.documentId).toMatch(/^sess-1-\d+$/); + }); + + it('respects retainEveryNTurns', async () => { + const client = makeClient(); + const messages = [ + { role: 'user', parts: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] }, + ]; + const config = makeConfig({ retainEveryNTurns: 5 }); + const state = makeState(); + const hooks = createHooks(client, 'bank', config, state, makeOpencodeClient(messages)); + + await hooks.event({ + event: { type: 'session.idle', properties: { sessionID: 'sess-1' } }, + }); + + // Only 1 user turn, needs 5 — should not retain + expect(client.retain).not.toHaveBeenCalled(); + }); + + it('does not throw on client error', async () => { + const client = makeClient(); + client.retain.mockRejectedValue(new Error('Network error')); + const messages = [ + { role: 'user', parts: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] }, + ]; + const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages)); + + await expect( + hooks.event({ + event: { type: 'session.idle', properties: { sessionID: 'sess-1' } }, + }), + ).resolves.not.toThrow(); + }); +}); + +describe('event hook — session.created', () => { + it('tracks session for recall injection', async () => { + const state = makeState(); + const hooks = createHooks(makeClient(), 'bank', makeConfig(), state, makeOpencodeClient()); + + await hooks.event({ + event: { + type: 'session.created', + properties: { info: { id: 'sess-1', title: 'Test' } }, + }, + }); + + expect(state.recalledSessions.has('sess-1')).toBe(true); + }); + + it('does not track when autoRecall is false', async () => { + const state = makeState(); + const hooks = createHooks( + makeClient(), + 'bank', + makeConfig({ autoRecall: false }), + state, + makeOpencodeClient(), + ); + + await hooks.event({ + event: { + type: 'session.created', + properties: { info: { id: 'sess-1' } }, + }, + }); + + expect(state.recalledSessions.has('sess-1')).toBe(false); + }); +}); + +describe('compacting hook', () => { + it('retains before compaction and recalls context', async () => { + const client = makeClient(); + client.recall.mockResolvedValue({ + results: [{ text: 'Important fact', type: 'world' }], + }); + const messages = [ + { role: 'user', parts: [{ type: 'text', text: 'Build the feature' }] }, + { role: 'assistant', parts: [{ type: 'text', text: 'Working on it' }] }, + ]; + const output = { context: [] as string[], prompt: undefined }; + const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages)); + + await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output); + + // Should have retained and recalled + expect(client.retain).toHaveBeenCalled(); + expect(client.recall).toHaveBeenCalled(); + expect(output.context.length).toBeGreaterThan(0); + expect(output.context[0]).toContain('hindsight_memories'); + expect(output.context[0]).toContain('Important fact'); + }); + + it('does not throw on error', async () => { + const client = makeClient(); + client.recall.mockRejectedValue(new Error('Failed')); + const messages = [ + { role: 'user', parts: [{ type: 'text', text: 'Test' }] }, + ]; + const output = { context: [] as string[] }; + const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages)); + + await expect( + hooks['experimental.session.compacting']({ sessionID: 's' }, output), + ).resolves.not.toThrow(); + }); +}); + +describe('system transform hook', () => { + it('injects memories for tracked sessions', async () => { + const client = makeClient(); + client.recall.mockResolvedValue({ + results: [{ text: 'User is a developer', type: 'world' }], + }); + const state = makeState(); + state.recalledSessions.add('sess-1'); + const output = { system: [] as string[] }; + const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient()); + + await hooks['experimental.chat.system.transform']( + { sessionID: 'sess-1', model: {} }, + output, + ); + + expect(output.system.length).toBeGreaterThan(0); + expect(output.system[0]).toContain('hindsight_memories'); + // Session should be removed after first injection + expect(state.recalledSessions.has('sess-1')).toBe(false); + }); + + it('skips untracked sessions', async () => { + const client = makeClient(); + const state = makeState(); + const output = { system: [] as string[] }; + const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient()); + + await hooks['experimental.chat.system.transform']( + { sessionID: 'sess-unknown', model: {} }, + output, + ); + + expect(output.system.length).toBe(0); + expect(client.recall).not.toHaveBeenCalled(); + }); + + it('skips when autoRecall is false', async () => { + const client = makeClient(); + const state = makeState(); + state.recalledSessions.add('sess-1'); + const output = { system: [] as string[] }; + const hooks = createHooks( + client, + 'bank', + makeConfig({ autoRecall: false }), + state, + makeOpencodeClient(), + ); + + await hooks['experimental.chat.system.transform']( + { sessionID: 'sess-1', model: {} }, + output, + ); + + expect(output.system.length).toBe(0); + }); +}); diff --git a/hindsight-integrations/opencode/src/hooks.ts b/hindsight-integrations/opencode/src/hooks.ts new file mode 100644 index 000000000..896c87ece --- /dev/null +++ b/hindsight-integrations/opencode/src/hooks.ts @@ -0,0 +1,296 @@ +/** + * Hook implementations for the Hindsight OpenCode plugin. + * + * Hooks: + * - event (session.created) → recall memories and inject into system prompt + * - event (session.idle) → auto-retain conversation transcript + * - experimental.session.compacting → inject memories into compaction context + */ + +import type { HindsightClient } from '@vectorize-io/hindsight-client'; +import type { HindsightConfig } from './config.js'; +import { debugLog } from './config.js'; +import { + formatMemories, + formatCurrentTime, + stripMemoryTags, + composeRecallQuery, + truncateRecallQuery, + prepareRetentionTranscript, + sliceLastTurnsByUserBoundary, + type Message, +} from './content.js'; +import { ensureBankMission } from './bank.js'; + +export interface PluginState { + turnCount: number; + missionsSet: Set; + /** Track sessions we've already injected recall into */ + recalledSessions: Set; + /** Track last retained turn count per session to avoid duplicates */ + lastRetainedTurn: Map; +} + +interface EventInput { + event: { + type: string; + properties: Record; + }; +} + +interface CompactingInput { + sessionID: string; +} + +interface CompactingOutput { + context: string[]; + prompt?: string; +} + +interface SystemTransformInput { + sessionID?: string; + model: unknown; +} + +interface SystemTransformOutput { + system: string[]; +} + +type OpencodeClient = { + session: { + messages: (opts: { path: { id: string } }) => Promise<{ data?: Array<{ role: string; parts?: Array<{ type: string; text?: string }> }> }>; + }; +}; + +export interface HindsightHooks { + event: (input: EventInput) => Promise; + 'experimental.session.compacting': ( + input: CompactingInput, + output: CompactingOutput, + ) => Promise; + 'experimental.chat.system.transform': ( + input: SystemTransformInput, + output: SystemTransformOutput, + ) => Promise; +} + +export function createHooks( + hindsightClient: HindsightClient, + bankId: string, + config: HindsightConfig, + state: PluginState, + opencodeClient: OpencodeClient, +): HindsightHooks { + /** Recall memories and format as context string */ + async function recallForContext(query: string): Promise { + try { + const response = await hindsightClient.recall(bankId, query, { + budget: config.recallBudget as 'low' | 'mid' | 'high', + maxTokens: config.recallMaxTokens, + types: config.recallTypes, + }); + + const results = response.results || []; + if (!results.length) return null; + + const formatted = formatMemories(results); + return ( + `\n` + + `${config.recallPromptPreamble}\n` + + `Current time: ${formatCurrentTime()} UTC\n\n` + + `${formatted}\n` + + `` + ); + } catch (e) { + debugLog(config, 'Recall failed:', e); + return null; + } + } + + /** Extract plain-text messages from an OpenCode session */ + async function getSessionMessages(sessionId: string): Promise { + try { + const response = await opencodeClient.session.messages({ + path: { id: sessionId }, + }); + const rawMessages = response.data || []; + const messages: Message[] = []; + for (const msg of rawMessages) { + const role = msg.role; + if (role !== 'user' && role !== 'assistant') continue; + const textParts = (msg.parts || []) + .filter((p: { type: string; text?: string }) => p.type === 'text' && p.text) + .map((p: { type: string; text?: string }) => p.text!); + if (textParts.length) { + messages.push({ role, content: textParts.join('\n') }); + } + } + return messages; + } catch (e) { + debugLog(config, 'Failed to get session messages:', e); + return []; + } + } + + /** Auto-retain conversation transcript */ + async function handleSessionIdle(sessionId: string): Promise { + if (!config.autoRetain) return; + + const messages = await getSessionMessages(sessionId); + if (!messages.length) return; + + // Count user turns + const userTurns = messages.filter((m) => m.role === 'user').length; + const lastRetained = state.lastRetainedTurn.get(sessionId) || 0; + + // Only retain if enough new turns since last retain + if (userTurns - lastRetained < config.retainEveryNTurns) return; + + // Determine retention window and document ID + const retainFullWindow = config.retainMode === 'full-session'; + let targetMessages: Message[]; + let documentId: string; + + if (retainFullWindow) { + targetMessages = messages; + // Full-session upserts the same document each time + documentId = sessionId; + } else { + // Sliding window: retainEveryNTurns + overlap + const windowTurns = config.retainEveryNTurns + config.retainOverlapTurns; + targetMessages = sliceLastTurnsByUserBoundary(messages, windowTurns); + // Chunked mode: unique document per chunk + documentId = `${sessionId}-${Date.now()}`; + } + + const { transcript } = prepareRetentionTranscript(targetMessages, true); + if (!transcript) return; + + try { + await ensureBankMission(hindsightClient, bankId, config, state.missionsSet); + await hindsightClient.retain(bankId, transcript, { + documentId, + context: config.retainContext, + tags: config.retainTags.length ? config.retainTags : undefined, + metadata: Object.keys(config.retainMetadata).length + ? { ...config.retainMetadata, session_id: sessionId } + : { session_id: sessionId }, + async: true, + }); + state.lastRetainedTurn.set(sessionId, userTurns); + debugLog(config, `Auto-retained ${messages.length} messages for session ${sessionId}`); + } catch (e) { + debugLog(config, 'Auto-retain failed:', e); + } + } + + const event = async (input: EventInput): Promise => { + try { + const { event: evt } = input; + + if (evt.type === 'session.idle') { + const sessionId = (evt.properties as { sessionID?: string }).sessionID; + if (sessionId) { + await handleSessionIdle(sessionId); + } + } + + if (evt.type === 'session.created') { + const session = evt.properties.info as { id?: string; title?: string } | undefined; + const sessionId = session?.id; + if (sessionId && config.autoRecall && !state.recalledSessions.has(sessionId)) { + state.recalledSessions.add(sessionId); + // Cap tracked sessions + if (state.recalledSessions.size > 1000) { + const first = state.recalledSessions.values().next().value; + if (first) state.recalledSessions.delete(first); + } + } + } + } catch (e) { + debugLog(config, 'Event hook error:', e); + } + }; + + const compacting = async ( + input: CompactingInput, + output: CompactingOutput, + ): Promise => { + try { + // First, retain what we have before compaction + const messages = await getSessionMessages(input.sessionID); + if (messages.length && config.autoRetain) { + const { transcript } = prepareRetentionTranscript(messages, true); + if (transcript) { + try { + await hindsightClient.retain(bankId, transcript, { + context: config.retainContext, + tags: config.retainTags.length ? config.retainTags : undefined, + async: true, + }); + debugLog(config, 'Pre-compaction retain completed'); + } catch (e) { + debugLog(config, 'Pre-compaction retain failed:', e); + } + } + } + + // Then recall relevant memories to inject into compaction context + if (messages.length) { + const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); + if (lastUserMsg) { + const query = composeRecallQuery( + lastUserMsg.content, + messages, + config.recallContextTurns, + ); + const truncated = truncateRecallQuery( + query, + lastUserMsg.content, + config.recallMaxQueryChars, + ); + const context = await recallForContext(truncated); + if (context) { + output.context.push(context); + } + } + } + } catch (e) { + debugLog(config, 'Compaction hook error:', e); + } + }; + + const systemTransform = async ( + input: SystemTransformInput, + output: SystemTransformOutput, + ): Promise => { + try { + if (!config.autoRecall) return; + const sessionId = input.sessionID; + if (!sessionId) return; + + // Only inject on first message of a session (tracked by recalledSessions) + if (!state.recalledSessions.has(sessionId)) return; + // Remove so we only do this once per session + state.recalledSessions.delete(sessionId); + + await ensureBankMission(hindsightClient, bankId, config, state.missionsSet); + + // Use a generic project-context query for session start + const query = `project context and recent work`; + const context = await recallForContext(query); + if (context) { + output.system.push(context); + debugLog(config, `Injected recall context for session ${sessionId}`); + } + } catch (e) { + debugLog(config, 'System transform hook error:', e); + } + }; + + return { + event, + 'experimental.session.compacting': compacting, + 'experimental.chat.system.transform': systemTransform, + }; +} diff --git a/hindsight-integrations/opencode/src/index.ts b/hindsight-integrations/opencode/src/index.ts new file mode 100644 index 000000000..1f47af303 --- /dev/null +++ b/hindsight-integrations/opencode/src/index.ts @@ -0,0 +1,80 @@ +/** + * Hindsight OpenCode Plugin — persistent long-term memory for OpenCode agents. + * + * Provides: + * - Custom tools: hindsight_retain, hindsight_recall, hindsight_reflect + * - Auto-retain on session.idle + * - Memory injection on session.created via system transform + * - Memory preservation during context compaction + * + * @example + * ```json + * // opencode.json + * { "plugin": ["@vectorize-io/opencode-hindsight"] } + * + * // With options: + * { "plugin": [["@vectorize-io/opencode-hindsight", { "bankId": "my-bank" }]] } + * ``` + */ + +import type { Plugin, PluginModule } from '@opencode-ai/plugin'; +import { HindsightClient } from '@vectorize-io/hindsight-client'; +import { loadConfig } from './config.js'; +import { deriveBankId } from './bank.js'; +import { createTools } from './tools.js'; +import { createHooks, type PluginState } from './hooks.js'; +import { debugLog } from './config.js'; + +const HindsightPlugin: Plugin = async (input, options) => { + const config = loadConfig(options); + + const apiUrl = config.hindsightApiUrl; + if (!apiUrl) { + console.error( + '[Hindsight] No API URL configured. Set HINDSIGHT_API_URL environment variable ' + + 'or add hindsightApiUrl to ~/.hindsight/opencode.json', + ); + // Return empty hooks — graceful degradation + return {}; + } + + const client = new HindsightClient({ + baseUrl: apiUrl, + apiKey: config.hindsightApiToken || undefined, + }); + + const bankId = deriveBankId(config, input.directory); + debugLog(config, `Initialized with bank: ${bankId}, API: ${apiUrl}`); + + const state: PluginState = { + turnCount: 0, + missionsSet: new Set(), + recalledSessions: new Set(), + lastRetainedTurn: new Map(), + }; + + const tools = createTools(client, bankId, config); + const hooks = createHooks(client, bankId, config, state, input.client as unknown as Parameters[4]); + + return { + tool: tools, + ...hooks, + }; +}; + +// Named export for direct import +export { HindsightPlugin }; + +// Default export as PluginModule for OpenCode plugin loader +const module: PluginModule = { + id: 'hindsight', + server: HindsightPlugin, +}; + +export default module; + +// Re-export types for consumers +export type { HindsightConfig } from './config.js'; +export type { PluginState } from './hooks.js'; +export { loadConfig } from './config.js'; +export { deriveBankId } from './bank.js'; diff --git a/hindsight-integrations/opencode/src/plugin.test.ts b/hindsight-integrations/opencode/src/plugin.test.ts new file mode 100644 index 000000000..77ef3c6d2 --- /dev/null +++ b/hindsight-integrations/opencode/src/plugin.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the HindsightClient before importing the plugin +vi.mock('@vectorize-io/hindsight-client', () => { + const MockHindsightClient = vi.fn(function (this: any) { + this.retain = vi.fn().mockResolvedValue({}); + this.recall = vi.fn().mockResolvedValue({ results: [] }); + this.reflect = vi.fn().mockResolvedValue({ text: '' }); + this.createBank = vi.fn().mockResolvedValue({}); + }); + return { HindsightClient: MockHindsightClient }; +}); + +import { HindsightPlugin } from './index.js'; +import { HindsightClient } from '@vectorize-io/hindsight-client'; + +const mockPluginInput = { + client: { + session: { + messages: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + project: { id: 'test-project', worktree: '/tmp/test', vcs: 'git' }, + directory: '/tmp/test-project', + worktree: '/tmp/test-project', + serverUrl: new URL('http://localhost:3000'), + $: {} as any, +}; + +describe('HindsightPlugin', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('HINDSIGHT_')) delete process.env[key]; + } + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns empty hooks when no API URL configured', async () => { + const result = await HindsightPlugin(mockPluginInput as any); + expect(result).toEqual({}); + expect(HindsightClient).not.toHaveBeenCalled(); + }); + + it('returns tools and hooks when configured', async () => { + process.env.HINDSIGHT_API_URL = 'http://localhost:8888'; + + const result = await HindsightPlugin(mockPluginInput as any); + + expect(HindsightClient).toHaveBeenCalledWith({ + baseUrl: 'http://localhost:8888', + apiKey: undefined, + }); + + expect(result.tool).toBeDefined(); + expect(result.tool!.hindsight_retain).toBeDefined(); + expect(result.tool!.hindsight_recall).toBeDefined(); + expect(result.tool!.hindsight_reflect).toBeDefined(); + expect(result.event).toBeDefined(); + expect(result['experimental.session.compacting']).toBeDefined(); + expect(result['experimental.chat.system.transform']).toBeDefined(); + }); + + it('passes API key when configured', async () => { + process.env.HINDSIGHT_API_URL = 'http://localhost:8888'; + process.env.HINDSIGHT_API_TOKEN = 'my-token'; + + await HindsightPlugin(mockPluginInput as any); + + expect(HindsightClient).toHaveBeenCalledWith({ + baseUrl: 'http://localhost:8888', + apiKey: 'my-token', + }); + }); + + it('accepts plugin options', async () => { + const result = await HindsightPlugin(mockPluginInput as any, { + hindsightApiUrl: 'http://example.com', + bankId: 'custom-bank', + }); + + expect(result.tool).toBeDefined(); + expect(HindsightClient).toHaveBeenCalledWith({ + baseUrl: 'http://example.com', + apiKey: undefined, + }); + }); +}); + +describe('PluginModule default export', () => { + it('exports correct module shape', async () => { + const mod = await import('./index.js'); + expect(mod.default).toBeDefined(); + expect(mod.default.id).toBe('hindsight'); + expect(typeof mod.default.server).toBe('function'); + }); +}); diff --git a/hindsight-integrations/opencode/src/tools.test.ts b/hindsight-integrations/opencode/src/tools.test.ts new file mode 100644 index 000000000..93e8dfb42 --- /dev/null +++ b/hindsight-integrations/opencode/src/tools.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createTools } from './tools.js'; +import type { HindsightConfig } from './config.js'; + +function makeConfig(overrides: Partial = {}): HindsightConfig { + return { + autoRecall: true, + recallBudget: 'mid', + recallMaxTokens: 1024, + recallTypes: ['world', 'experience'], + recallContextTurns: 1, + recallMaxQueryChars: 800, + recallPromptPreamble: '', + autoRetain: true, + retainMode: 'full-session', + retainEveryNTurns: 10, + retainOverlapTurns: 2, + retainContext: 'opencode', + retainTags: [], + retainMetadata: {}, + hindsightApiUrl: null, + hindsightApiToken: null, + bankId: null, + bankIdPrefix: '', + dynamicBankId: false, + dynamicBankGranularity: ['agent', 'project'], + bankMission: '', + retainMission: null, + agentName: 'opencode', + debug: false, + ...overrides, + }; +} + +const mockContext = { + sessionID: 'sess-1', + messageID: 'msg-1', + agent: 'default', + directory: '/tmp', + worktree: '/tmp', + abort: new AbortController().signal, + metadata: vi.fn(), + ask: vi.fn(), +}; + +describe('createTools', () => { + it('creates all three tools', () => { + const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + expect(tools.hindsight_retain).toBeDefined(); + expect(tools.hindsight_recall).toBeDefined(); + expect(tools.hindsight_reflect).toBeDefined(); + }); + + it('all tools have description and execute', () => { + const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + for (const tool of Object.values(tools)) { + expect(tool.description).toBeTruthy(); + expect(typeof tool.execute).toBe('function'); + } + }); + + describe('hindsight_retain', () => { + it('calls client.retain with correct bank and content', async () => { + const client = { + retain: vi.fn().mockResolvedValue({}), + recall: vi.fn(), + reflect: vi.fn(), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + const result = await tools.hindsight_retain.execute( + { content: 'User likes TypeScript' }, + mockContext, + ); + + expect(client.retain).toHaveBeenCalledWith('test-bank', 'User likes TypeScript', { + context: 'opencode', + tags: undefined, + metadata: undefined, + }); + expect(result).toBe('Memory stored successfully.'); + }); + + it('passes optional context', async () => { + const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn() } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + await tools.hindsight_retain.execute( + { content: 'Fact', context: 'from conversation' }, + mockContext, + ); + + expect(client.retain).toHaveBeenCalledWith('test-bank', 'Fact', { + context: 'from conversation', + tags: undefined, + metadata: undefined, + }); + }); + + it('includes tags and metadata from config', async () => { + const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn() } as any; + const config = makeConfig({ + retainTags: ['coding'], + retainMetadata: { source: 'opencode' }, + }); + const tools = createTools(client, 'test-bank', config); + + await tools.hindsight_retain.execute({ content: 'Fact' }, mockContext); + + expect(client.retain).toHaveBeenCalledWith('test-bank', 'Fact', { + context: 'opencode', + tags: ['coding'], + metadata: { source: 'opencode' }, + }); + }); + }); + + describe('hindsight_recall', () => { + it('calls client.recall and formats results', async () => { + const client = { + retain: vi.fn(), + recall: vi.fn().mockResolvedValue({ + results: [ + { text: 'User likes Python', type: 'world', mentioned_at: '2025-01-01' }, + ], + }), + reflect: vi.fn(), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + const result = await tools.hindsight_recall.execute( + { query: 'user preferences' }, + mockContext, + ); + + expect(client.recall).toHaveBeenCalledWith('test-bank', 'user preferences', { + budget: 'mid', + maxTokens: 1024, + types: ['world', 'experience'], + }); + expect(result).toContain('User likes Python'); + expect(result).toContain('[world]'); + }); + + it('returns no-results message when empty', async () => { + const client = { + retain: vi.fn(), + recall: vi.fn().mockResolvedValue({ results: [] }), + reflect: vi.fn(), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + const result = await tools.hindsight_recall.execute({ query: 'unknown' }, mockContext); + expect(result).toBe('No relevant memories found.'); + }); + + it('uses config budget settings', async () => { + const client = { + retain: vi.fn(), + recall: vi.fn().mockResolvedValue({ results: [] }), + reflect: vi.fn(), + } as any; + const config = makeConfig({ recallBudget: 'high', recallMaxTokens: 4096 }); + const tools = createTools(client, 'test-bank', config); + + await tools.hindsight_recall.execute({ query: 'test' }, mockContext); + + expect(client.recall).toHaveBeenCalledWith('test-bank', 'test', { + budget: 'high', + maxTokens: 4096, + types: ['world', 'experience'], + }); + }); + }); + + describe('hindsight_reflect', () => { + it('calls client.reflect and returns text', async () => { + const client = { + retain: vi.fn(), + recall: vi.fn(), + reflect: vi.fn().mockResolvedValue({ text: 'The user is a Python developer.' }), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + const result = await tools.hindsight_reflect.execute( + { query: 'What do I know about this user?' }, + mockContext, + ); + + expect(client.reflect).toHaveBeenCalledWith( + 'test-bank', + 'What do I know about this user?', + { context: undefined, budget: 'mid' }, + ); + expect(result).toBe('The user is a Python developer.'); + }); + + it('returns fallback when no text', async () => { + const client = { + retain: vi.fn(), + recall: vi.fn(), + reflect: vi.fn().mockResolvedValue({ text: '' }), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + const result = await tools.hindsight_reflect.execute( + { query: 'something' }, + mockContext, + ); + expect(result).toBe('No relevant information found to reflect on.'); + }); + + it('passes context to reflect', async () => { + const client = { + retain: vi.fn(), + recall: vi.fn(), + reflect: vi.fn().mockResolvedValue({ text: 'Answer' }), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + await tools.hindsight_reflect.execute( + { query: 'Q', context: 'We are building an app' }, + mockContext, + ); + + expect(client.reflect).toHaveBeenCalledWith('test-bank', 'Q', { + context: 'We are building an app', + budget: 'mid', + }); + }); + }); + + describe('error propagation', () => { + it('propagates retain errors', async () => { + const client = { + retain: vi.fn().mockRejectedValue(new Error('Network error')), + recall: vi.fn(), + reflect: vi.fn(), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + await expect( + tools.hindsight_retain.execute({ content: 'test' }, mockContext), + ).rejects.toThrow('Network error'); + }); + + it('propagates recall errors', async () => { + const client = { + retain: vi.fn(), + recall: vi.fn().mockRejectedValue(new Error('Timeout')), + reflect: vi.fn(), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + await expect( + tools.hindsight_recall.execute({ query: 'test' }, mockContext), + ).rejects.toThrow('Timeout'); + }); + + it('propagates reflect errors', async () => { + const client = { + retain: vi.fn(), + recall: vi.fn(), + reflect: vi.fn().mockRejectedValue(new Error('Server error')), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + await expect( + tools.hindsight_reflect.execute({ query: 'test' }, mockContext), + ).rejects.toThrow('Server error'); + }); + }); + + it('always uses constructor bankId', async () => { + const client = { + retain: vi.fn().mockResolvedValue({}), + recall: vi.fn().mockResolvedValue({ results: [] }), + reflect: vi.fn().mockResolvedValue({ text: 'ok' }), + } as any; + const tools = createTools(client, 'fixed-bank', makeConfig()); + + await tools.hindsight_retain.execute({ content: 'x' }, mockContext); + await tools.hindsight_recall.execute({ query: 'x' }, mockContext); + await tools.hindsight_reflect.execute({ query: 'x' }, mockContext); + + expect(client.retain.mock.calls[0][0]).toBe('fixed-bank'); + expect(client.recall.mock.calls[0][0]).toBe('fixed-bank'); + expect(client.reflect.mock.calls[0][0]).toBe('fixed-bank'); + }); +}); diff --git a/hindsight-integrations/opencode/src/tools.ts b/hindsight-integrations/opencode/src/tools.ts new file mode 100644 index 000000000..d102201f1 --- /dev/null +++ b/hindsight-integrations/opencode/src/tools.ts @@ -0,0 +1,101 @@ +/** + * Custom tool definitions for the Hindsight OpenCode plugin. + * + * Registers hindsight_retain, hindsight_recall, and hindsight_reflect + * as tools the agent can call explicitly. + */ + +import { tool } from '@opencode-ai/plugin/tool'; +import type { ToolDefinition } from '@opencode-ai/plugin/tool'; +import type { HindsightClient } from '@vectorize-io/hindsight-client'; +import type { HindsightConfig } from './config.js'; +import { formatMemories, formatCurrentTime } from './content.js'; + +export interface HindsightTools { + hindsight_retain: ToolDefinition; + hindsight_recall: ToolDefinition; + hindsight_reflect: ToolDefinition; +} + +export function createTools( + client: HindsightClient, + bankId: string, + config: HindsightConfig, +): HindsightTools { + const hindsight_retain = tool({ + description: + 'Store information in long-term memory. Use this to remember important facts, ' + + 'user preferences, project context, decisions, and anything worth recalling in future sessions. ' + + 'Be specific — include who, what, when, and why.', + args: { + content: tool.schema.string().describe( + 'The information to remember. Be specific and self-contained.', + ), + context: tool.schema + .string() + .optional() + .describe('Optional context about where this information came from.'), + }, + async execute(args) { + await client.retain(bankId, args.content, { + context: args.context || config.retainContext, + tags: config.retainTags.length ? config.retainTags : undefined, + metadata: Object.keys(config.retainMetadata).length + ? config.retainMetadata + : undefined, + }); + return 'Memory stored successfully.'; + }, + }); + + const hindsight_recall = tool({ + description: + 'Search long-term memory for relevant information. Use this proactively before ' + + 'answering questions about past conversations, user preferences, project history, ' + + 'or any topic where prior context would help. When in doubt, recall first.', + args: { + query: tool.schema.string().describe( + 'Natural language search query. Be specific about what you need to know.', + ), + }, + async execute(args) { + const response = await client.recall(bankId, args.query, { + budget: config.recallBudget as 'low' | 'mid' | 'high', + maxTokens: config.recallMaxTokens, + types: config.recallTypes, + }); + + const results = response.results || []; + if (!results.length) return 'No relevant memories found.'; + + const formatted = formatMemories(results); + return `Found ${results.length} relevant memories (as of ${formatCurrentTime()} UTC):\n\n${formatted}`; + }, + }); + + const hindsight_reflect = tool({ + description: + 'Generate a thoughtful answer using long-term memory. Unlike recall (which returns ' + + 'raw memories), reflect synthesizes memories into a coherent answer. Use for questions ' + + 'like "What do you know about this user?" or "Summarize our project decisions."', + args: { + query: tool.schema.string().describe( + 'The question to answer using long-term memory.', + ), + context: tool.schema + .string() + .optional() + .describe('Optional additional context to guide the reflection.'), + }, + async execute(args) { + const response = await client.reflect(bankId, args.query, { + context: args.context, + budget: config.recallBudget as 'low' | 'mid' | 'high', + }); + + return response.text || 'No relevant information found to reflect on.'; + }, + }); + + return { hindsight_retain, hindsight_recall, hindsight_reflect }; +} diff --git a/hindsight-integrations/opencode/tsconfig.json b/hindsight-integrations/opencode/tsconfig.json new file mode 100644 index 000000000..518d02778 --- /dev/null +++ b/hindsight-integrations/opencode/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/hindsight-integrations/opencode/tsup.config.ts b/hindsight-integrations/opencode/tsup.config.ts new file mode 100644 index 000000000..c536a42d4 --- /dev/null +++ b/hindsight-integrations/opencode/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + outDir: 'dist', + clean: true, + sourcemap: true, + bundle: true, +}); diff --git a/hindsight-integrations/opencode/vitest.config.ts b/hindsight-integrations/opencode/vitest.config.ts new file mode 100644 index 000000000..7dd13254e --- /dev/null +++ b/hindsight-integrations/opencode/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/scripts/release-integration.sh b/scripts/release-integration.sh index d5251558b..2ab814c33 100755 --- a/scripts/release-integration.sh +++ b/scripts/release-integration.sh @@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; } print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } print_error() { echo -e "${RED}[ERROR]${NC} $1"; } -VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen") +VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen" "opencode") usage() { print_error "Usage: $0 " From f79c1b8d09f9644e4707bc706161f60ce3b49679 Mon Sep 17 00:00:00 2001 From: DK09876 Date: Thu, 2 Apr 2026 22:12:28 -0700 Subject: [PATCH 2/4] fix: address review findings for opencode integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Pre-compaction retain now uses shared retainSession() helper, respecting retainMode, documentId, and session_id metadata consistently with idle-retain (was bypassing retention policy). 2. System transform recall is only consumed after successful injection. If Hindsight is briefly unavailable, the plugin retries on the next LLM call instead of permanently skipping recall for the session. 3. Config validation for retainMode and recallBudget — typos like "full_session" or "maximum" now log a warning and fall back to the default instead of silently changing retention semantics. 85 tests (6 new covering compaction documentId, recall retry, and config validation). Co-Authored-By: Claude Opus 4.6 --- .../opencode/src/config.test.ts | 25 ++++++ hindsight-integrations/opencode/src/config.ts | 23 +++++- .../opencode/src/hooks.test.ts | 66 +++++++++++++++ hindsight-integrations/opencode/src/hooks.ts | 80 +++++++++---------- 4 files changed, 153 insertions(+), 41 deletions(-) diff --git a/hindsight-integrations/opencode/src/config.test.ts b/hindsight-integrations/opencode/src/config.test.ts index 13c6f1ae3..e2aafa82f 100644 --- a/hindsight-integrations/opencode/src/config.test.ts +++ b/hindsight-integrations/opencode/src/config.test.ts @@ -99,4 +99,29 @@ describe('loadConfig', () => { expect(config.bankId).toBeNull(); // stays default null expect(config.debug).toBe(false); // stays default }); + + it('invalid retainMode falls back to full-session with warning', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const config = loadConfig({ retainMode: 'full_session' }); + expect(config.retainMode).toBe('full-session'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('Unknown retainMode')); + spy.mockRestore(); + }); + + it('invalid recallBudget falls back to mid with warning', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const config = loadConfig({ recallBudget: 'maximum' }); + expect(config.recallBudget).toBe('mid'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('Unknown recallBudget')); + spy.mockRestore(); + }); + + it('valid retainMode and recallBudget pass without warning', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const config = loadConfig({ retainMode: 'last-turn', recallBudget: 'high' }); + expect(config.retainMode).toBe('last-turn'); + expect(config.recallBudget).toBe('high'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); }); diff --git a/hindsight-integrations/opencode/src/config.ts b/hindsight-integrations/opencode/src/config.ts index 026bb0061..c86c442d1 100644 --- a/hindsight-integrations/opencode/src/config.ts +++ b/hindsight-integrations/opencode/src/config.ts @@ -156,7 +156,28 @@ export function loadConfig(pluginOptions?: Record): HindsightCo } } - return config as unknown as HindsightConfig; + const result = config as unknown as HindsightConfig; + + // Validate enum-like fields to catch typos early + const VALID_RETAIN_MODES = ['full-session', 'last-turn']; + if (!VALID_RETAIN_MODES.includes(result.retainMode)) { + console.error( + `[Hindsight] Unknown retainMode "${result.retainMode}" — ` + + `valid: ${VALID_RETAIN_MODES.join(', ')}. Falling back to "full-session".`, + ); + result.retainMode = 'full-session'; + } + + const VALID_BUDGETS = ['low', 'mid', 'high']; + if (!VALID_BUDGETS.includes(result.recallBudget)) { + console.error( + `[Hindsight] Unknown recallBudget "${result.recallBudget}" — ` + + `valid: ${VALID_BUDGETS.join(', ')}. Falling back to "mid".`, + ); + result.recallBudget = 'mid'; + } + + return result; } export function debugLog(config: HindsightConfig, ...args: unknown[]): void { diff --git a/hindsight-integrations/opencode/src/hooks.test.ts b/hindsight-integrations/opencode/src/hooks.test.ts index 7cd2f6594..208b6e7ae 100644 --- a/hindsight-integrations/opencode/src/hooks.test.ts +++ b/hindsight-integrations/opencode/src/hooks.test.ts @@ -227,6 +227,41 @@ describe('compacting hook', () => { expect(output.context[0]).toContain('Important fact'); }); + it('pre-compaction retain includes documentId and session metadata', async () => { + const client = makeClient(); + client.recall.mockResolvedValue({ results: [] }); + const messages = [ + { role: 'user', parts: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] }, + ]; + const output = { context: [] as string[] }; + const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages)); + + await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output); + + expect(client.retain).toHaveBeenCalledTimes(1); + const opts = client.retain.mock.calls[0][2]; + expect(opts.documentId).toBe('sess-1'); + expect(opts.metadata.session_id).toBe('sess-1'); + }); + + it('pre-compaction retain uses chunked documentId in last-turn mode', async () => { + const client = makeClient(); + client.recall.mockResolvedValue({ results: [] }); + const messages = [ + { role: 'user', parts: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] }, + ]; + const config = makeConfig({ retainMode: 'last-turn', retainEveryNTurns: 1 }); + const output = { context: [] as string[] }; + const hooks = createHooks(client, 'bank', config, makeState(), makeOpencodeClient(messages)); + + await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output); + + const opts = client.retain.mock.calls[0][2]; + expect(opts.documentId).toMatch(/^sess-1-\d+$/); + }); + it('does not throw on error', async () => { const client = makeClient(); client.recall.mockRejectedValue(new Error('Failed')); @@ -279,6 +314,37 @@ describe('system transform hook', () => { expect(client.recall).not.toHaveBeenCalled(); }); + it('retries recall on next transform if first attempt returns no results', async () => { + const client = makeClient(); + // First call: no results (Hindsight temporarily empty/unavailable) + client.recall.mockResolvedValueOnce({ results: [] }); + // Second call: has results + client.recall.mockResolvedValueOnce({ + results: [{ text: 'Found it', type: 'world' }], + }); + const state = makeState(); + state.recalledSessions.add('sess-1'); + const output1 = { system: [] as string[] }; + const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient()); + + // First attempt — no results, session should NOT be consumed + await hooks['experimental.chat.system.transform']( + { sessionID: 'sess-1', model: {} }, + output1, + ); + expect(output1.system.length).toBe(0); + expect(state.recalledSessions.has('sess-1')).toBe(true); + + // Second attempt — results found, session consumed + const output2 = { system: [] as string[] }; + await hooks['experimental.chat.system.transform']( + { sessionID: 'sess-1', model: {} }, + output2, + ); + expect(output2.system.length).toBeGreaterThan(0); + expect(state.recalledSessions.has('sess-1')).toBe(false); + }); + it('skips when autoRecall is false', async () => { const client = makeClient(); const state = makeState(); diff --git a/hindsight-integrations/opencode/src/hooks.ts b/hindsight-integrations/opencode/src/hooks.ts index 896c87ece..6b96e2dda 100644 --- a/hindsight-integrations/opencode/src/hooks.ts +++ b/hindsight-integrations/opencode/src/hooks.ts @@ -132,21 +132,11 @@ export function createHooks( } } - /** Auto-retain conversation transcript */ - async function handleSessionIdle(sessionId: string): Promise { - if (!config.autoRetain) return; - - const messages = await getSessionMessages(sessionId); - if (!messages.length) return; - - // Count user turns - const userTurns = messages.filter((m) => m.role === 'user').length; - const lastRetained = state.lastRetainedTurn.get(sessionId) || 0; - - // Only retain if enough new turns since last retain - if (userTurns - lastRetained < config.retainEveryNTurns) return; - - // Determine retention window and document ID + /** + * Retain messages for a session, respecting retainMode and documentId semantics. + * Used by both idle-retain and pre-compaction retain. + */ + async function retainSession(sessionId: string, messages: Message[]): Promise { const retainFullWindow = config.retainMode === 'full-session'; let targetMessages: Message[]; let documentId: string; @@ -166,17 +156,34 @@ export function createHooks( const { transcript } = prepareRetentionTranscript(targetMessages, true); if (!transcript) return; + await ensureBankMission(hindsightClient, bankId, config, state.missionsSet); + await hindsightClient.retain(bankId, transcript, { + documentId, + context: config.retainContext, + tags: config.retainTags.length ? config.retainTags : undefined, + metadata: Object.keys(config.retainMetadata).length + ? { ...config.retainMetadata, session_id: sessionId } + : { session_id: sessionId }, + async: true, + }); + } + + /** Auto-retain conversation transcript */ + async function handleSessionIdle(sessionId: string): Promise { + if (!config.autoRetain) return; + + const messages = await getSessionMessages(sessionId); + if (!messages.length) return; + + // Count user turns + const userTurns = messages.filter((m) => m.role === 'user').length; + const lastRetained = state.lastRetainedTurn.get(sessionId) || 0; + + // Only retain if enough new turns since last retain + if (userTurns - lastRetained < config.retainEveryNTurns) return; + try { - await ensureBankMission(hindsightClient, bankId, config, state.missionsSet); - await hindsightClient.retain(bankId, transcript, { - documentId, - context: config.retainContext, - tags: config.retainTags.length ? config.retainTags : undefined, - metadata: Object.keys(config.retainMetadata).length - ? { ...config.retainMetadata, session_id: sessionId } - : { session_id: sessionId }, - async: true, - }); + await retainSession(sessionId, messages); state.lastRetainedTurn.set(sessionId, userTurns); debugLog(config, `Auto-retained ${messages.length} messages for session ${sessionId}`); } catch (e) { @@ -217,21 +224,14 @@ export function createHooks( output: CompactingOutput, ): Promise => { try { - // First, retain what we have before compaction + // First, retain what we have before compaction (using shared retention logic) const messages = await getSessionMessages(input.sessionID); if (messages.length && config.autoRetain) { - const { transcript } = prepareRetentionTranscript(messages, true); - if (transcript) { - try { - await hindsightClient.retain(bankId, transcript, { - context: config.retainContext, - tags: config.retainTags.length ? config.retainTags : undefined, - async: true, - }); - debugLog(config, 'Pre-compaction retain completed'); - } catch (e) { - debugLog(config, 'Pre-compaction retain failed:', e); - } + try { + await retainSession(input.sessionID, messages); + debugLog(config, 'Pre-compaction retain completed'); + } catch (e) { + debugLog(config, 'Pre-compaction retain failed:', e); } } @@ -271,8 +271,6 @@ export function createHooks( // Only inject on first message of a session (tracked by recalledSessions) if (!state.recalledSessions.has(sessionId)) return; - // Remove so we only do this once per session - state.recalledSessions.delete(sessionId); await ensureBankMission(hindsightClient, bankId, config, state.missionsSet); @@ -281,6 +279,8 @@ export function createHooks( const context = await recallForContext(query); if (context) { output.system.push(context); + // Only mark as consumed after successful injection + state.recalledSessions.delete(sessionId); debugLog(config, `Injected recall context for session ${sessionId}`); } } catch (e) { From 874ae168f171c221e2edbf208a83e8911788a0d9 Mon Sep 17 00:00:00 2001 From: DK09876 Date: Fri, 3 Apr 2026 00:46:48 -0700 Subject: [PATCH 3/4] fix: docs/tools findings from second review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove "session" from supported dynamic bank fields in docs — the implementation can't vary bank ID per session since it's derived once at plugin startup. 2. Explicit tools (retain, reflect) now call ensureBankMission() before API calls, so bankMission/retainMission are applied even when the agent uses tools exclusively without triggering hooks. 3. Added tests for mission setup via tools path. 88 tests pass. Co-Authored-By: Claude Opus 4.6 --- hindsight-docs/docs-integrations/opencode.md | 2 +- hindsight-integrations/opencode/src/index.ts | 2 +- .../opencode/src/tools.test.ts | 54 +++++++++++++++++++ hindsight-integrations/opencode/src/tools.ts | 9 ++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/hindsight-docs/docs-integrations/opencode.md b/hindsight-docs/docs-integrations/opencode.md index 93b772a8d..93057dc98 100644 --- a/hindsight-docs/docs-integrations/opencode.md +++ b/hindsight-docs/docs-integrations/opencode.md @@ -122,7 +122,7 @@ For multi-project isolation, enable dynamic bank ID derivation: export HINDSIGHT_DYNAMIC_BANK_ID=true ``` -The bank ID is composed from granularity fields (default: `agent::project`). Supported fields: `agent`, `project`, `session`, `channel`, `user`. +The bank ID is composed from granularity fields (default: `agent::project`). Supported fields: `agent`, `project`, `channel`, `user`. For multi-user scenarios (e.g., shared agent serving multiple users): diff --git a/hindsight-integrations/opencode/src/index.ts b/hindsight-integrations/opencode/src/index.ts index 1f47af303..4f4967b40 100644 --- a/hindsight-integrations/opencode/src/index.ts +++ b/hindsight-integrations/opencode/src/index.ts @@ -53,7 +53,7 @@ const HindsightPlugin: Plugin = async (input, options) => { lastRetainedTurn: new Map(), }; - const tools = createTools(client, bankId, config); + const tools = createTools(client, bankId, config, state.missionsSet); const hooks = createHooks(client, bankId, config, state, input.client as unknown as Parameters[4]); return { diff --git a/hindsight-integrations/opencode/src/tools.test.ts b/hindsight-integrations/opencode/src/tools.test.ts index 93e8dfb42..6e8f4dc92 100644 --- a/hindsight-integrations/opencode/src/tools.test.ts +++ b/hindsight-integrations/opencode/src/tools.test.ts @@ -275,6 +275,60 @@ describe('createTools', () => { }); }); + describe('bank mission setup', () => { + it('calls ensureBankMission before retain when missionsSet provided', async () => { + const client = { + retain: vi.fn().mockResolvedValue({}), + recall: vi.fn(), + reflect: vi.fn(), + createBank: vi.fn().mockResolvedValue({}), + } as any; + const missionsSet = new Set(); + const config = makeConfig({ bankMission: 'Extract technical decisions' }); + const tools = createTools(client, 'test-bank', config, missionsSet); + + await tools.hindsight_retain.execute({ content: 'fact' }, mockContext); + + expect(client.createBank).toHaveBeenCalledWith('test-bank', { + reflectMission: 'Extract technical decisions', + retainMission: undefined, + }); + expect(missionsSet.has('test-bank')).toBe(true); + expect(client.retain).toHaveBeenCalled(); + }); + + it('calls ensureBankMission before reflect when missionsSet provided', async () => { + const client = { + retain: vi.fn(), + recall: vi.fn(), + reflect: vi.fn().mockResolvedValue({ text: 'answer' }), + createBank: vi.fn().mockResolvedValue({}), + } as any; + const missionsSet = new Set(); + const config = makeConfig({ bankMission: 'Synthesize project context' }); + const tools = createTools(client, 'test-bank', config, missionsSet); + + await tools.hindsight_reflect.execute({ query: 'summary' }, mockContext); + + expect(client.createBank).toHaveBeenCalled(); + expect(client.reflect).toHaveBeenCalled(); + }); + + it('skips mission setup when missionsSet not provided (backward compat)', async () => { + const client = { + retain: vi.fn().mockResolvedValue({}), + recall: vi.fn(), + reflect: vi.fn(), + } as any; + const tools = createTools(client, 'test-bank', makeConfig()); + + await tools.hindsight_retain.execute({ content: 'fact' }, mockContext); + + expect(client.retain).toHaveBeenCalled(); + // No createBank call since missionsSet wasn't passed + }); + }); + it('always uses constructor bankId', async () => { const client = { retain: vi.fn().mockResolvedValue({}), diff --git a/hindsight-integrations/opencode/src/tools.ts b/hindsight-integrations/opencode/src/tools.ts index d102201f1..78aacf9e7 100644 --- a/hindsight-integrations/opencode/src/tools.ts +++ b/hindsight-integrations/opencode/src/tools.ts @@ -10,6 +10,8 @@ import type { ToolDefinition } from '@opencode-ai/plugin/tool'; import type { HindsightClient } from '@vectorize-io/hindsight-client'; import type { HindsightConfig } from './config.js'; import { formatMemories, formatCurrentTime } from './content.js'; +import { ensureBankMission } from './bank.js'; +import type { PluginState } from './hooks.js'; export interface HindsightTools { hindsight_retain: ToolDefinition; @@ -21,6 +23,7 @@ export function createTools( client: HindsightClient, bankId: string, config: HindsightConfig, + missionsSet?: Set, ): HindsightTools { const hindsight_retain = tool({ description: @@ -37,6 +40,9 @@ export function createTools( .describe('Optional context about where this information came from.'), }, async execute(args) { + if (missionsSet) { + await ensureBankMission(client, bankId, config, missionsSet); + } await client.retain(bankId, args.content, { context: args.context || config.retainContext, tags: config.retainTags.length ? config.retainTags : undefined, @@ -88,6 +94,9 @@ export function createTools( .describe('Optional additional context to guide the reflection.'), }, async execute(args) { + if (missionsSet) { + await ensureBankMission(client, bankId, config, missionsSet); + } const response = await client.reflect(bankId, args.query, { context: args.context, budget: config.recallBudget as 'low' | 'mid' | 'high', From 81b61ca1ced4fcf9cabfa1337b5be1b0e406670e Mon Sep 17 00:00:00 2001 From: DK09876 Date: Fri, 3 Apr 2026 07:28:56 -0700 Subject: [PATCH 4/4] fix: recall retry semantics and README bank scoping clarity 1. recallForContext now returns { context, ok } to distinguish "no results" (ok=true) from "API error" (ok=false). System transform consumes the session on ok=true even with 0 results, so empty banks don't cause repeated queries. Only transient API failures preserve retry. 2. README clarifies that channel/user bank dimensions are process- scoped (set via env vars before launch), not per-session dynamic within a running OpenCode process. 89 tests pass. Co-Authored-By: Claude Opus 4.6 --- hindsight-integrations/opencode/README.md | 4 ++- .../opencode/src/hooks.test.ts | 33 +++++++++++++++---- hindsight-integrations/opencode/src/hooks.ts | 32 ++++++++++++------ 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/hindsight-integrations/opencode/README.md b/hindsight-integrations/opencode/README.md index 2d3b6628c..0e6b377c6 100644 --- a/hindsight-integrations/opencode/README.md +++ b/hindsight-integrations/opencode/README.md @@ -122,7 +122,9 @@ For multi-project setups, enable dynamic bank ID derivation: export HINDSIGHT_DYNAMIC_BANK_ID=true ``` -The bank ID is composed from granularity fields (default: `agent::project`). For multi-user scenarios: +The bank ID is composed from granularity fields (default: `agent::project`). Supported fields: `agent`, `project`, `channel`, `user`. + +**Note:** The bank ID is derived once when the plugin loads, from environment variables set before OpenCode starts. These dimensions are process-scoped — they don't change per session within a running OpenCode process. For per-user isolation, set the env vars before launching each user's OpenCode instance: ```bash export HINDSIGHT_CHANNEL_ID="slack-general" diff --git a/hindsight-integrations/opencode/src/hooks.test.ts b/hindsight-integrations/opencode/src/hooks.test.ts index 208b6e7ae..18b8a6ba1 100644 --- a/hindsight-integrations/opencode/src/hooks.test.ts +++ b/hindsight-integrations/opencode/src/hooks.test.ts @@ -314,20 +314,39 @@ describe('system transform hook', () => { expect(client.recall).not.toHaveBeenCalled(); }); - it('retries recall on next transform if first attempt returns no results', async () => { + it('consumes session on empty recall (no repeated queries for empty banks)', async () => { const client = makeClient(); - // First call: no results (Hindsight temporarily empty/unavailable) - client.recall.mockResolvedValueOnce({ results: [] }); - // Second call: has results + // No results — empty bank + client.recall.mockResolvedValue({ results: [] }); + const state = makeState(); + state.recalledSessions.add('sess-1'); + const output = { system: [] as string[] }; + const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient()); + + await hooks['experimental.chat.system.transform']( + { sessionID: 'sess-1', model: {} }, + output, + ); + + // No injection, but session consumed — won't re-query on next transform + expect(output.system.length).toBe(0); + expect(state.recalledSessions.has('sess-1')).toBe(false); + }); + + it('retries recall on next transform after transient API failure', async () => { + const client = makeClient(); + // First call: API error (transient) + client.recall.mockRejectedValueOnce(new Error('Connection refused')); + // Second call: succeeds client.recall.mockResolvedValueOnce({ results: [{ text: 'Found it', type: 'world' }], }); const state = makeState(); state.recalledSessions.add('sess-1'); - const output1 = { system: [] as string[] }; const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient()); - // First attempt — no results, session should NOT be consumed + // First attempt — API error, session preserved for retry + const output1 = { system: [] as string[] }; await hooks['experimental.chat.system.transform']( { sessionID: 'sess-1', model: {} }, output1, @@ -335,7 +354,7 @@ describe('system transform hook', () => { expect(output1.system.length).toBe(0); expect(state.recalledSessions.has('sess-1')).toBe(true); - // Second attempt — results found, session consumed + // Second attempt — succeeds, session consumed const output2 = { system: [] as string[] }; await hooks['experimental.chat.system.transform']( { sessionID: 'sess-1', model: {} }, diff --git a/hindsight-integrations/opencode/src/hooks.ts b/hindsight-integrations/opencode/src/hooks.ts index 6b96e2dda..3db7d72f2 100644 --- a/hindsight-integrations/opencode/src/hooks.ts +++ b/hindsight-integrations/opencode/src/hooks.ts @@ -81,8 +81,15 @@ export function createHooks( state: PluginState, opencodeClient: OpencodeClient, ): HindsightHooks { + interface RecallOutcome { + /** formatted context string, or null if no results */ + context: string | null; + /** true if the API call succeeded (even with 0 results) */ + ok: boolean; + } + /** Recall memories and format as context string */ - async function recallForContext(query: string): Promise { + async function recallForContext(query: string): Promise { try { const response = await hindsightClient.recall(bankId, query, { budget: config.recallBudget as 'low' | 'mid' | 'high', @@ -91,19 +98,19 @@ export function createHooks( }); const results = response.results || []; - if (!results.length) return null; + if (!results.length) return { context: null, ok: true }; const formatted = formatMemories(results); - return ( + const context = `\n` + `${config.recallPromptPreamble}\n` + `Current time: ${formatCurrentTime()} UTC\n\n` + `${formatted}\n` + - `` - ); + ``; + return { context, ok: true }; } catch (e) { debugLog(config, 'Recall failed:', e); - return null; + return { context: null, ok: false }; } } @@ -249,7 +256,7 @@ export function createHooks( lastUserMsg.content, config.recallMaxQueryChars, ); - const context = await recallForContext(truncated); + const { context } = await recallForContext(truncated); if (context) { output.context.push(context); } @@ -276,11 +283,16 @@ export function createHooks( // Use a generic project-context query for session start const query = `project context and recent work`; - const context = await recallForContext(query); + const { context, ok } = await recallForContext(query); + + // Consume after a successful API round-trip (even with 0 results). + // Only preserve retry for transient API failures (ok=false). + if (ok) { + state.recalledSessions.delete(sessionId); + } + if (context) { output.system.push(context); - // Only mark as consumed after successful injection - state.recalledSessions.delete(sessionId); debugLog(config, `Injected recall context for session ${sessionId}`); } } catch (e) {