diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 541c312..921af5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,10 @@ jobs: if: steps.version-check.outputs.changed == 'true' run: bun run typecheck + - name: Test + if: steps.version-check.outputs.changed == 'true' + run: bun test + - name: Build if: steps.version-check.outputs.changed == 'true' run: bun run build diff --git a/README.md b/README.md index a488d8f..69910e4 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ OpenCode plugin for persistent memory using [Supermemory](https://supermemory.ai Your agent remembers what you tell it - across sessions, across projects. +One package supports both OpenCode generations. OpenCode V1 loads +`opencode-supermemory`; the OpenCode 2 beta loads `opencode-supermemory/v2`. +The initial V2 adapter targets `@opencode-ai/plugin` beta `0.0.0-beta-17728`. +Use the `opencode` binary for V1 and `opencode2` for the beta while testing both. + ## Installation ### For Humans @@ -45,7 +50,8 @@ bunx opencode-supermemory@latest install --no-tui This will: -- Register the plugin in `~/.config/opencode/opencode.jsonc` +- Register both the V1 and V2 entrypoints in `~/.config/opencode/opencode.jsonc` +- Allow only the V2 `supermemory_recall` helper without prompting - Create the `/supermemory-init` command #### Step 2: Verify the config @@ -58,10 +64,22 @@ Should contain: ```json { - "plugin": ["opencode-supermemory"] + "plugin": ["opencode-supermemory@latest"], + "plugins": ["opencode-supermemory/v2"], + "permissions": [ + { + "action": "supermemory_recall", + "resource": "*", + "effect": "allow" + } + ] } ``` +OpenCode V1 reads the singular `plugin` entry. OpenCode 2 reads the plural +`plugins` entry and loads the V2 adapter. Both entrypoints ship in the same npm +package and use the same Supermemory account and configuration. + If not, add it manually: **JSONC:** @@ -69,9 +87,20 @@ If not, add it manually: ```jsonc { "plugin": [ - "opencode-supermemory", + "opencode-supermemory@latest", // ... other plugins ], + "plugins": [ + "opencode-supermemory/v2", + // ... other OpenCode 2 plugins + ], + "permissions": [ + { + "action": "supermemory_recall", + "resource": "*", + "effect": "allow", + }, + ], } ``` @@ -79,10 +108,23 @@ If not, add it manually: ```json { - "plugin": ["opencode-supermemory"] + "plugin": ["opencode-supermemory@latest"], + "plugins": ["opencode-supermemory/v2"], + "permissions": [ + { + "action": "supermemory_recall", + "resource": "*", + "effect": "allow" + } + ] } ``` +The installer preserves comments, existing plugins, permissions, and unrelated +settings. Running it again is safe. If `supermemory_recall` is explicitly +denied, the installer keeps that deny and prints a warning instead of +overriding it. + #### Step 3: Authenticate Run the browser authentication flow: @@ -110,7 +152,7 @@ bunx opencode-supermemory@latest status If it is not connected, check: 1. Is the user authenticated, or is `SUPERMEMORY_API_KEY` set? -2. Is the plugin in `opencode.jsonc`? +2. Does `opencode.jsonc` contain the V1 `plugin` and V2 `plugins` entries shown above? 3. Check logs: `tail ~/.opencode-supermemory.log` #### Step 5: Initialize codebase memory (optional) @@ -119,6 +161,14 @@ Run `/supermemory-init` to have the agent explore and memorize the codebase. +### OpenCode 2 rollback + +To stop loading the beta adapter without affecting OpenCode V1, remove only +`"opencode-supermemory/v2"` from the plural `plugins` array and restart +OpenCode 2. The singular `plugin` entry continues to load the V1 adapter. The +recall permission may remain in the file; it has no effect when the V2 adapter +is not loaded. + ## Features ### Context Injection @@ -156,9 +206,12 @@ message. The model searches only when earlier work, saved conventions, or user preferences are likely to help; trivial and self-contained messages skip the network call. -Recall uses the `supermemory` tool in `search` mode and is auto-approved. -Customize the directive with `recallDirective`. Set `SUPERMEMORY_DEBUG=1` to -show a `[recall-decision]` line in each reply while testing. +On V1, recall uses the `supermemory` tool in `search` mode. On OpenCode 2, it +uses the search-only `supermemory_recall` helper, which is the only V2 action +the installer auto-allows. Add and forget operations remain behind the normal +`supermemory` permission. Customize the directive with `recallDirective`. Set +`SUPERMEMORY_DEBUG=1` to show a `[recall-decision]` line in each reply while +testing. ### Automatic Capture @@ -185,15 +238,13 @@ Add custom triggers via `keywordPatterns` config. Run `/supermemory-init` to explore and memorize your codebase structure, patterns, and conventions. -### Preemptive Compaction - -When context hits 80% capacity: - -1. Triggers OpenCode's summarization -2. Injects project memories into summary context -3. Saves session summary as a memory +### Native Compaction Lifecycle -This preserves conversation context across compaction events. +OpenCode decides when to compact, which model to use, and how execution +continues afterward. Supermemory enriches that native lifecycle by injecting +bounded project memory into compaction context and saving only successful +session summaries. It does not trigger compaction or override OpenCode's +configured compaction model. ### Privacy @@ -277,8 +328,8 @@ Create `~/.config/opencode/supermemory.jsonc`: // Extra keyword patterns for memory detection (regex) "keywordPatterns": ["log\\s+this", "write\\s+down"], - // Context usage ratio that triggers compaction (0-1) - "compactionThreshold": 0.8, + // Enrich OpenCode's native compaction lifecycle with Supermemory + "compactionEnabled": true, // Save completed conversation batches every N turns (0 = session end only) "captureEveryNTurns": 3, @@ -321,7 +372,7 @@ This is useful when you want to: ## Usage with Oh My OpenCode -If you're using [Oh My OpenCode](https://github.com/code-yeongyu/oh-my-opencode), disable its built-in auto-compact hook to let supermemory handle context compaction: +If you're using [Oh My OpenCode](https://github.com/code-yeongyu/oh-my-opencode), disable its built-in auto-compact hook so it does not compete with OpenCode's native compaction lifecycle: Add to `~/.config/opencode/oh-my-opencode.json`: @@ -339,14 +390,21 @@ bun run build bun run typecheck ``` -Local install: +Local install after building: ```jsonc { "plugin": ["file:///path/to/opencode-supermemory"], + "plugins": [ + "file:///path/to/opencode-supermemory/dist/v2/index.js", + ], } ``` +Launch `opencode` to test the V1 entry and `opencode2` to test the V2 entry. +The direct built-file URL is for local development only; the published package +uses the stable `opencode-supermemory/v2` export shown above. + ## Logs ```bash diff --git a/bun.lock b/bun.lock index 32a3f60..a1c09a3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,30 +5,237 @@ "": { "name": "opencode-plugin", "devDependencies": { - "@opencode-ai/plugin": "^1.0.162", + "@opencode-ai/plugin": "0.0.0-beta-17728", "@types/bun": "latest", + "jsonc-parser": "3.3.1", "supermemory": "^4.0.0", "typescript": "^5.7.3", }, }, }, "packages": { - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.191", "", { "dependencies": { "@opencode-ai/sdk": "1.0.191", "zod": "4.1.8" } }, "sha512-+Z83g4uwRM+Qed5bV/HJ9KEA4FOPEOKZgTcyIvl0lVu++VYwPXydy1+YyW+/IRp17Ghz/xF7zWL+pBh2XlT9xQ=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.191", "", {}, "sha512-UjbwaxdrP8XFbMcCCy4FfWbGrY1Kz/6wzdg34ASCVlXA/FxfWw7cFhM9oPKnwmR7HyGY7nq/y4Ywb0yW9TAwEA=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@opencode-ai/ai": ["@opencode-ai/ai@0.0.0-beta-17728", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-17728", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.110", "google-auth-library": "10.5.0" } }, "sha512-kQMEkLIpft1QnJaDfS5y8qVDTTI3eMveriz91BSrbXR18yfehiV67gBNWjBsYdVRrxa6deEbZT2w+rZjKXKUCA=="], + + "@opencode-ai/client": ["@opencode-ai/client@0.0.0-beta-17728", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-beta-17728", "@opencode-ai/schema": "0.0.0-beta-17728" }, "peerDependencies": { "effect": "4.0.0-rc.110", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-VqoXZj064L2lpJ/On/xPJm14GWzf2ewzs4sQ2JD/jDPuzhhgWdfdGJV5LgHwNsAS7NTZQa3pt3UopVFCG8Sv0Q=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@0.0.0-beta-17728", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/ai": "0.0.0-beta-17728", "@opencode-ai/client": "0.0.0-beta-17728", "@opencode-ai/protocol": "0.0.0-beta-17728", "@opencode-ai/schema": "0.0.0-beta-17728", "@opencode-ai/sdk": "1.18.5", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.110", "zod": "4.1.8" }, "peerDependencies": { "@opencode-ai/theme": "0.0.0-beta-17728", "@opentui/core": ">=0.5.4", "@opentui/keymap": ">=0.5.4", "@opentui/solid": ">=0.5.4", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode-ai/theme", "@opentui/core", "@opentui/keymap", "@opentui/solid", "solid-js"] }, "sha512-qSCDonK91UKbRvkwjRTVWvU+qim3XC5P4jnMo8nZmsTa5KaqnoV8HtjmQKvHTnwT1ygR9LRAXv7iyhvMrVKaow=="], + + "@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-17728", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-17728", "effect": "4.0.0-rc.110" } }, "sha512-GlnQDyFKM8JdCfFOzN0uhnxpRG4LfikBJU6vf8Ce7qGow2Mfojx8TWWJQAiI0is41Y7VSwQwKpQyLclnMxOygQ=="], + + "@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-17728", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.110" } }, "sha512-JlJuyf11RyGUIsOY3zgIeUGMUHm77CmdbhsmEAyqfQzDd7/4jZN71JKKp2zqAg+SdWsmf9s+L65P0lruSTE/Vg=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.5", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-7KgMvP5/1oxbhHj6kYBtPSTEdFKYpUeEYOzBTKdzSaRpapUpFFdn6Hkus3rr0rljO0kukWZIgRd3DrVBwTULGA=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@smithy/core": ["@smithy/core@3.33.2", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "effect": ["effect@4.0.0-rc.110", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-ega6FTJ8CS2of7tHZiADvgyJyV999Q6tZ9juE56V81O0jw6flRwydaPNtyfvP2a5LL9PZrse8A1jnNUD5sWVHg=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.4", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.2.0", "", {}, "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "supermemory": ["supermemory@4.0.0", "", {}, "sha512-xMN05PQ8kTv8DuXa2qf8h/9LaRI7v1Kz3Tutt97JPq+PzhGabKLv5YVbSgqHiPX5yXcSUBVBNYPPbhAQMF6GYQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index a4484f1..acd20e7 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,25 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./v2": { + "types": "./dist/v2/index.d.ts", + "import": "./dist/v2/index.js", + "default": "./dist/v2/index.js" + }, + "./package.json": "./package.json" + }, "bin": { "opencode-supermemory": "./dist/cli.js" }, "scripts": { "generate:version": "node scripts/sync-version.mjs", - "build": "node scripts/sync-version.mjs && bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly", + "build": "node scripts/sync-version.mjs && bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/v2/index.ts --outfile ./dist/v2/index.js --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly", "dev": "tsc --watch", "typecheck": "node scripts/sync-version.mjs && tsc --noEmit", "test": "node scripts/sync-version.mjs && bun test" @@ -30,8 +43,9 @@ "url": "https://github.com/supermemoryai/opencode-supermemory" }, "devDependencies": { - "@opencode-ai/plugin": "^1.0.162", + "@opencode-ai/plugin": "0.0.0-beta-17728", "@types/bun": "latest", + "jsonc-parser": "3.3.1", "supermemory": "^4.0.0", "typescript": "^5.7.3" }, @@ -40,6 +54,7 @@ "hooks": [ "chat.message", "permission.ask", + "experimental.session.compacting", "event" ] }, diff --git a/src/cli.ts b/src/cli.ts index bfad2e8..bd316e7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,16 +3,14 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import * as readline from "node:readline"; -import { stripJsoncComments } from "./services/jsonc.js"; import { startAuthFlow, clearCredentials, loadCredentials, CREDENTIALS_FILE } from "./services/auth.js"; import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured, writeInstallDefaults } from "./config.js"; import { SupermemoryClient } from "./services/client.js"; import { getTags } from "./services/tags.js"; +import { editOpenCodeConfig } from "./services/opencode-config.js"; const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode"); const OPENCODE_COMMAND_DIR = join(OPENCODE_CONFIG_DIR, "command"); -const OH_MY_OPENCODE_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json"); -const PLUGIN_NAME = "opencode-supermemory@latest"; const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(OPENCODE_CONFIG_DIR, "supermemory.json"); const SUPERMEMORY_INIT_COMMAND = `--- @@ -256,51 +254,18 @@ function findOpencodeConfig(): string | null { function addPluginToConfig(configPath: string): boolean { try { const content = readFileSync(configPath, "utf-8"); - - if (content.includes("opencode-supermemory")) { - console.log("✓ Plugin already registered in config"); - return true; - } - - const jsonContent = stripJsoncComments(content); - let config: Record; - - try { - config = JSON.parse(jsonContent); - } catch { - console.error("✗ Failed to parse config file"); - return false; - } + const result = editOpenCodeConfig(content); - const plugins = (config.plugin as string[]) || []; - plugins.push(PLUGIN_NAME); - config.plugin = plugins; - - if (configPath.endsWith(".jsonc")) { - if (content.includes('"plugin"')) { - const newContent = content.replace( - /("plugin"\s*:\s*\[)([^\]]*?)(\])/, - (_match, start, middle, end) => { - const trimmed = middle.trim(); - if (trimmed === "") { - return `${start}\n "${PLUGIN_NAME}"\n ${end}`; - } - return `${start}${middle.trimEnd()},\n "${PLUGIN_NAME}"\n ${end}`; - } - ); - writeFileSync(configPath, newContent); - } else { - const newContent = content.replace( - /^(\s*\{)/, - `$1\n "plugin": ["${PLUGIN_NAME}"],` - ); - writeFileSync(configPath, newContent); - } + if (result.changed) { + writeFileSync(configPath, result.content); + console.log(`✓ Added OpenCode V1 and V2 plugin entries to ${configPath}`); } else { - writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log("✓ OpenCode V1 and V2 plugin entries already registered"); } - console.log(`✓ Added plugin to ${configPath}`); + for (const warning of result.warnings) { + console.warn(`⚠ ${warning}`); + } return true; } catch (err) { console.error("✗ Failed to update config:", err); @@ -311,13 +276,9 @@ function addPluginToConfig(configPath: string): boolean { function createNewConfig(): boolean { const configPath = join(OPENCODE_CONFIG_DIR, "opencode.jsonc"); mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }); - - const config = `{ - "plugin": ["${PLUGIN_NAME}"] -} -`; - - writeFileSync(configPath, config); + + const config = editOpenCodeConfig("{}\n"); + writeFileSync(configPath, config.content); console.log(`✓ Created ${configPath}`); return true; } @@ -344,58 +305,8 @@ function createCommands(): boolean { return true; } -function isOhMyOpencodeInstalled(): boolean { - const configPath = findOpencodeConfig(); - if (!configPath) return false; - - try { - const content = readFileSync(configPath, "utf-8"); - return content.includes("oh-my-opencode"); - } catch { - return false; - } -} - -function isAutoCompactAlreadyDisabled(): boolean { - if (!existsSync(OH_MY_OPENCODE_CONFIG)) return false; - - try { - const content = readFileSync(OH_MY_OPENCODE_CONFIG, "utf-8"); - const config = JSON.parse(content); - const disabledHooks = config.disabled_hooks as string[] | undefined; - return disabledHooks?.includes("anthropic-context-window-limit-recovery") ?? false; - } catch { - return false; - } -} - -function disableAutoCompactHook(): boolean { - try { - let config: Record = {}; - - if (existsSync(OH_MY_OPENCODE_CONFIG)) { - const content = readFileSync(OH_MY_OPENCODE_CONFIG, "utf-8"); - config = JSON.parse(content); - } - - const disabledHooks = (config.disabled_hooks as string[]) || []; - if (!disabledHooks.includes("anthropic-context-window-limit-recovery")) { - disabledHooks.push("anthropic-context-window-limit-recovery"); - } - config.disabled_hooks = disabledHooks; - - writeFileSync(OH_MY_OPENCODE_CONFIG, JSON.stringify(config, null, 2)); - console.log(`✓ Disabled anthropic-context-window-limit-recovery hook in oh-my-opencode.json`); - return true; - } catch (err) { - console.error("✗ Failed to update oh-my-opencode.json:", err); - return false; - } -} - interface InstallOptions { tui: boolean; - disableAutoCompact: boolean; } async function install(options: InstallOptions): Promise { @@ -446,33 +357,9 @@ async function install(options: InstallOptions): Promise { createCommands(); } - // Step 3: Configure Oh My OpenCode (if installed) - if (isOhMyOpencodeInstalled()) { - console.log("\nStep 3: Configure Oh My OpenCode"); - console.log("Detected Oh My OpenCode plugin."); - console.log("Supermemory handles context compaction, so the built-in context-window-limit-recovery hook should be disabled."); - - if (isAutoCompactAlreadyDisabled()) { - console.log("✓ anthropic-context-window-limit-recovery hook already disabled"); - } else { - if (options.tui) { - const shouldDisable = await confirm(rl!, "Disable anthropic-context-window-limit-recovery hook to let Supermemory handle context?"); - if (!shouldDisable) { - console.log("Skipped."); - } else { - disableAutoCompactHook(); - } - } else if (options.disableAutoCompact) { - disableAutoCompactHook(); - } else { - console.log("Skipped. Use --disable-context-recovery to disable the hook in non-interactive mode."); - } - } - } - if (rl) rl.close(); - // Step 4: Authenticate + // Final step: Authenticate console.log("\n" + "─".repeat(50)); console.log("\n🔑 Final step: Authenticate with Supermemory\n"); @@ -654,7 +541,6 @@ opencode-supermemory - Persistent memory for OpenCode agents Commands: install Install and configure the plugin --no-tui Non-interactive mode (for LLM agents) - --disable-context-recovery Disable Oh My OpenCode's context hook login Authenticate with Supermemory (opens browser) logout Clear stored credentials status Show Supermemory connection status @@ -676,13 +562,11 @@ if (args.length === 0 || args[0] === "help" || args[0] === "--help" || args[0] = if (args[0] === "install") { const noTui = args.includes("--no-tui"); - const disableAutoCompact = args.includes("--disable-context-recovery"); - install({ tui: !noTui, disableAutoCompact }).then((code) => process.exit(code)); + install({ tui: !noTui }).then((code) => process.exit(code)); } else if (args[0] === "setup") { console.log("Note: 'setup' is deprecated. Use 'install' instead.\n"); const noTui = args.includes("--no-tui"); - const disableAutoCompact = args.includes("--disable-context-recovery"); - install({ tui: !noTui, disableAutoCompact }).then((code) => process.exit(code)); + install({ tui: !noTui }).then((code) => process.exit(code)); } else if (args[0] === "login") { login().then((code) => process.exit(code)); } else if (args[0] === "logout") { diff --git a/src/config.ts b/src/config.ts index b100652..737240d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,7 @@ const CONFIG_FILES = [ ]; export const DEFAULT_BASE_URL = "https://api.supermemory.ai"; +const DEFAULT_COMPACTION_THRESHOLD = 0.8; interface SupermemoryConfig { apiKey?: string; @@ -26,7 +27,9 @@ interface SupermemoryConfig { projectContainerTag?: string; filterPrompt?: string; keywordPatterns?: string[]; - compactionThreshold?: number; + compactionEnabled?: boolean; + /** @deprecated OpenCode now owns the compaction trigger. Use compactionEnabled. */ + compactionThreshold?: number | false; autoRecallEveryPrompt?: boolean; captureEveryNTurns?: number; recallDirective?: string | null; @@ -60,7 +63,8 @@ const DEFAULTS: Required 1) return DEFAULTS.compactionThreshold; + if (value < 0 || value > 1) return DEFAULT_COMPACTION_THRESHOLD; return value; } +export function resolveCompactionEnabled( + enabled: boolean | undefined, + legacyThreshold: number | false | undefined, +): boolean { + if (enabled !== undefined) return enabled; + return validateCompactionThreshold(legacyThreshold) !== 0; +} + function validateCaptureEveryNTurns( value: number | undefined, fallback: number, @@ -168,6 +183,10 @@ export const CONFIG = { ...DEFAULT_KEYWORD_PATTERNS, ...(fileConfig.keywordPatterns ?? []).filter(isValidRegex), ], + compactionEnabled: resolveCompactionEnabled( + fileConfig.compactionEnabled, + fileConfig.compactionThreshold, + ), compactionThreshold: validateCompactionThreshold(fileConfig.compactionThreshold), autoRecallEveryPrompt: fileConfig.autoRecallEveryPrompt ?? diff --git a/src/index.ts b/src/index.ts index cb090e5..c907be0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,20 +1,21 @@ -import type { Plugin, PluginInput } from "@opencode-ai/plugin"; +import type { Plugin, PluginInput } from "@opencode-ai/plugin/v1"; import type { Part, Permission } from "@opencode-ai/sdk"; -import { tool } from "@opencode-ai/plugin"; +import { tool } from "@opencode-ai/plugin/v1"; -import { AGENT_ENTITY_CONTEXT } from "./services/entity-context.js"; import { supermemoryClient } from "./services/client.js"; import { formatContextForPrompt } from "./services/context.js"; import { createCaptureHook } from "./services/capture.js"; import { buildRecallDirective } from "./services/recall.js"; import { getTags } from "./services/tags.js"; -import { stripPrivateContent, isFullyPrivate } from "./services/privacy.js"; import { createCompactionHook, type CompactionContext } from "./services/compaction.js"; +import { + executeSupermemoryTool, + type SupermemoryToolArgs, +} from "./services/memory-tool.js"; import { isConfigured, CONFIG, PLUGIN_VERSION } from "./config.js"; import { log } from "./services/logger.js"; import { checkNpmUpdate, formatUpdateNotice } from "./services/version-check.js"; -import type { MemoryScope, MemoryType } from "./types/index.js"; const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g; const INLINE_CODE_PATTERN = /`[^`]+`/g; @@ -73,44 +74,20 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { log("Plugin disabled - SUPERMEMORY_API_KEY not set"); } - // Fetch model limits once at plugin init - const modelLimits = new Map(); - - (async () => { - try { - const response = await ctx.client.provider.list(); - if (response.data?.all) { - for (const provider of response.data.all) { - if (provider.models) { - for (const [modelId, model] of Object.entries(provider.models)) { - if (model.limit?.context) { - modelLimits.set(`${provider.id}/${modelId}`, model.limit.context); - } - } - } - } - } - log("Model limits loaded", { count: modelLimits.size }); - } catch (error) { - log("Failed to fetch model limits", { error: String(error) }); - } - })(); - - const getModelLimit = (providerID: string, modelID: string): number | undefined => { - return modelLimits.get(`${providerID}/${modelID}`); - }; - - const compactionHook = isConfigured() && ctx.client - ? createCompactionHook(ctx as CompactionContext, tags, { - threshold: CONFIG.compactionThreshold, - getModelLimit, - }) + const compactionHook = isConfigured() && ctx.client && CONFIG.compactionEnabled + ? createCompactionHook(ctx as CompactionContext, tags) : null; const captureHook = isConfigured() && ctx.client ? createCaptureHook(ctx, tags) : null; return { + "experimental.session.compacting": compactionHook + ? async (input, output) => { + await compactionHook.compacting(input, output); + } + : undefined, + "chat.message": async (input, output) => { if (!isConfigured()) return; @@ -279,288 +256,8 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { memoryId: tool.schema.string().optional(), limit: tool.schema.number().optional(), }, - async execute(args: { - mode?: string; - content?: string; - query?: string; - type?: MemoryType; - scope?: MemoryScope; - memoryId?: string; - limit?: number; - }) { - if (!isConfigured()) { - return JSON.stringify({ - success: false, - error: - "SUPERMEMORY_API_KEY not set. Set it in your environment to use Supermemory.", - }); - } - - const mode = args.mode || "help"; - - try { - switch (mode) { - case "help": { - return JSON.stringify({ - success: true, - message: "Supermemory Usage Guide", - commands: [ - { - command: "add", - description: "Store a new memory", - args: ["content", "type?", "scope?"], - }, - { - command: "search", - description: "Search memories", - args: ["query", "scope?"], - }, - { - command: "profile", - description: "View user profile", - args: ["query?"], - }, - { - command: "list", - description: "List recent memories", - args: ["scope?", "limit?"], - }, - { - command: "forget", - description: "Remove a memory", - args: ["memoryId", "scope?"], - }, - ], - scopes: { - user: "Personal preferences and knowledge for this project", - project: "Project-specific knowledge (default)", - }, - types: [ - "project-config", - "architecture", - "error-solution", - "preference", - "learned-pattern", - "conversation", - ], - }); - } - - case "add": { - if (!args.content) { - return JSON.stringify({ - success: false, - error: "content parameter is required for add mode", - }); - } - - const sanitizedContent = stripPrivateContent(args.content); - if (isFullyPrivate(args.content)) { - return JSON.stringify({ - success: false, - error: "Cannot store fully private content", - }); - } - - const scope = args.scope || "project"; - const internalScope = - scope === "user" ? "personal" : "project"; - - const result = await supermemoryClient.addMemory( - sanitizedContent, - tags.canonical, - { - type: args.type, - project: tags.projectName, - sm_project_id: tags.projectId, - sm_scope: internalScope, - sm_capture_mode: "tool", - }, - { entityContext: AGENT_ENTITY_CONTEXT } - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to add memory", - }); - } - - return JSON.stringify({ - success: true, - message: `Memory added to ${scope} scope`, - id: result.id, - scope, - type: args.type, - }); - } - - case "search": { - if (!args.query) { - return JSON.stringify({ - success: false, - error: "query parameter is required for search mode", - }); - } - - const scope = args.scope; - - if (scope === "user") { - const result = await supermemoryClient.searchMemoriesScoped( - args.query, - tags.canonical, - tags.personalReads, - "personal", - ); - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to search memories", - }); - } - return formatSearchResults(args.query, scope, result, args.limit); - } - - if (scope === "project") { - const result = await supermemoryClient.searchMemoriesScoped( - args.query, - tags.canonical, - tags.projectReads, - "project", - ); - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to search memories", - }); - } - return formatSearchResults(args.query, scope, result, args.limit); - } - - const result = await supermemoryClient.searchMemoriesMany( - args.query, - tags.allReads, - ); - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to search memories", - }); - } - return formatSearchResults( - args.query, - undefined, - result, - args.limit, - ); - } - - case "profile": { - const result = await supermemoryClient.getProfileScoped( - tags.canonical, - tags.personalReads, - "personal", - args.query, - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to fetch profile", - }); - } - - return JSON.stringify({ - success: true, - profile: { - static: result.profile?.static || [], - dynamic: result.profile?.dynamic || [], - }, - }); - } - - case "list": { - const scope = args.scope || "project"; - const limit = args.limit || 20; - const internalScope = - scope === "user" ? "personal" : "project"; - const readTags = - scope === "user" ? tags.personalReads : tags.projectReads; - - const result = await supermemoryClient.listMemoriesScoped( - tags.canonical, - readTags, - internalScope, - limit, - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to list memories", - }); - } - - const memories = result.memories || []; - return JSON.stringify({ - success: true, - scope, - count: memories.length, - memories: memories.map((m) => ({ - id: m.id, - content: m.summary, - createdAt: m.createdAt, - metadata: m.metadata, - })), - }); - } - - case "forget": { - if (!args.memoryId) { - return JSON.stringify({ - success: false, - error: "memoryId parameter is required for forget mode", - }); - } - - const scope = args.scope || "project"; - const readTags = - scope === "user" - ? tags.personalReads - : scope === "project" - ? tags.projectReads - : tags.allReads; - - const result = await supermemoryClient.deleteMemory( - args.memoryId, - [tags.canonical, ...readTags], - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to delete memory", - }); - } - - return JSON.stringify({ - success: true, - message: `Memory ${args.memoryId} removed from ${scope} scope`, - }); - } - - default: - return JSON.stringify({ - success: false, - error: `Unknown mode: ${mode}`, - }); - } - } catch (error) { - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } + async execute(args: SupermemoryToolArgs) { + return executeSupermemoryTool(args, tags); }, }), }, @@ -587,28 +284,3 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }, }; }; - -function formatSearchResults( - query: string, - scope: string | undefined, - results: { results?: Array<{ id?: string; memory?: string; chunk?: string; similarity?: number }> }, - limit?: number -): string { - const memoryResults = results.results || []; - return JSON.stringify({ - success: true, - query, - scope, - count: memoryResults.length, - results: memoryResults.slice(0, limit || 10).map((r) => { - const result = { - content: r.memory ?? r.chunk, - similarity: Math.round((r.similarity ?? 0) * 100), - }; - - return r.memory === undefined - ? { ...result, forgettable: false } - : { id: r.id, ...result, forgettable: true }; - }), - }); -} diff --git a/src/services/compaction.ts b/src/services/compaction.ts index f925892..4835056 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -1,72 +1,92 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; import { AGENT_ENTITY_CONTEXT } from "./entity-context.js"; import { supermemoryClient } from "./client.js"; import { log } from "./logger.js"; import { CONFIG } from "../config.js"; import type { ResolvedTags } from "./tags.js"; -const MESSAGE_STORAGE = join(homedir(), ".opencode", "messages"); -const PART_STORAGE = join(homedir(), ".opencode", "parts"); - -const DEFAULT_THRESHOLD = 0.80; -const MIN_TOKENS_FOR_COMPACTION = 50_000; -const COMPACTION_COOLDOWN_MS = 30_000; -const DEFAULT_CONTEXT_LIMIT = 200_000; - -interface CompactionState { - lastCompactionTime: Map; - compactionInProgress: Set; - summarizedSessions: Set; -} - -interface TokenInfo { - input: number; - output: number; - cache: { read: number; write: number }; -} +const COMPACTION_CONTEXT_MARKER = "[SUPERMEMORY COMPACTION CONTEXT]"; +const MAX_COMPACTION_MEMORY_CHARS = 12_000; +const MAX_SINGLE_MEMORY_CHARS = 2_000; interface MessageInfo { id: string; role: string; sessionID: string; - providerID?: string; - modelID?: string; - tokens?: TokenInfo; summary?: boolean; - finish?: boolean; + finish?: string | boolean; + error?: unknown; } -interface StoredMessage { - agent?: string; - model?: { providerID?: string; modelID?: string }; +interface SessionMessage { + info: MessageInfo; + parts?: Array<{ type: string; text?: string }>; } -interface SummarizeContext { - sessionID: string; - providerID: string; - modelID: string; - usageRatio: number; +interface CompactionMemoryClient { + listMemoriesScoped: ( + canonicalTag: string, + containerTags: string[], + scope: "project", + limit: number, + ) => Promise<{ + memories?: Array<{ summary?: string | null; content?: string | null }>; + }>; + addMemory: ( + content: string, + containerTag: string, + metadata?: Record, + options?: { customId?: string; entityContext?: string }, + ) => Promise<{ success: boolean; id?: string; error?: string }>; +} + +export interface CompactionContext { directory: string; - agent?: string; + client: { + session: { + messages: (params: { + path: { id: string }; + query: { directory: string }; + }) => Promise<{ data?: SessionMessage[] } | SessionMessage[]>; + }; + }; } export interface CompactionOptions { - threshold?: number; - getModelLimit?: (providerID: string, modelID: string) => number | undefined; + memoryClient?: CompactionMemoryClient; } -function createCompactionPrompt(projectMemories: string[]): string { - const memoriesSection = projectMemories.length > 0 - ? ` +export function fitProjectMemories(memories: string[]): string[] { + const result: string[] = []; + const seen = new Set(); + let remaining = MAX_COMPACTION_MEMORY_CHARS; + + for (const rawMemory of memories) { + const normalized = rawMemory.trim(); + if (!normalized || seen.has(normalized) || remaining <= 0) continue; + seen.add(normalized); + + const memory = normalized.slice( + 0, + Math.min(MAX_SINGLE_MEMORY_CHARS, remaining), + ); + result.push(memory); + remaining -= memory.length; + } + + return result; +} + +export function createCompactionPrompt(projectMemories: string[]): string { + const memoriesSection = + projectMemories.length > 0 + ? ` ## Project Knowledge (from Supermemory) The following project-specific knowledge should be preserved and referenced in the summary: -${projectMemories.map(m => `- ${m}`).join('\n')} +${projectMemories.map((memory) => `- ${memory}`).join("\n")} ` - : ''; + : ""; - return `[COMPACTION CONTEXT INJECTION] + return `${COMPACTION_CONTEXT_MARKER} When summarizing this session, you MUST include the following sections in your summary: @@ -99,213 +119,67 @@ This context is critical for maintaining continuity after compaction. `; } -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null; - - const directPath = join(MESSAGE_STORAGE, sessionID); - if (existsSync(directPath)) return directPath; - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID); - if (existsSync(sessionPath)) return sessionPath; - } - - return null; +function getResponseMessages( + response: { data?: SessionMessage[] } | SessionMessage[], +): SessionMessage[] { + return Array.isArray(response) ? response : response.data ?? []; } -function getOrCreateMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE)) { - mkdirSync(MESSAGE_STORAGE, { recursive: true }); - } - - const directPath = join(MESSAGE_STORAGE, sessionID); - if (existsSync(directPath)) return directPath; - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID); - if (existsSync(sessionPath)) return sessionPath; - } - - mkdirSync(directPath, { recursive: true }); - return directPath; -} - -function findNearestMessageWithFields(messageDir: string): StoredMessage | null { - try { - const files = readdirSync(messageDir) - .filter((f) => f.endsWith(".json")) - .sort() - .reverse(); - - for (const file of files) { - try { - const content = readFileSync(join(messageDir, file), "utf-8"); - const msg = JSON.parse(content) as StoredMessage; - if (msg.agent && msg.model?.providerID && msg.model?.modelID) { - return msg; - } - } catch { - continue; - } - } - } catch { - return null; - } - return null; -} - -function generateMessageId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 14); - return `msg_${timestamp}${random}`; -} - -function generatePartId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 10); - return `prt_${timestamp}${random}`; -} - -function injectHookMessage( - sessionID: string, - hookContent: string, - originalMessage: { - agent?: string; - model?: { providerID?: string; modelID?: string }; - path?: { cwd?: string; root?: string }; - } -): boolean { - if (!hookContent || hookContent.trim().length === 0) { - log("[compaction] attempted to inject empty content, skipping"); - return false; - } - - const messageDir = getOrCreateMessageDir(sessionID); - const fallback = findNearestMessageWithFields(messageDir); - - const now = Date.now(); - const messageID = generateMessageId(); - const partID = generatePartId(); - - const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"; - const resolvedModel = - originalMessage.model?.providerID && originalMessage.model?.modelID - ? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID } - : fallback?.model?.providerID && fallback?.model?.modelID - ? { providerID: fallback.model.providerID, modelID: fallback.model.modelID } - : undefined; - - const messageMeta = { - id: messageID, - sessionID, - role: "user", - time: { created: now }, - agent: resolvedAgent, - model: resolvedModel, - path: originalMessage.path?.cwd - ? { cwd: originalMessage.path.cwd, root: originalMessage.path.root ?? "/" } - : undefined, - }; - - const textPart = { - id: partID, - type: "text", - text: hookContent, - synthetic: true, - time: { start: now, end: now }, - messageID, - sessionID, - }; - - try { - writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2)); - - const partDir = join(PART_STORAGE, messageID); - if (!existsSync(partDir)) { - mkdirSync(partDir, { recursive: true }); - } - writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2)); - - log("[compaction] hook message injected", { sessionID, messageID }); - return true; - } catch (err) { - log("[compaction] failed to inject hook message", { error: String(err) }); - return false; - } -} - -export interface CompactionContext { - directory: string; - client: { - session: { - summarize: (params: { path: { id: string }; body: { providerID: string; modelID: string }; query: { directory: string } }) => Promise; - messages: (params: { path: { id: string }; query: { directory: string } }) => Promise<{ data?: Array<{ info: MessageInfo }> }>; - promptAsync: (params: { path: { id: string }; body: { agent?: string; parts: Array<{ type: string; text: string }> }; query: { directory: string } }) => Promise; - }; - tui: { - showToast: (params: { body: { title: string; message: string; variant: string; duration: number } }) => Promise; - }; - }; +function getSummaryContent(message: SessionMessage): string { + return (message.parts ?? []) + .filter( + (part): part is { type: string; text: string } => + part.type === "text" && typeof part.text === "string", + ) + .map((part) => part.text) + .join("\n") + .trim(); } export function createCompactionHook( ctx: CompactionContext, tags: ResolvedTags, - options?: CompactionOptions + options?: CompactionOptions, ) { - const state: CompactionState = { - lastCompactionTime: new Map(), - compactionInProgress: new Set(), - summarizedSessions: new Set(), - }; + const memoryClient = options?.memoryClient ?? supermemoryClient; + const pendingSessions = new Set(); + const captureInProgress = new Set(); + const capturedSummaryIDs = new Map>(); - const threshold = options?.threshold ?? DEFAULT_THRESHOLD; - const getModelLimit = options?.getModelLimit; - - async function fetchProjectMemoriesForCompaction(): Promise { + async function fetchProjectMemories(): Promise { try { - const result = await supermemoryClient.listMemoriesScoped( + const result = await memoryClient.listMemoriesScoped( tags.canonical, tags.projectReads, "project", CONFIG.maxProjectMemories, ); - const memories = result.memories || []; - return memories.map((m: any) => m.summary || m.content || "").filter(Boolean); - } catch (err) { - log("[compaction] failed to fetch project memories", { error: String(err) }); + const memories = (result.memories ?? []) + .map((memory) => memory.summary || memory.content || "") + .filter((memory): memory is string => Boolean(memory)); + return fitProjectMemories(memories); + } catch (error) { + log("[compaction] failed to fetch project memories", { + error: String(error), + }); return []; } } - async function injectCompactionContext(summarizeCtx: SummarizeContext): Promise { - log("[compaction] injecting context", { sessionID: summarizeCtx.sessionID }); - - const projectMemories = await fetchProjectMemoriesForCompaction(); - const prompt = createCompactionPrompt(projectMemories); - - const success = injectHookMessage(summarizeCtx.sessionID, prompt, { - agent: summarizeCtx.agent, - model: { providerID: summarizeCtx.providerID, modelID: summarizeCtx.modelID }, - path: { cwd: summarizeCtx.directory }, - }); - - if (success) { - log("[compaction] context injected with project memories", { - sessionID: summarizeCtx.sessionID, - memoriesCount: projectMemories.length + async function saveSummaryAsMemory( + sessionID: string, + summaryContent: string, + ): Promise { + if (summaryContent.length < 100) { + log("[compaction] summary too short to save", { + sessionID, + length: summaryContent.length, }); - } - } - - async function saveSummaryAsMemory(sessionID: string, summaryContent: string): Promise { - if (!summaryContent || summaryContent.length < 100) { - log("[compaction] summary too short to save", { sessionID, length: summaryContent.length }); - return; + return true; } try { - const result = await supermemoryClient.addMemory( + const result = await memoryClient.addMemory( `[Session Summary]\n${summaryContent}`, tags.canonical, { @@ -316,239 +190,161 @@ export function createCompactionHook( sm_capture_mode: "compaction", sessionId: sessionID, }, - { entityContext: AGENT_ENTITY_CONTEXT } + { entityContext: AGENT_ENTITY_CONTEXT }, ); if (result.success) { - log("[compaction] summary saved as memory", { sessionID, memoryId: result.id }); - } else { - log("[compaction] failed to save summary", { error: result.error }); + log("[compaction] summary saved as memory", { + sessionID, + memoryId: result.id, + }); + return true; } - } catch (err) { - log("[compaction] failed to save summary", { error: String(err) }); - } - } - - async function checkAndTriggerCompaction(sessionID: string, lastAssistant: MessageInfo): Promise { - if (state.compactionInProgress.has(sessionID)) return; - - const lastCompaction = state.lastCompactionTime.get(sessionID) ?? 0; - if (Date.now() - lastCompaction < COMPACTION_COOLDOWN_MS) return; - - if (lastAssistant.summary === true) return; - - const tokens = lastAssistant.tokens; - if (!tokens) return; - let modelID = lastAssistant.modelID ?? ""; - let providerID = lastAssistant.providerID ?? ""; - let agent: string | undefined; - - // Fallback: find model/agent from stored messages if not available - const messageDir = getMessageDir(sessionID); - const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null; - - if (!providerID || !modelID) { - if (storedMessage?.model?.providerID) providerID = storedMessage.model.providerID; - if (storedMessage?.model?.modelID) modelID = storedMessage.model.modelID; + log("[compaction] failed to save summary", { error: result.error }); + return false; + } catch (error) { + log("[compaction] failed to save summary", { error: String(error) }); + return false; } - agent = storedMessage?.agent; - - const configLimit = getModelLimit?.(providerID, modelID); - const contextLimit = configLimit ?? DEFAULT_CONTEXT_LIMIT; - const totalUsed = tokens.input + tokens.cache.read + tokens.output; - - if (totalUsed < MIN_TOKENS_FOR_COMPACTION) return; - - const usageRatio = totalUsed / contextLimit; - - log("[compaction] checking", { - sessionID, - totalUsed, - contextLimit, - usageRatio: usageRatio.toFixed(2), - threshold, - }); - - if (usageRatio < threshold) return; - - state.compactionInProgress.add(sessionID); - state.lastCompactionTime.set(sessionID, Date.now()); + } - if (!providerID || !modelID) { - state.compactionInProgress.delete(sessionID); + async function captureSummary( + sessionID: string, + expectedSummaryID?: string, + ): Promise { + if (!pendingSessions.has(sessionID) || captureInProgress.has(sessionID)) { return; } - await ctx.client.tui.showToast({ - body: { - title: "Preemptive Compaction", - message: `Context at ${(usageRatio * 100).toFixed(0)}% - compacting with Supermemory context...`, - variant: "warning", - duration: 3000, - }, - }).catch(() => {}); - - log("[compaction] triggering compaction", { sessionID, usageRatio }); - - try { - await injectCompactionContext({ - sessionID, - providerID, - modelID, - usageRatio, - directory: ctx.directory, - agent, - }); - - state.summarizedSessions.add(sessionID); - - await ctx.client.session.summarize({ - path: { id: sessionID }, - body: { providerID, modelID }, - query: { directory: ctx.directory }, - }); - - await ctx.client.tui.showToast({ - body: { - title: "Compaction Complete", - message: "Session compacted with Supermemory context. Resuming...", - variant: "success", - duration: 2000, - }, - }).catch(() => {}); - - state.compactionInProgress.delete(sessionID); - - setTimeout(async () => { - try { - const messageDir = getMessageDir(sessionID); - const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null; - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - agent: storedMessage?.agent, - parts: [{ type: "text", text: "Continue" }], - }, - query: { directory: ctx.directory }, - }); - } catch {} - }, 500); - } catch (err) { - log("[compaction] compaction failed", { sessionID, error: String(err) }); - state.compactionInProgress.delete(sessionID); - } - } - - async function handleSummaryMessage(sessionID: string, _messageInfo: MessageInfo): Promise { - log("[compaction] handleSummaryMessage called", { sessionID, inSet: state.summarizedSessions.has(sessionID) }); - - if (!state.summarizedSessions.has(sessionID)) return; - - state.summarizedSessions.delete(sessionID); - log("[compaction] capturing summary for memory", { sessionID }); + const capturedForSession = capturedSummaryIDs.get(sessionID); + if (expectedSummaryID && capturedForSession?.has(expectedSummaryID)) return; + captureInProgress.add(sessionID); try { - const resp = await ctx.client.session.messages({ + const response = await ctx.client.session.messages({ path: { id: sessionID }, query: { directory: ctx.directory }, }); - - const messages = (resp.data ?? resp) as Array<{ info: MessageInfo; parts?: Array<{ type: string; text?: string }> }>; - - const summaryMessage = messages.find(m => - m.info.role === "assistant" && - m.info.summary === true + const messages = getResponseMessages(response); + const summaries = messages.filter( + (message) => + message.info.role === "assistant" && + message.info.summary === true && + Boolean(message.info.finish) && + message.info.finish !== "error" && + !message.info.error, ); + const summary = expectedSummaryID + ? summaries.find((message) => message.info.id === expectedSummaryID) + : summaries.at(-1); - log("[compaction] looking for summary message", { - sessionID, - found: !!summaryMessage, - hasParts: !!summaryMessage?.parts - }); + if (!summary) { + log("[compaction] summary message not available yet", { sessionID }); + return; + } + + const alreadyCaptured = capturedSummaryIDs + .get(sessionID) + ?.has(summary.info.id); + if (alreadyCaptured) return; - if (summaryMessage?.parts) { - const textParts = summaryMessage.parts.filter(p => p.type === "text" && p.text); - const summaryContent = textParts.map(p => p.text).join("\n"); - - log("[compaction] summary content", { - sessionID, - textPartsCount: textParts.length, - contentLength: summaryContent.length + const summaryContent = getSummaryContent(summary); + if (!summaryContent) { + log("[compaction] summary content not available yet", { + sessionID, + summaryID: summary.info.id, }); - - if (summaryContent) { - await saveSummaryAsMemory(sessionID, summaryContent); - } + return; } - } catch (err) { - log("[compaction] failed to capture summary", { error: String(err) }); + + if (!(await saveSummaryAsMemory(sessionID, summaryContent))) return; + + const captured = capturedSummaryIDs.get(sessionID) ?? new Set(); + captured.add(summary.info.id); + capturedSummaryIDs.set(sessionID, captured); + pendingSessions.delete(sessionID); + } catch (error) { + log("[compaction] failed to capture summary", { error: String(error) }); + } finally { + captureInProgress.delete(sessionID); } } return { - async event({ event }: { event: { type: string; properties?: unknown } }) { - const props = event.properties as Record | undefined; + async compacting( + input: { sessionID: string }, + output: { context: string[]; prompt?: string }, + ): Promise { + pendingSessions.add(input.sessionID); - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - state.lastCompactionTime.delete(sessionInfo.id); - state.compactionInProgress.delete(sessionInfo.id); - state.summarizedSessions.delete(sessionInfo.id); + try { + const projectMemories = await fetchProjectMemories(); + const context = createCompactionPrompt(projectMemories); + if (!output.context.some((item) => item.includes(COMPACTION_CONTEXT_MARKER))) { + output.context.push(context); } - return; + log("[compaction] native context injected", { + sessionID: input.sessionID, + memoriesCount: projectMemories.length, + }); + } catch (error) { + // Compaction must never fail because optional Supermemory context failed. + log("[compaction] failed to inject native context", { + sessionID: input.sessionID, + error: String(error), + }); } + }, - if (event.type === "message.updated") { - const info = props?.info as MessageInfo | undefined; - if (!info) return; - - const sessionID = info.sessionID; - if (!sessionID) return; + async event({ event }: { event: { type: string; properties?: unknown } }) { + const properties = event.properties as + | Record + | undefined; - if (info.role === "assistant" && info.summary === true && info.finish) { - await handleSummaryMessage(sessionID, info); + if (event.type === "message.updated") { + const info = properties?.info as MessageInfo | undefined; + if ( + info?.sessionID && + info.role === "assistant" && + info.summary === true && + Boolean(info.finish) && + (info.finish === "error" || Boolean(info.error)) + ) { + pendingSessions.delete(info.sessionID); + log("[compaction] native compaction failed; summary not captured", { + sessionID: info.sessionID, + }); return; } - - if (info.role !== "assistant" || !info.finish) return; - - await checkAndTriggerCompaction(sessionID, info); + if ( + info?.sessionID && + info.role === "assistant" && + info.summary === true && + Boolean(info.finish) + ) { + await captureSummary(info.sessionID, info.id); + } return; } - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined; - if (!sessionID) return; - - try { - const resp = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }); - - const messages = (resp.data ?? resp) as Array<{ info: MessageInfo }>; - const assistants = messages - .filter((m) => m.info.role === "assistant") - .map((m) => m.info); - - if (assistants.length === 0) return; - - const lastAssistant = assistants[assistants.length - 1]!; - - if (!lastAssistant.providerID || !lastAssistant.modelID) { - const messageDir = getMessageDir(sessionID); - const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null; - if (storedMessage?.model?.providerID && storedMessage?.model?.modelID) { - lastAssistant.providerID = storedMessage.model.providerID; - lastAssistant.modelID = storedMessage.model.modelID; - } - } + if ( + event.type === "session.compacted" || + event.type === "session.idle" + ) { + const sessionID = properties?.sessionID as string | undefined; + if (sessionID && pendingSessions.has(sessionID)) { + await captureSummary(sessionID); + } + return; + } - await checkAndTriggerCompaction(sessionID, lastAssistant); - } catch {} + if (event.type === "session.deleted") { + const sessionInfo = properties?.info as { id?: string } | undefined; + if (!sessionInfo?.id) return; + pendingSessions.delete(sessionInfo.id); + captureInProgress.delete(sessionInfo.id); + capturedSummaryIDs.delete(sessionInfo.id); } }, }; diff --git a/src/services/logger.ts b/src/services/logger.ts index 5d1c44c..56483c9 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -4,12 +4,28 @@ import { join } from "path"; const LOG_FILE = join(homedir(), ".opencode-supermemory.log"); -writeFileSync(LOG_FILE, `\n--- Session started: ${new Date().toISOString()} ---\n`, { flag: "a" }); +function writeLogLine(line: string): void { + try { + appendFileSync(LOG_FILE, line); + } catch { + // Logging must never prevent either OpenCode plugin generation from loading. + } +} + +try { + writeFileSync( + LOG_FILE, + `\n--- Session started: ${new Date().toISOString()} ---\n`, + { flag: "a" }, + ); +} catch { + // A read-only home directory should disable file logging, not the plugin. +} export function log(message: string, data?: unknown) { const timestamp = new Date().toISOString(); const line = data ? `[${timestamp}] ${message}: ${JSON.stringify(data)}\n` : `[${timestamp}] ${message}\n`; - appendFileSync(LOG_FILE, line); + writeLogLine(line); } diff --git a/src/services/memory-tool.ts b/src/services/memory-tool.ts new file mode 100644 index 0000000..05fa398 --- /dev/null +++ b/src/services/memory-tool.ts @@ -0,0 +1,343 @@ +import { isConfigured } from "../config.js"; +import type { MemoryScope, MemoryType } from "../types/index.js"; +import { supermemoryClient, type SupermemoryClient } from "./client.js"; +import { AGENT_ENTITY_CONTEXT } from "./entity-context.js"; +import { isFullyPrivate, stripPrivateContent } from "./privacy.js"; +import type { ResolvedTags } from "./tags.js"; + +export interface SupermemoryToolArgs { + mode?: string; + content?: string; + query?: string; + type?: MemoryType; + scope?: MemoryScope; + memoryId?: string; + limit?: number; +} + +export type MemoryToolClient = Pick< + SupermemoryClient, + | "addMemory" + | "searchMemoriesScoped" + | "searchMemoriesMany" + | "getProfileScoped" + | "listMemoriesScoped" + | "deleteMemory" +>; + +export interface MemoryToolOptions { + memoryClient?: MemoryToolClient; + configured?: boolean; +} + +export async function executeSupermemoryTool( + args: SupermemoryToolArgs, + tags: ResolvedTags, + options: MemoryToolOptions = {}, +): Promise { + const memoryClient = options.memoryClient ?? supermemoryClient; + const configured = options.configured ?? isConfigured(); + + if (!configured) { + return JSON.stringify({ + success: false, + error: + "SUPERMEMORY_API_KEY not set. Set it in your environment to use Supermemory.", + }); + } + + const mode = args.mode || "help"; + + try { + switch (mode) { + case "help": { + return JSON.stringify({ + success: true, + message: "Supermemory Usage Guide", + commands: [ + { + command: "add", + description: "Store a new memory", + args: ["content", "type?", "scope?"], + }, + { + command: "search", + description: "Search memories", + args: ["query", "scope?"], + }, + { + command: "profile", + description: "View user profile", + args: ["query?"], + }, + { + command: "list", + description: "List recent memories", + args: ["scope?", "limit?"], + }, + { + command: "forget", + description: "Remove a memory", + args: ["memoryId", "scope?"], + }, + ], + scopes: { + user: "Personal preferences and knowledge for this project", + project: "Project-specific knowledge (default)", + }, + types: [ + "project-config", + "architecture", + "error-solution", + "preference", + "learned-pattern", + "conversation", + ], + }); + } + + case "add": { + if (!args.content) { + return JSON.stringify({ + success: false, + error: "content parameter is required for add mode", + }); + } + + const sanitizedContent = stripPrivateContent(args.content); + if (isFullyPrivate(args.content)) { + return JSON.stringify({ + success: false, + error: "Cannot store fully private content", + }); + } + + const scope = args.scope || "project"; + const internalScope = scope === "user" ? "personal" : "project"; + + const result = await memoryClient.addMemory( + sanitizedContent, + tags.canonical, + { + type: args.type, + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: internalScope, + sm_capture_mode: "tool", + }, + { entityContext: AGENT_ENTITY_CONTEXT }, + ); + + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to add memory", + }); + } + + return JSON.stringify({ + success: true, + message: `Memory added to ${scope} scope`, + id: result.id, + scope, + type: args.type, + }); + } + + case "search": { + if (!args.query) { + return JSON.stringify({ + success: false, + error: "query parameter is required for search mode", + }); + } + + const scope = args.scope; + + if (scope === "user") { + const result = await memoryClient.searchMemoriesScoped( + args.query, + tags.canonical, + tags.personalReads, + "personal", + ); + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to search memories", + }); + } + return formatSearchResults(args.query, scope, result, args.limit); + } + + if (scope === "project") { + const result = await memoryClient.searchMemoriesScoped( + args.query, + tags.canonical, + tags.projectReads, + "project", + ); + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to search memories", + }); + } + return formatSearchResults(args.query, scope, result, args.limit); + } + + const result = await memoryClient.searchMemoriesMany( + args.query, + tags.allReads, + ); + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to search memories", + }); + } + return formatSearchResults(args.query, undefined, result, args.limit); + } + + case "profile": { + const result = await memoryClient.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + args.query, + ); + + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to fetch profile", + }); + } + + return JSON.stringify({ + success: true, + profile: { + static: result.profile?.static || [], + dynamic: result.profile?.dynamic || [], + }, + }); + } + + case "list": { + const scope = args.scope || "project"; + const limit = args.limit || 20; + const internalScope = scope === "user" ? "personal" : "project"; + const readTags = + scope === "user" ? tags.personalReads : tags.projectReads; + + const result = await memoryClient.listMemoriesScoped( + tags.canonical, + readTags, + internalScope, + limit, + ); + + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to list memories", + }); + } + + const memories = result.memories || []; + return JSON.stringify({ + success: true, + scope, + count: memories.length, + memories: memories.map((memory) => ({ + id: memory.id, + content: memory.summary, + createdAt: memory.createdAt, + metadata: memory.metadata, + })), + }); + } + + case "forget": { + if (!args.memoryId) { + return JSON.stringify({ + success: false, + error: "memoryId parameter is required for forget mode", + }); + } + + const scope = args.scope || "project"; + const readTags = + scope === "user" + ? tags.personalReads + : scope === "project" + ? tags.projectReads + : tags.allReads; + + const result = await memoryClient.deleteMemory(args.memoryId, [ + tags.canonical, + ...readTags, + ]); + + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to delete memory", + }); + } + + return JSON.stringify({ + success: true, + message: `Memory ${args.memoryId} removed from ${scope} scope`, + }); + } + + default: + return JSON.stringify({ + success: false, + error: `Unknown mode: ${mode}`, + }); + } + } catch (error) { + return JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export function formatSearchResults( + query: string, + scope: string | undefined, + results: { + results?: Array<{ + id?: string; + memory?: string; + chunk?: string; + similarity?: number; + }>; + }, + limit?: number, +): string { + const memoryResults = results.results || []; + return JSON.stringify({ + success: true, + query, + scope, + count: memoryResults.length, + results: memoryResults.slice(0, limit || 10).map((result) => { + const formattedResult = { + content: result.memory ?? result.chunk, + similarity: Math.round((result.similarity ?? 0) * 100), + }; + + return result.memory === undefined + ? { ...formattedResult, forgettable: false } + : { + id: result.id, + ...formattedResult, + forgettable: true, + }; + }), + }); +} diff --git a/src/services/opencode-config.ts b/src/services/opencode-config.ts new file mode 100644 index 0000000..517e7c7 --- /dev/null +++ b/src/services/opencode-config.ts @@ -0,0 +1,168 @@ +import { + applyEdits, + modify, + parse, + type FormattingOptions, + type ParseError, +} from "jsonc-parser"; + +export const V1_PLUGIN_ENTRY = "opencode-supermemory@latest"; +export const V2_PLUGIN_ENTRY = "opencode-supermemory/v2"; + +export const RECALL_PERMISSION = { + action: "supermemory_recall", + resource: "*", + effect: "allow", +} as const; + +export interface OpenCodeConfigEditResult { + content: string; + changed: boolean; + warnings: string[]; +} + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseConfig(content: string): JsonObject { + const errors: ParseError[] = []; + const value = parse(content, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + + if (errors.length > 0) { + const first = errors[0]!; + throw new Error(`Invalid OpenCode JSONC config at offset ${first.offset}`); + } + + if (!isObject(value)) { + throw new Error("OpenCode config must contain a JSON object"); + } + + return value; +} + +function getFormattingOptions(content: string): FormattingOptions { + const eol = content.includes("\r\n") ? "\r\n" : "\n"; + const indent = content.match(/\r?\n([ \t]+)["}]/)?.[1]; + const usesTabs = indent?.includes("\t") ?? false; + + return { + eol, + insertSpaces: !usesTabs, + tabSize: usesTabs ? 1 : Math.max(2, indent?.length ?? 2), + }; +} + +function applyModification( + content: string, + path: Array, + value: unknown, +): string { + return applyEdits( + content, + modify(content, path, value, { + formattingOptions: getFormattingOptions(content), + }), + ); +} + +function getPluginPackage(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (isObject(value) && typeof value.package === "string") { + return value.package; + } + return undefined; +} + +function isV1Plugin(value: unknown): boolean { + const packageName = getPluginPackage(value); + return ( + packageName !== undefined && + /^(?:npm:)?opencode-supermemory(?:@[^/]+)?$/.test(packageName) + ); +} + +function isV2Plugin(value: unknown): boolean { + const packageName = getPluginPackage(value); + return ( + packageName !== undefined && + /^(?:npm:)?opencode-supermemory(?:@[^/]+)?\/v2$/.test(packageName) + ); +} + +function addArrayEntry( + content: string, + property: string, + value: unknown, + alreadyPresent: (entry: unknown) => boolean, +): string { + const config = parseConfig(content); + const current = config[property]; + + if (current === undefined) { + return applyModification(content, [property], [value]); + } + + if (!Array.isArray(current)) { + throw new Error(`OpenCode config property "${property}" must be an array`); + } + + if (current.some(alreadyPresent)) return content; + return applyModification(content, [property, -1], value); +} + +function isRecallPermission(value: unknown, effect: "allow" | "deny"): boolean { + return ( + isObject(value) && + value.action === RECALL_PERMISSION.action && + value.resource === RECALL_PERMISSION.resource && + value.effect === effect + ); +} + +/** + * Adds the OpenCode V1 and V2 plugin entries without rewriting unrelated JSONC. + * Existing package versions are kept, and an explicit recall deny is respected. + */ +export function editOpenCodeConfig(rawContent: string): OpenCodeConfigEditResult { + const original = rawContent; + let content = rawContent.trim() === "" ? "{}\n" : rawContent; + const warnings: string[] = []; + + parseConfig(content); + content = addArrayEntry(content, "plugin", V1_PLUGIN_ENTRY, isV1Plugin); + content = addArrayEntry(content, "plugins", V2_PLUGIN_ENTRY, isV2Plugin); + + const config = parseConfig(content); + const permissions = config.permissions; + if (permissions !== undefined && !Array.isArray(permissions)) { + throw new Error('OpenCode config property "permissions" must be an array'); + } + + const permissionEntries = permissions ?? []; + if (permissionEntries.some((entry) => isRecallPermission(entry, "deny"))) { + warnings.push( + 'OpenCode 2 permission "supermemory_recall" is explicitly denied; preserving the deny instead of adding an allow.', + ); + } else if ( + !permissionEntries.some((entry) => isRecallPermission(entry, "allow")) + ) { + content = addArrayEntry( + content, + "permissions", + RECALL_PERMISSION, + (entry) => isRecallPermission(entry, "allow"), + ); + } + + return { + content, + changed: content !== original, + warnings, + }; +} diff --git a/src/v2/index.ts b/src/v2/index.ts new file mode 100644 index 0000000..25f04d3 --- /dev/null +++ b/src/v2/index.ts @@ -0,0 +1,10 @@ +import { Plugin } from "@opencode-ai/plugin"; + +import { setupV2 } from "./runtime.js"; + +export default Plugin.define({ + id: "supermemory.opencode", + setup: setupV2, +}); + +export { setupV2 } from "./runtime.js"; diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts new file mode 100644 index 0000000..6814132 --- /dev/null +++ b/src/v2/runtime.ts @@ -0,0 +1,1100 @@ +import { createHash } from "node:crypto"; + +import type { Message } from "@opencode-ai/ai"; +import type { Context as PluginContext } from "@opencode-ai/plugin/promise/plugin"; + +import { CONFIG, isConfigured, PLUGIN_VERSION } from "../config.js"; +import { + buildCadenceBatches, + buildSessionEndBatch, + getCaptureId, + type CaptureBatch, + type CaptureTurn, +} from "../services/capture.js"; +import { supermemoryClient, type SupermemoryClient } from "../services/client.js"; +import { + createCompactionPrompt, + fitProjectMemories, +} from "../services/compaction.js"; +import { formatContextForPrompt } from "../services/context.js"; +import { AGENT_ENTITY_CONTEXT } from "../services/entity-context.js"; +import { log } from "../services/logger.js"; +import { + executeSupermemoryTool, + type SupermemoryToolArgs, +} from "../services/memory-tool.js"; +import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js"; +import { buildRecallDirective } from "../services/recall.js"; +import { getTags, type ResolvedTags } from "../services/tags.js"; +import { checkNpmUpdate, formatUpdateNotice } from "../services/version-check.js"; + +const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g; +const INLINE_CODE_PATTERN = /`[^`]+`/g; +const COMPACTION_CONTEXT_MARKER = "[SUPERMEMORY COMPACTION CONTEXT]"; +const SYNTHETIC_METADATA_KEY = "supermemoryV2"; +const UPDATE_COMMAND = "bunx opencode-supermemory@latest install"; + +export const MEMORY_NUDGE_MESSAGE = `[MEMORY TRIGGER DETECTED] +The user wants you to remember something. You MUST use the \`supermemory\` tool with \`mode: "add"\` to save this information. + +Extract the key information the user wants remembered and save it as a concise, searchable memory. +- Use \`scope: "project"\` for project-specific preferences (e.g., "run lint with tests") +- Use \`scope: "user"\` for personal preferences in this project (e.g., "prefers concise responses") +- Choose an appropriate \`type\`: "preference", "project-config", "learned-pattern", etc. + +DO NOT skip this step. The user explicitly asked you to remember.`; + +export const SUPERMEMORY_TOOL_INPUT = { + type: "object", + additionalProperties: false, + properties: { + mode: { + type: "string", + enum: ["add", "search", "profile", "list", "forget", "help"], + }, + content: { type: "string" }, + query: { type: "string" }, + type: { + type: "string", + enum: [ + "project-config", + "architecture", + "error-solution", + "preference", + "learned-pattern", + "conversation", + ], + }, + scope: { type: "string", enum: ["user", "project"] }, + memoryId: { type: "string" }, + limit: { type: "number" }, + }, +} as const; + +export const SUPERMEMORY_RECALL_INPUT = { + type: "object", + additionalProperties: false, + properties: { + mode: { type: "string", enum: ["search"] }, + query: { type: "string" }, + scope: { type: "string", enum: ["user", "project"] }, + limit: { type: "number" }, + }, + required: ["query"], +} as const; + +const SUPERMEMORY_DESCRIPTION = + "Manage and query the Supermemory persistent memory system. Use 'search' to find relevant memories, 'add' to store new knowledge, 'profile' to view user profile, 'list' to see recent memories, 'forget' to remove a memory."; + +const SUPERMEMORY_RECALL_DESCRIPTION = + "Search saved Supermemory context. This least-privilege helper only accepts search operations."; + +type RuntimeMemoryClient = Pick< + SupermemoryClient, + | "addMemory" + | "ingestConversation" + | "getProfileScoped" + | "searchMemoriesScoped" + | "listMemoriesScoped" + | "searchMemoriesMany" + | "deleteMemory" +>; + +type RuntimeConfig = Pick< + typeof CONFIG, + | "autoRecallEveryPrompt" + | "captureEveryNTurns" + | "compactionEnabled" + | "keywordPatterns" + | "maxProjectMemories" +>; + +export interface V2RuntimeDependencies { + configured: boolean; + config: RuntimeConfig; + memoryClient: RuntimeMemoryClient; + executeTool: typeof executeSupermemoryTool; + resolveTags: typeof getTags; + logger: typeof log; + getUpdateNotice: () => Promise; +} + +const DEFAULT_DEPENDENCIES: V2RuntimeDependencies = { + configured: isConfigured(), + config: CONFIG, + memoryClient: supermemoryClient, + executeTool: executeSupermemoryTool, + resolveTags: getTags, + logger: log, + getUpdateNotice: async () => { + const info = await checkNpmUpdate( + "opencode-supermemory", + PLUGIN_VERSION, + UPDATE_COMMAND, + ); + return info ? formatUpdateNotice(info) : null; + }, +}; + +interface V2Event { + id?: string; + type: string; + created?: number; + data?: Record; +} + +interface CachedMessage { + id: string; + role: string; + contextText: string; + streamText: Map; +} + +interface SessionState { + messages: Map; + order: string[]; + completedUsers: Set; + completedCaptureIds: Set; + injectedInitialContext: boolean; + lastInjectedDispatch?: string; + compactionNeedsContext: boolean; + directory?: string; + tags?: ResolvedTags; + resolving?: Promise; +} + +interface PendingSummary { + customId: string; + eventId: string; + sessionID: string; + text: string; +} + +interface Registration { + dispose: () => Promise; +} + +function mergeDependencies( + overrides: Partial | undefined, +): V2RuntimeDependencies { + return { + ...DEFAULT_DEPENDENCIES, + ...overrides, + config: { ...DEFAULT_DEPENDENCIES.config, ...overrides?.config }, + }; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function removeCodeBlocks(text: string): string { + return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, ""); +} + +export function detectMemoryKeyword( + text: string, + patterns: readonly string[] = CONFIG.keywordPatterns, +): boolean { + if (patterns.length === 0) return false; + return new RegExp(`\\b(${patterns.join("|")})\\b`, "i").test( + removeCodeBlocks(text), + ); +} + +export function buildV2RecallDirective( + directive: string = buildRecallDirective(), +): string { + return directive.replaceAll("`supermemory`", "`supermemory_recall`"); +} + +function isSyntheticPart(part: unknown): boolean { + if (!part || typeof part !== "object") return false; + const metadata = (part as { metadata?: Record }).metadata; + return Boolean(metadata?.[SYNTHETIC_METADATA_KEY]); +} + +function extractMessageText(message: Message): string { + return message.content + .filter( + (part): part is Message["content"][number] & { type: "text"; text: string } => + part.type === "text" && + typeof part.text === "string" && + !isSyntheticPart(part), + ) + .map((part) => part.text) + .join("\n") + .trim(); +} + +function messageKey( + message: Message, + text: string, + occurrence: number, +): string { + if (message.id) return message.id; + return `context:${message.role}:${sha256(text).slice(0, 24)}:${occurrence}`; +} + +function cachedText(message: CachedMessage): string { + if (message.streamText.size === 0) return message.contextText; + return [...message.streamText.entries()] + .sort(([left], [right]) => left - right) + .map(([, text]) => text) + .join("\n") + .trim(); +} + +function sanitizeCaptureText(text: string): string { + if (!text || isFullyPrivate(text)) return ""; + return stripPrivateContent(text).trim(); +} + +export function buildCachedCaptureTurns( + messages: Map, + order: readonly string[], + completedUsers: ReadonlySet, +): CaptureTurn[] { + const turns: CaptureTurn[] = []; + let current: + | { + id: string; + messages: CaptureTurn["messages"]; + fullyPrivate: boolean; + complete: boolean; + } + | undefined; + + const finish = () => { + if (current?.complete) { + turns.push({ + id: current.id, + messages: current.fullyPrivate ? [] : current.messages, + }); + } + current = undefined; + }; + + for (const id of order) { + const message = messages.get(id); + if (!message) continue; + const rawText = cachedText(message); + + if (message.role === "user") { + finish(); + current = { + id, + messages: sanitizeCaptureText(rawText) + ? [{ role: "user", content: sanitizeCaptureText(rawText) }] + : [], + fullyPrivate: rawText.length > 0 && isFullyPrivate(rawText), + complete: completedUsers.has(id), + }; + continue; + } + + if (!current || message.role !== "assistant" || current.fullyPrivate) { + continue; + } + + const text = sanitizeCaptureText(rawText); + if (text) current.messages.push({ role: "assistant", content: text }); + } + + finish(); + return turns; +} + +function makeSyntheticText(text: string, kind: string) { + return { + type: "text" as const, + text, + metadata: { [SYNTHETIC_METADATA_KEY]: kind }, + }; +} + +function injectIntoMessage( + message: Message, + text: string, + kind: string, + position: "start" | "end" = "end", +): void { + if (!text.trim()) return; + const mutable = message.content as Array; + const part = makeSyntheticText(text, kind); + if (position === "start") mutable.unshift(part); + else mutable.push(part); +} + +function getLatestUser(messages: Message[]): Message | undefined { + return messages.findLast((message) => message.role === "user"); +} + +export class EventDeduper { + readonly #limit: number; + readonly #seen = new Set(); + readonly #order: string[] = []; + + constructor(limit = 4_096) { + this.#limit = Math.max(1, limit); + } + + hasSeen(id: string | undefined): boolean { + if (!id) return false; + if (this.#seen.has(id)) return true; + this.#seen.add(id); + this.#order.push(id); + if (this.#order.length > this.#limit) { + const oldest = this.#order.shift(); + if (oldest) this.#seen.delete(oldest); + } + return false; + } +} + +export class V2Runtime { + readonly #ctx: PluginContext; + readonly #deps: V2RuntimeDependencies; + readonly #isOwner: () => boolean; + readonly #states = new Map(); + readonly #captureInFlight = new Map>(); + readonly #pendingSummaries = new Map(); + readonly #summaryInFlight = new Set(); + readonly #deduper = new EventDeduper(); + readonly #registrations: Registration[] = []; + readonly #abortController = new AbortController(); + #active = true; + + constructor( + ctx: PluginContext, + options?: Partial, + isOwner: () => boolean = () => true, + ) { + this.#ctx = ctx; + this.#deps = mergeDependencies(options); + this.#isOwner = isOwner; + } + + get active(): boolean { + return this.#active && this.#isOwner(); + } + + get trackedSessionCount(): number { + return this.#states.size; + } + + get completedCaptureCount(): number { + return [...this.#states.values()].reduce( + (total, state) => total + state.completedCaptureIds.size, + 0, + ); + } + + async register(): Promise { + const toolRegistration = await this.#ctx.tool.transform((draft) => { + draft.add({ + name: "supermemory", + description: SUPERMEMORY_DESCRIPTION, + input: SUPERMEMORY_TOOL_INPUT, + options: { codemode: false, permission: "supermemory" }, + execute: async (args, context) => { + if (!this.active) return { content: this.#inactiveToolResult() }; + return { + content: await this.executeTool( + args as SupermemoryToolArgs, + context.sessionID, + ), + }; + }, + }); + + draft.add({ + name: "supermemory_recall", + description: SUPERMEMORY_RECALL_DESCRIPTION, + input: SUPERMEMORY_RECALL_INPUT, + options: { codemode: false, permission: "supermemory_recall" }, + execute: async (args, context) => { + if (!this.active) return { content: this.#inactiveToolResult() }; + return { + content: await this.executeRecallTool( + args as SupermemoryToolArgs, + context.sessionID, + ), + }; + }, + }); + }); + if (!this.active) { + this.#disposeRegistration(toolRegistration); + return; + } + this.#registrations.push(toolRegistration); + + const contextRegistration = await this.#ctx.session.hook( + "context", + async (context) => { + if (!this.active) return; + await this.handleContext(context); + }, + ); + if (!this.active) { + this.#disposeRegistration(contextRegistration); + return; + } + this.#registrations.push(contextRegistration); + + if (this.active && this.#deps.configured) this.#startEventSubscription(); + } + + async executeTool(args: SupermemoryToolArgs, sessionID: string): Promise { + try { + const tags = await this.#resolveSession(sessionID); + return await this.#deps.executeTool(args, tags, { + memoryClient: this.#deps.memoryClient, + configured: this.#deps.configured, + }); + } catch (error) { + return JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + async executeRecallTool( + args: SupermemoryToolArgs, + sessionID: string, + ): Promise { + if (args.mode && args.mode !== "search") { + return JSON.stringify({ + success: false, + error: "supermemory_recall only supports search mode", + }); + } + return this.executeTool({ ...args, mode: "search" }, sessionID); + } + + async handleContext(context: { + sessionID: string; + messages: Message[]; + }): Promise { + if (!this.#deps.configured) return; + const state = this.#state(context.sessionID); + this.#cacheContextMessages(state, context.messages); + + const latestUser = getLatestUser(context.messages); + if (state.compactionNeedsContext && latestUser) { + state.compactionNeedsContext = false; + await this.#injectCompactionContext(context.sessionID, latestUser); + } + + if (!latestUser) return; + const userText = extractMessageText(latestUser); + if (!userText) return; + const dispatchKey = this.#dispatchKey(context.messages, latestUser, userText); + if (state.lastInjectedDispatch === dispatchKey) return; + state.lastInjectedDispatch = dispatchKey; + + if (detectMemoryKeyword(userText, this.#deps.config.keywordPatterns)) { + injectIntoMessage(latestUser, MEMORY_NUDGE_MESSAGE, "nudge"); + } + injectIntoMessage(latestUser, buildV2RecallDirective(), "recall"); + + if (state.injectedInitialContext) return; + state.injectedInitialContext = true; + try { + const tags = await this.#resolveSession(context.sessionID); + const [memoryContext, updateNotice] = await Promise.all([ + this.#buildInitialContext(userText, tags), + this.#deps.getUpdateNotice().catch((error) => { + this.#deps.logger("v2 update check failed", { error: String(error) }); + return null; + }), + ]); + const initialContext = [memoryContext, updateNotice] + .map((part) => part?.trim()) + .filter(Boolean) + .join("\n\n"); + injectIntoMessage(latestUser, initialContext, "initial-context", "start"); + } catch (error) { + this.#deps.logger("v2 context injection failed", { + sessionID: context.sessionID, + error: String(error), + }); + } + } + + async handleEvent(event: V2Event): Promise { + if (!this.active || this.#deduper.hasSeen(event.id)) return; + const sessionID = this.#eventSessionID(event); + + if (sessionID && event.type !== "session.compaction.ended") { + await this.#retryPendingSummaries(sessionID); + } + + switch (event.type) { + case "session.text.ended": { + if (!sessionID) return; + const assistantMessageID = String(event.data?.assistantMessageID ?? ""); + const text = String(event.data?.text ?? ""); + const ordinal = Number(event.data?.ordinal ?? 0); + if (assistantMessageID && text) { + this.#cacheAssistantText( + this.#state(sessionID), + assistantMessageID, + Number.isFinite(ordinal) ? ordinal : 0, + text, + ); + } + return; + } + + case "session.execution.succeeded": { + if (!sessionID) return; + const state = this.#state(sessionID); + this.#markLatestTurnComplete(state); + await this.#runCaptureExclusive(sessionID, () => + this.#captureCadence(sessionID, state), + ); + return; + } + + case "session.execution.interrupted": { + if (!sessionID || event.data?.reason !== "shutdown") return; + const state = this.#states.get(sessionID); + if (state) { + await this.#runCaptureExclusive(sessionID, () => + this.#captureSessionEnd(sessionID, state), + ); + } + return; + } + + case "session.deleted": { + if (!sessionID) return; + const state = this.#states.get(sessionID); + if (state) { + await this.#runCaptureExclusive(sessionID, () => + this.#captureSessionEnd(sessionID, state), + ); + } + this.#states.delete(sessionID); + return; + } + + case "session.compaction.started": { + if (sessionID && this.#deps.config.compactionEnabled) { + this.#state(sessionID).compactionNeedsContext = true; + } + return; + } + + case "session.compaction.ended": { + if (!sessionID || !this.#deps.config.compactionEnabled) return; + this.#state(sessionID).compactionNeedsContext = false; + const text = String(event.data?.text ?? "").trim(); + if (!text) return; + if (text.length < 100) { + this.#deps.logger("v2 compaction summary too short to save", { + sessionID, + length: text.length, + }); + return; + } + const eventId = event.id ?? sha256(`${sessionID}:${text}`); + const customId = `opencode:compaction:${sha256(`${sessionID}:${eventId}`)}`; + this.#pendingSummaries.set(customId, { + customId, + eventId, + sessionID, + text, + }); + await this.#retryPendingSummaries(sessionID); + return; + } + + case "session.compaction.failed": { + if (sessionID) this.#state(sessionID).compactionNeedsContext = false; + return; + } + + case "global.disposed": { + await Promise.all( + [...this.#states.entries()].map(([id, state]) => + this.#runCaptureExclusive(id, () => + this.#captureSessionEnd(id, state), + ), + ), + ); + this.#states.clear(); + return; + } + } + } + + cleanup(): void { + if (!this.#active) return; + this.#active = false; + this.#abortController.abort(); + + const snapshots = [...this.#states.entries()]; + const pendingSessions = [ + ...new Set([...this.#pendingSummaries.values()].map((item) => item.sessionID)), + ]; + for (const [sessionID, state] of snapshots) { + void this.#runCaptureExclusive(sessionID, () => + this.#captureSessionEnd(sessionID, state), + ).catch((error) => { + this.#deps.logger("v2 cleanup capture failed", { + sessionID, + error: String(error), + }); + }); + } + for (const sessionID of pendingSessions) { + void this.#retryPendingSummaries(sessionID, true); + } + + this.#states.clear(); + for (const registration of this.#registrations.splice(0)) { + this.#disposeRegistration(registration); + } + } + + #state(sessionID: string): SessionState { + const existing = this.#states.get(sessionID); + if (existing) return existing; + const state: SessionState = { + messages: new Map(), + order: [], + completedUsers: new Set(), + completedCaptureIds: new Set(), + injectedInitialContext: false, + compactionNeedsContext: false, + }; + this.#states.set(sessionID, state); + return state; + } + + #inactiveToolResult(): string { + return JSON.stringify({ + success: false, + error: "This duplicate Supermemory V2 plugin instance is inactive", + }); + } + + #disposeRegistration(registration: Registration): void { + try { + void registration.dispose().catch((error) => { + this.#deps.logger("v2 registration cleanup failed", { + error: String(error), + }); + }); + } catch (error) { + this.#deps.logger("v2 registration cleanup failed", { + error: String(error), + }); + } + } + + #dispatchKey(messages: Message[], latestUser: Message, text: string): string { + if (latestUser.id) return latestUser.id; + const index = messages.lastIndexOf(latestUser); + let occurrence = 0; + for (let cursor = 0; cursor <= index; cursor += 1) { + const candidate = messages[cursor]; + if ( + candidate?.role === "user" && + extractMessageText(candidate) === text + ) { + occurrence += 1; + } + } + return `dispatch:${index}:${occurrence}:${sha256(text)}`; + } + + async #resolveSession(sessionID: string): Promise { + const state = this.#state(sessionID); + if (state.tags) return state.tags; + if (state.resolving) return state.resolving; + + state.resolving = (async () => { + const session = await this.#ctx.session.get({ sessionID }); + const directory = session.location?.directory; + if (!directory) { + throw new Error(`Unable to resolve directory for OpenCode session ${sessionID}`); + } + state.directory = directory; + state.tags = this.#deps.resolveTags(directory); + return state.tags; + })(); + + try { + return await state.resolving; + } finally { + state.resolving = undefined; + } + } + + #cacheContextMessages(state: SessionState, messages: Message[]): void { + const occurrences = new Map(); + for (const message of messages) { + if (message.role !== "user" && message.role !== "assistant") continue; + const text = extractMessageText(message); + if (!text) continue; + const occurrenceKey = `${message.role}:${sha256(text)}`; + const occurrence = occurrences.get(occurrenceKey) ?? 0; + occurrences.set(occurrenceKey, occurrence + 1); + const id = messageKey(message, text, occurrence); + const existing = state.messages.get(id); + if (existing) { + existing.contextText = text; + continue; + } + state.messages.set(id, { + id, + role: message.role, + contextText: text, + streamText: new Map(), + }); + state.order.push(id); + } + } + + #cacheAssistantText( + state: SessionState, + id: string, + ordinal: number, + text: string, + ): void { + let message = state.messages.get(id); + if (!message) { + message = { + id, + role: "assistant", + contextText: "", + streamText: new Map(), + }; + state.messages.set(id, message); + state.order.push(id); + } + message.streamText.set(ordinal, text); + } + + #markLatestTurnComplete(state: SessionState): void { + const latestUser = state.order.findLast((id) => state.messages.get(id)?.role === "user"); + if (latestUser) state.completedUsers.add(latestUser); + } + + async #buildInitialContext( + userMessage: string, + tags: ResolvedTags, + ): Promise { + if (this.#deps.config.autoRecallEveryPrompt) { + const [profileResult, userMemoriesResult, projectMemoriesListResult] = + await Promise.all([ + this.#deps.memoryClient.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + userMessage, + ), + this.#deps.memoryClient.searchMemoriesScoped( + userMessage, + tags.canonical, + tags.personalReads, + "personal", + ), + this.#deps.memoryClient.listMemoriesScoped( + tags.canonical, + tags.projectReads, + "project", + this.#deps.config.maxProjectMemories, + ), + ]); + + const projectMemories = { + results: (projectMemoriesListResult.memories ?? []).map((memory) => ({ + id: memory.id, + memory: memory.summary || memory.content || memory.title || "", + similarity: 1, + title: memory.title, + metadata: memory.metadata, + })), + }; + return formatContextForPrompt( + profileResult.success ? profileResult : null, + userMemoriesResult.success ? userMemoriesResult : { results: [] }, + projectMemories, + ); + } + + const profileResult = await this.#deps.memoryClient.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + ); + return formatContextForPrompt( + profileResult.success ? profileResult : null, + { results: [] }, + { results: [] }, + ); + } + + async #injectCompactionContext( + sessionID: string, + latestUser: Message | undefined, + ): Promise { + let memories: string[] = []; + try { + const tags = await this.#resolveSession(sessionID); + const result = await this.#deps.memoryClient.listMemoriesScoped( + tags.canonical, + tags.projectReads, + "project", + this.#deps.config.maxProjectMemories, + ); + memories = fitProjectMemories( + (result.memories ?? []) + .map((memory) => memory.summary || memory.content || "") + .filter((memory): memory is string => Boolean(memory)), + ); + } catch (error) { + this.#deps.logger("v2 compaction project-memory lookup failed", { + sessionID, + error: String(error), + }); + } + + const context = createCompactionPrompt(memories); + if (latestUser && !extractMessageText(latestUser).includes(COMPACTION_CONTEXT_MARKER)) { + injectIntoMessage(latestUser, context, "compaction"); + } + this.#deps.logger("v2 compaction context injected", { + sessionID, + memoriesCount: memories.length, + }); + } + + #captureTurns(state: SessionState): CaptureTurn[] { + return buildCachedCaptureTurns( + state.messages, + state.order, + state.completedUsers, + ); + } + + async #saveCaptureBatch( + sessionID: string, + state: SessionState, + batch: CaptureBatch, + reason: "cadence" | "session_end", + ): Promise { + const captureId = getCaptureId(sessionID, batch); + if (state.completedCaptureIds.has(captureId)) return; + const messages = batch.turns.flatMap((turn) => turn.messages); + if (messages.length === 0) { + state.completedCaptureIds.add(captureId); + return; + } + + const tags = state.tags ?? (await this.#resolveSession(sessionID)); + const result = await this.#deps.memoryClient.ingestConversation( + `${sessionID}:${batch.startTurn}-${batch.endTurn}`, + messages, + [tags.canonical], + { + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: "personal", + sm_capture_mode: "automatic", + captureReason: reason, + sessionId: sessionID, + turnStart: batch.startTurn, + turnEnd: batch.endTurn, + }, + { + defaultEntityContext: AGENT_ENTITY_CONTEXT, + customId: captureId, + }, + ); + + if (result.success) state.completedCaptureIds.add(captureId); + else { + this.#deps.logger("v2 capture failed", { + sessionID, + reason, + error: result.error, + }); + } + } + + async #captureCadence(sessionID: string, state: SessionState): Promise { + const turns = this.#captureTurns(state); + for (const batch of buildCadenceBatches( + turns, + this.#deps.config.captureEveryNTurns, + )) { + await this.#saveCaptureBatch(sessionID, state, batch, "cadence"); + } + } + + async #captureSessionEnd(sessionID: string, state: SessionState): Promise { + await this.#captureCadence(sessionID, state); + const turns = this.#captureTurns(state); + const finalBatch = buildSessionEndBatch( + turns, + this.#deps.config.captureEveryNTurns, + ); + if (finalBatch) { + await this.#saveCaptureBatch(sessionID, state, finalBatch, "session_end"); + } + } + + async #runCaptureExclusive( + sessionID: string, + task: () => Promise, + ): Promise { + const previous = this.#captureInFlight.get(sessionID) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(task); + this.#captureInFlight.set(sessionID, next); + try { + await next; + } finally { + if (this.#captureInFlight.get(sessionID) === next) { + this.#captureInFlight.delete(sessionID); + } + } + } + + async #retryPendingSummaries( + sessionID: string, + allowInactive = false, + ): Promise { + if (!allowInactive && !this.active) return; + const pending = [...this.#pendingSummaries.values()].filter( + (item) => item.sessionID === sessionID, + ); + if (pending.length === 0) return; + + let tags: ResolvedTags; + try { + tags = await this.#resolveSession(sessionID); + } catch (error) { + this.#deps.logger("v2 compaction summary retry deferred", { + sessionID, + error: String(error), + }); + return; + } + + for (const summary of pending) { + if (this.#summaryInFlight.has(summary.customId)) continue; + this.#summaryInFlight.add(summary.customId); + try { + const result = await this.#deps.memoryClient.addMemory( + `[Session Summary]\n${summary.text}`, + tags.canonical, + { + type: "conversation", + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: "personal", + sm_capture_mode: "compaction", + sessionId: sessionID, + }, + { + customId: summary.customId, + entityContext: AGENT_ENTITY_CONTEXT, + }, + ); + if (result.success) this.#pendingSummaries.delete(summary.customId); + else { + this.#deps.logger("v2 compaction summary save failed", { + sessionID, + error: result.error, + }); + } + } catch (error) { + this.#deps.logger("v2 compaction summary save failed", { + sessionID, + error: String(error), + }); + } finally { + this.#summaryInFlight.delete(summary.customId); + } + } + } + + #eventSessionID(event: V2Event): string | undefined { + const sessionID = event.data?.sessionID; + return typeof sessionID === "string" && sessionID ? sessionID : undefined; + } + + #startEventSubscription(): void { + const events = this.#ctx.event.subscribe({ signal: this.#abortController.signal }); + void (async () => { + try { + for await (const event of events) { + if (!this.active) return; + try { + await this.handleEvent(event as V2Event); + } catch (error) { + this.#deps.logger("v2 event handling failed", { + type: (event as V2Event).type, + error: String(error), + }); + } + } + } catch (error) { + if (this.active) { + this.#deps.logger("v2 event subscription failed", { + error: String(error), + }); + } + } + })(); + } +} + +const OWNER_KEY = Symbol.for("opencode-supermemory.v2.owner"); + +interface GlobalOwner { + generation: number; + cleanup: () => void; +} + +function ownerRegistry(): Record { + return globalThis as unknown as Record; +} + +export async function setupV2( + ctx: PluginContext, + options?: Partial, +): Promise<() => void> { + const registry = ownerRegistry(); + const previous = registry[OWNER_KEY]; + previous?.cleanup(); + + const owner: GlobalOwner = { + generation: (previous?.generation ?? 0) + 1, + cleanup: () => undefined, + }; + registry[OWNER_KEY] = owner; + + const runtime = new V2Runtime(ctx, options, () => registry[OWNER_KEY] === owner); + const cleanup = () => { + runtime.cleanup(); + if (registry[OWNER_KEY] === owner) delete registry[OWNER_KEY]; + }; + owner.cleanup = cleanup; + + try { + await runtime.register(); + } catch (error) { + cleanup(); + throw error; + } + + return cleanup; +}