diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f9b812..64e84af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,20 @@ jobs: jq -e '.plugins' .claude-plugin/marketplace.json >/dev/null - name: Run hook tests run: sh tests/run.sh + + opencode-plugin: + runs-on: ubuntu-latest + defaults: + run: + working-directory: .opencode-plugin + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install dependencies + run: npm ci + - name: Typecheck + run: npm run typecheck + - name: Run tests + run: npm test diff --git a/.gitignore b/.gitignore index b3821ab..fd68b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ # effect) can still recreate it. Same local-only policy applies regardless of # which convention wrote it. .agent/ + +# Deepwork progress files (local-only, not shared) +.slim/deepwork/ diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..a704baf --- /dev/null +++ b/.ignore @@ -0,0 +1,2 @@ +!.slim/deepwork/ +!.slim/deepwork/** diff --git a/.opencode-plugin/.gitignore b/.opencode-plugin/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.opencode-plugin/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/.opencode-plugin/package-lock.json b/.opencode-plugin/package-lock.json new file mode 100644 index 0000000..72e8634 --- /dev/null +++ b/.opencode-plugin/package-lock.json @@ -0,0 +1,469 @@ +{ + "name": "throughline-opencode", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "throughline-opencode", + "version": "1.0.0", + "devDependencies": { + "@opencode-ai/plugin": "^1.14.48", + "@types/node": "^20.0.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.18.tgz", + "integrity": "sha512-vqQeqJtn9c+J+tIQDzYk88xip/NVNN1hym1ATmckxo6zINHAoXoul4Sw/jgnvL00rLsfAvhja28qax4h3g/5Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.18", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.18.tgz", + "integrity": "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "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" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "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" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.opencode-plugin/package.json b/.opencode-plugin/package.json new file mode 100644 index 0000000..fb0d98c --- /dev/null +++ b/.opencode-plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "throughline-opencode", + "version": "1.0.0", + "description": "OpenCode plugin for throughline session memory", + "type": "module", + "main": "src/index.ts", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test dist/utils/redaction.test.js dist/integration.test.js" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.48", + "@types/node": "^20.0.0", + "typescript": "^5.4.0" + } +} diff --git a/.opencode-plugin/src/hooks/chat-message.ts b/.opencode-plugin/src/hooks/chat-message.ts new file mode 100644 index 0000000..351f5b8 --- /dev/null +++ b/.opencode-plugin/src/hooks/chat-message.ts @@ -0,0 +1,90 @@ +/** + * throughline — chat-message hook (maps to UserPromptSubmit). + * + * Captures the user's intent (redacted, truncated) to the session buffer. + * This records the "why" - the user's prompt - which otherwise lives only + * in the compactable conversation. + */ + +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import type { Hooks } from "@opencode-ai/plugin"; +import { + type ThroughlineContext, + tlActive, + tlSafeSid, + tlAppendLine, + tlErr, +} from "../lib.js"; +import { redactPrompt, clean, clamp } from "../utils/redaction.js"; + +type ChatMessageHook = NonNullable; +type ChatMessageInput = Parameters[0]; +type ChatMessageOutput = Parameters[1]; + +/** + * Extract user prompt text from message parts. + * + * UserMessage itself carries no `parts` — the text lives in the sibling + * `output.parts` array (see @opencode-ai/sdk UserMessage / Hooks["chat.message"]). + */ +function extractPromptText(output: ChatMessageOutput): string { + // Only capture user messages + if (output.message.role !== "user") return ""; + + // Concatenate all text parts + const textParts = output.parts + .filter((part) => part.type === "text" && part.text) + .map((part) => (part as { text: string }).text); + + return textParts.join(" "); +} + +/** + * Chat message hook implementation. + */ +export async function chatMessage( + ctx: ThroughlineContext, + input: ChatMessageInput, + output: ChatMessageOutput, +): Promise { + const state = tlActive(ctx); + if (!state.active) return; + + const sid = tlSafeSid(input.sessionID); + if (!sid) return; + + // Extract prompt text + const rawPrompt = extractPromptText(output); + if (!rawPrompt || rawPrompt.trim() === "") return; + + // Build the capture line + // 1. Clamp raw text to 2000 chars BEFORE redacting (performance) + // 2. Redact with prose-safe redaction + // 3. Clean control chars + // 4. Clamp to 200 chars for buffer + const clampedRaw = clamp(rawPrompt, 2000, ""); + const redacted = redactPrompt(clampedRaw); + const cleaned = clean(redacted); + const finalText = clamp(cleaned, 200, "…[truncated]"); + + if (!finalText || finalText.trim() === "") return; + + const bufDir = join(state.dataDir, "buffer"); + const line = `**prompt** ${finalText}`; + + // A chat message is normally the FIRST capture event of a session — the + // buffer dir does not exist yet at this point (tlActive() only bootstraps + // dataDir, not dataDir/buffer). Without this, appendFileSync in + // tlAppendLine throws ENOENT, is swallowed, and the session's opening + // prompt is silently dropped. See tool-execute-after.ts, which needs the + // same mkdir for the same reason. + try { + mkdirSync(bufDir, { recursive: true }); + } catch (err) { + tlErr(state.dataDir, `mkdir failed for buffer dir: ${err}`); + return; + } + + tlAppendLine(bufDir, sid, line); +} diff --git a/.opencode-plugin/src/hooks/session-compacted.ts b/.opencode-plugin/src/hooks/session-compacted.ts new file mode 100644 index 0000000..df2bd8b --- /dev/null +++ b/.opencode-plugin/src/hooks/session-compacted.ts @@ -0,0 +1,73 @@ +/** + * throughline — session-compacted hook (maps to PreCompact). + * + * Stamps a compaction boundary marker into the session buffer so a later + * handoff knows a compaction happened and treats actions above the line + * as "distill from buffer text alone, not conversation recall." + */ + +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + type ThroughlineContext, + tlDisabled, + tlDataDir, + tlSafeSid, + tlNow, + tlCleanCtrl, +} from "../lib.js"; + +interface SessionCompactedInput { + sessionID: string; +} + +/** + * Resolve session ID from OpenCode's sessionID. + */ +function resolveSid(sessionID: string): string { + return tlSafeSid(sessionID); +} + +/** + * Session compacted hook implementation. + */ +export async function sessionCompacted( + ctx: ThroughlineContext, + input: SessionCompactedInput, +): Promise { + if (tlDisabled()) return; + + const dataDir = tlDataDir(ctx); + const bufDir = join(dataDir, "buffer"); + + if (!existsSync(bufDir)) return; + + const sid = resolveSid(input.sessionID); + if (!sid) return; + + const bufPath = join(bufDir, `session-${sid}.md`); + if (!existsSync(bufPath)) return; + + // Idempotency guard: skip if buffer already ends with a boundary marker + try { + const content = readFileSync(bufPath, "utf-8"); + const lines = content.split("\n").filter((l) => l.trim() !== ""); + const lastLine = lines[lines.length - 1] || ""; + if (lastLine.startsWith("\n`; + + try { + appendFileSync(bufPath, marker, "utf-8"); + } catch { + // Silently fail - this is a marker, not critical + } +} diff --git a/.opencode-plugin/src/hooks/session-created.ts b/.opencode-plugin/src/hooks/session-created.ts new file mode 100644 index 0000000..969715a --- /dev/null +++ b/.opencode-plugin/src/hooks/session-created.ts @@ -0,0 +1,144 @@ +/** + * throughline — session-created hook (maps to SessionStart). + * + * Injects a context block at session start: a pointer to HANDOFF.md plus + * live git state. This automates the cheap half of orientation. + * + * Note: OpenCode's session.created hook doesn't inject text into context + * like Claude Code's SessionStart does. Instead, we log a message that + * the agent can read, or we could use experimental.chat.system.transform + * to inject into the system prompt. For now, we'll just ensure the data + * dir exists and log a message. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { execSync } from "node:child_process"; +import { + type ThroughlineContext, + tlDisabled, + tlDataRoot, + tlDataDir, + tlActive, + tlDataExists, +} from "../lib.js"; + +// Note: Unlike the shell version, this TypeScript port does NOT require jq. +// JSON parsing is native in TypeScript, so the shell's jq-availability check +// and warning are not applicable here. + +interface SessionCreatedInput { + sessionID: string; +} + +/** + * Get live git state (branch, status). + */ +function getGitState(root: string): { branch: string; status: string } | null { + try { + const branch = execSync(`git -C "${root}" rev-parse --abbrev-ref HEAD`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + const status = execSync(`git -C "${root}" status -s`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + return { branch, status }; + } catch { + return null; + } +} + +/** + * Session created hook implementation. + */ +export async function sessionCreated( + ctx: ThroughlineContext, + input: SessionCreatedInput, +): Promise { + if (tlDisabled()) return null; + + const root = ctx.directory; + const dataRoot = tlDataRoot(ctx); + const dataDir = tlDataDir(ctx); + + // Check if throughline is active + const state = tlActive(ctx); + const dataExists = tlDataExists(ctx); + + if (!dataExists && !state.active) { + // Distinguish deliberate opt-out from bootstrap failure + if (state.activeReason === "bootstrap-failed") { + return `⚠️ throughline could not create its data directory (${relative(dataRoot, dataDir)}) - check permissions/disk space. Capture will not run.`; + } + return null; + } + + const lines: string[] = []; + + // Header + lines.push("## throughline - project session context"); + lines.push(""); + + // Worktree sharing note + if (dataRoot !== root) { + lines.push( + `🔗 throughline data is shared with the main working tree at \`${dataRoot}\` (this is a linked worktree).`, + ); + lines.push(""); + } + + // Capture errors breadcrumb + const errPath = join(dataDir, ".capture-errors"); + if (existsSync(errPath)) { + try { + const errContent = readFileSync(errPath, "utf-8"); + const errCount = errContent.split("\n").filter((l) => l.trim()).length; + if (errCount > 0) { + lines.push( + `⚠️ ${errCount} capture failure(s) recorded in \`${relative(dataRoot, errPath)}\` - some actions may be missing. Check disk space / permissions.`, + ); + lines.push(""); + } + } catch { + // Ignore read errors + } + } + + // HANDOFF.md pointer + const handoffPath = join(dataDir, "HANDOFF.md"); + if (existsSync(handoffPath)) { + lines.push(`Durable handoff exists at \`${relative(dataRoot, handoffPath)}\` - read it before starting.`); + + // Extract "Last Updated" line + try { + const content = readFileSync(handoffPath, "utf-8"); + const match = content.match(/Last Updated:\s*(.+)/i); + if (match) { + lines.push(`Last Updated: ${match[1].trim()}`); + } + } catch { + // Ignore read errors + } + } else { + lines.push("No HANDOFF.md yet for this project. One will be written at the next handoff."); + } + + // Git state (if in worktree) + const gitState = getGitState(root); + if (gitState) { + lines.push(""); + lines.push("### Live git state"); + lines.push("```"); + lines.push(`branch: ${gitState.branch}`); + if (gitState.status) { + lines.push(gitState.status); + } + lines.push("```"); + } + + return lines.join("\n"); +} diff --git a/.opencode-plugin/src/hooks/session-idle.ts b/.opencode-plugin/src/hooks/session-idle.ts new file mode 100644 index 0000000..fa518af --- /dev/null +++ b/.opencode-plugin/src/hooks/session-idle.ts @@ -0,0 +1,84 @@ +/** + * throughline — session-idle hook. + * + * OpenCode's `session.idle` fires whenever the agent finishes a turn and + * goes idle — the docs' own example uses it to fire a "response is ready" + * desktop notification — NOT once at process exit the way Claude Code's + * SessionEnd does. A session with several user turns fires this many times. + * + * That makes a Claude-style one-shot idempotency guard (stamp once, skip + * forever after) wrong here: the FIRST idle would permanently stamp the + * buffer "ended" while the session keeps going, and onboard would then read + * an active session as abandoned. Instead this keeps exactly ONE marker, + * always at the true end: every idle strips any previous marker (wherever + * it landed — trailing, or buried under activity that resumed since) and + * re-appends a fresh one. onboard's use of it is "no capture activity since + * this point," which is accurate for every idle, not just a final one — + * there is no OpenCode event today that reliably fires only at true session + * end (session.deleted is explicit-delete, not exit). + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + type ThroughlineContext, + tlDisabled, + tlDataDir, + tlSafeSid, + tlNow, + tlCleanCtrl, +} from "../lib.js"; + +interface SessionIdleInput { + sessionID: string; +} + +// Matches a session-ended marker line, together with its surrounding blank +// line, ANYWHERE in the buffer (global, not end-anchored) — a marker from an +// earlier idle may no longer be trailing if activity resumed since, and it +// still needs to be removed so re-stamping never leaves more than one +// marker in the file. +const MARKER_BLOCK = /\n?\n?/g; + +/** + * Resolve session ID from OpenCode's sessionID. + */ +function resolveSid(sessionID: string): string { + return tlSafeSid(sessionID); +} + +/** + * Session idle hook implementation. + */ +export async function sessionIdle( + ctx: ThroughlineContext, + input: SessionIdleInput, +): Promise { + if (tlDisabled()) return; + + const dataDir = tlDataDir(ctx); + const bufDir = join(dataDir, "buffer"); + + if (!existsSync(bufDir)) return; + + const sid = resolveSid(input.sessionID); + if (!sid) return; + + const bufPath = join(bufDir, `session-${sid}.md`); + if (!existsSync(bufPath)) return; + + // Determine reason (OpenCode doesn't provide this, so default to "idle") + const reason = "idle"; + const cleanReason = tlCleanCtrl(reason); + const marker = `\n\n`; + + try { + const content = readFileSync(bufPath, "utf-8"); + // Remove any existing marker (wherever it is) before re-appending fresh + // at the true end — keeps exactly one marker in the file at all times. + const stripped = content.replace(MARKER_BLOCK, ""); + writeFileSync(bufPath, stripped + marker, "utf-8"); + } catch { + // Silently fail - this is a safety net, not critical + } +} diff --git a/.opencode-plugin/src/hooks/tool-execute-after.ts b/.opencode-plugin/src/hooks/tool-execute-after.ts new file mode 100644 index 0000000..238c352 --- /dev/null +++ b/.opencode-plugin/src/hooks/tool-execute-after.ts @@ -0,0 +1,166 @@ +/** + * throughline — tool-execute-after hook (maps to PostToolUse). + * + * Appends a structured one-line record of each captured action to the + * per-session buffer. This is the continuous raw layer that a later + * handoff distills from. Mechanical and cheap — no model call. + * Always exits cleanly; never blocks a tool. + * + * Which tools land here is decided by the switch below: the mutating + * tools (bash/edit/write) plus the high-signal read-side tools + * (grep/webfetch/websearch/task) and MCP tools (mcp__*). read and glob are + * deliberately skipped — they are the noisiest tools by far, and a buffer + * that logs every file read stops being skimmable. + * + * OpenCode's built-in tool ids are lowercase (`bash`, `edit`, `write`, + * `grep`, `webfetch`, `websearch`, `task`, ...) — confirmed against + * packages/opencode/src/tool/*.ts in anomalyco/opencode (the sst/opencode + * successor) and against `permission=...` lines in this machine's + * opencode.log. This does NOT match Claude Code's PascalCase tool names + * (Bash/Edit/Write/Grep/WebFetch/WebSearch/Task), so the arg field names + * below are OpenCode's own (`filePath`, not `file_path`; no NotebookEdit + * or Agent alias; the bash tool has no `description` field, only + * `command`/`timeout`/`workdir`). + * + * Port of hooks/session-capture.sh lines 71-147, adapted to the OpenCode + * tool surface rather than Claude Code's. + */ + +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import type { Hooks } from "@opencode-ai/plugin"; +import { + type ThroughlineContext, + tlActive, + tlSafeSid, + tlAppendLine, + tlErr, +} from "../lib.js"; +import { redact, redactPrompt, clean, clamp } from "../utils/redaction.js"; + +type ToolExecuteAfterHook = NonNullable; +type ToolExecuteAfterInput = Parameters[0]; +type ToolExecuteAfterOutput = Parameters[1]; + +/** + * Determine outcome suffix from tool result metadata. + * OpenCode's tool output may expose `interrupted`, `is_error`, or + * `exit_code`/`error`/`code` for bash. Match shell version's outcome() def. + */ +function outcome(tool: string, output: ToolExecuteAfterOutput): string { + const meta = output?.metadata ?? {}; + const resp = meta as Record; + + if (resp.interrupted === true) return " `[interrupted]`"; + if (resp.is_error === true) return " `[failed]`"; + + // bash-specific: check exit code + if (tool === "bash") { + const exitCode = resp.exit_code ?? resp.code ?? resp.returncode ?? 0; + if (resp.error || String(exitCode) !== "0") return " `[failed]`"; + } + + return ""; +} + +/** + * Tool execute after hook implementation. + */ +export async function toolExecuteAfter( + ctx: ThroughlineContext, + input: ToolExecuteAfterInput, + output: ToolExecuteAfterOutput, +): Promise { + const state = tlActive(ctx); + if (!state.active) return; + + const sid = tlSafeSid(input.sessionID); + if (!sid) return; + + const { tool, args } = input; + const root = ctx.directory; + const suffix = outcome(tool, output); + + let line = ""; + + switch (tool) { + case "bash": { + // OpenCode's bash tool takes command/timeout/workdir — no + // description field (unlike Claude Code's Bash tool). + const cmd = (args?.command as string) ?? ""; + if (!cmd) return; + line = `**bash**${suffix} \`${clamp(clean(redact(cmd)), 200, "…[truncated]")}\``; + break; + } + + case "edit": + case "write": { + const filePath = (args?.filePath as string) ?? ""; + if (!filePath) return; + // Show path relative to project root + const relPath = filePath.startsWith(root + "/") + ? filePath.slice(root.length + 1) + : filePath; + line = `**${tool}** ${clean(redact(relPath))}${suffix}`; + break; + } + + case "grep": { + const pattern = (args?.pattern as string) ?? ""; + if (!pattern) return; + // Grep pattern is not prose — use command-path redaction + line = `**grep** \`${clamp(clean(redact(pattern)), 120, "…")}\`${suffix}`; + break; + } + + case "webfetch": { + const url = (args?.url as string) ?? ""; + if (!url) return; + line = `**webfetch** ${clamp(clean(redact(url)), 200, "…")}${suffix}`; + break; + } + + case "websearch": { + const query = (args?.query as string) ?? ""; + if (!query) return; + // Natural-language query — prose-safe redaction + line = `**websearch** ${clamp(clean(redactPrompt(query)), 200, "…")}${suffix}`; + break; + } + + case "task": { + const subagentType = (args?.subagent_type as string) ?? ""; + // description // prompt: prefer description, fall back to prompt + const desc = + (args?.description as string) || (args?.prompt as string) || ""; + if (!desc && !subagentType) return; + // Natural-language description — prose-safe redaction + const redactedDesc = desc ? redactPrompt(desc) : ""; + const prefix = subagentType ? `${subagentType}: ` : ""; + line = `**agent** ${prefix}${clamp(clean(redactedDesc), 200, "…")}${suffix}`; + break; + } + + default: { + // MCP tools (mcp__server__tool) and any other matched tool: name only. + // Strip asterisks from tool name to avoid breaking markdown bold. + if (tool.startsWith("mcp__") || tool.includes("__")) { + const safeName = clean(tool).replace(/\*/g, ""); + line = `**${safeName}**${suffix}`; + } + break; + } + } + + if (!line) return; + + const bufDir = join(state.dataDir, "buffer"); + try { + mkdirSync(bufDir, { recursive: true }); + } catch (err) { + tlErr(state.dataDir, `mkdir failed for buffer dir: ${err}`); + return; + } + + tlAppendLine(bufDir, sid, line); +} diff --git a/.opencode-plugin/src/index.ts b/.opencode-plugin/src/index.ts new file mode 100644 index 0000000..2b35b2b --- /dev/null +++ b/.opencode-plugin/src/index.ts @@ -0,0 +1,86 @@ +/** + * throughline — OpenCode plugin entry point. + * + * Wires the 5 hooks to OpenCode's plugin API: + * - session-created, session-idle, session-compacted → via event handler + * - chat-message → via chat.message hook + * - tool-execute-after → via tool.execute.after hook + * - HANDOFF.md context injection → via experimental.chat.system.transform, + * since OpenCode's session.created event has no text-injection channel of + * its own (unlike Claude Code's SessionStart). This rides an + * `experimental.*` hook and may need to move if OpenCode's API changes. + * + * Types are imported from @opencode-ai/plugin rather than hand-rolled, so a + * payload-shape mismatch is a compile error instead of a silent no-op. + */ + +import type { Plugin, Hooks } from "@opencode-ai/plugin"; + +import { sessionCreated } from "./hooks/session-created.js"; +import { chatMessage } from "./hooks/chat-message.js"; +import { toolExecuteAfter } from "./hooks/tool-execute-after.js"; +import { sessionCompacted } from "./hooks/session-compacted.js"; +import { sessionIdle } from "./hooks/session-idle.js"; + +// --- Plugin implementation --- + +/** + * throughline plugin for OpenCode. + * + * Continuous, state-aware session memory. Captures what you did and what is, + * hands it off with judgment when the session wraps. + */ +export const ThroughlinePlugin: Plugin = async ({ directory, worktree }) => { + const tlCtx = { + directory, + worktree: worktree ?? directory, + }; + + // Context block built at session.created, consumed once by the next + // system-prompt transform for that session. `null` means "computed, but + // nothing to inject" (still consumed, so we don't recompute every turn). + const pendingContext = new Map(); + + const hooks: Hooks = { + // Direct hooks + "chat.message": async (input, output) => { + await chatMessage(tlCtx, input, output); + }, + + "tool.execute.after": async (input, output) => { + await toolExecuteAfter(tlCtx, input, output); + }, + + "experimental.chat.system.transform": async (input, output) => { + if (!input.sessionID || !pendingContext.has(input.sessionID)) return; + const block = pendingContext.get(input.sessionID); + pendingContext.delete(input.sessionID); + if (block) output.system.push(block); + }, + + // Event-based hooks + event: async ({ event }) => { + switch (event.type) { + case "session.created": { + const sessionID = event.properties.info.id; + const block = await sessionCreated(tlCtx, { sessionID }); + pendingContext.set(sessionID, block); + break; + } + + case "session.compacted": + await sessionCompacted(tlCtx, { sessionID: event.properties.sessionID }); + break; + + case "session.idle": + await sessionIdle(tlCtx, { sessionID: event.properties.sessionID }); + break; + } + }, + }; + + return hooks; +}; + +// Default export for OpenCode plugin loader +export default ThroughlinePlugin; diff --git a/.opencode-plugin/src/integration.test.ts b/.opencode-plugin/src/integration.test.ts new file mode 100644 index 0000000..5026107 --- /dev/null +++ b/.opencode-plugin/src/integration.test.ts @@ -0,0 +1,563 @@ +import assert from 'assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { execSync } from 'node:child_process'; + +// Import plugin components +import { ThroughlinePlugin as pluginFn } from './index.js'; +import { sessionCreated } from './hooks/session-created.js'; +import { chatMessage } from './hooks/chat-message.js'; +import { toolExecuteAfter } from './hooks/tool-execute-after.js'; +import { sessionCompacted } from './hooks/session-compacted.js'; +import { sessionIdle } from './hooks/session-idle.js'; +import { tlDataDir, tlSafeSid } from './lib.js'; + +// --- Real-shaped fixture builders -------------------------------------- +// +// These mirror what @opencode-ai/sdk's generated types (and the OpenCode +// docs' own event examples) actually put on the wire — NOT what would be +// convenient for the hook code to consume. A fixture shaped to match the +// hook's assumptions instead of the SDK's real payload is exactly how the +// original event-unwrapping and UserMessage.parts bugs shipped past this +// suite: the fixtures were wrong in the same way the code was, so nothing +// caught the mismatch. See dynamic/throughline#41 review notes. + +function sessionCreatedEvent(sessionID: string) { + return { + type: 'session.created' as const, + properties: { info: { id: sessionID } as any }, + }; +} + +function sessionIdleEvent(sessionID: string) { + return { type: 'session.idle' as const, properties: { sessionID } }; +} + +function sessionCompactedEvent(sessionID: string) { + return { type: 'session.compacted' as const, properties: { sessionID } }; +} + +// UserMessage (packages/sdk/dist/gen/types.gen.d.ts) has NO `parts` field — +// only `output.parts` (the sibling array) carries message content. +function userMessageOutput(text: string) { + return { + message: { role: 'user' as const, sessionID: 'x', id: 'm1' } as any, + parts: [{ type: 'text' as const, text, id: 'p1', sessionID: 'x', messageID: 'm1' }] as any, + }; +} + +function assistantMessageOutput(text: string) { + return { + message: { role: 'assistant' as const, sessionID: 'x', id: 'm1' } as any, + parts: [{ type: 'text' as const, text, id: 'p1', sessionID: 'x', messageID: 'm1' }] as any, + }; +} + +function toolOutput(overrides: Partial<{ title: string; output: string; metadata: any }> = {}) { + return { title: 'ok', output: 'ok', metadata: {}, ...overrides }; +} + +describe('Throughline Plugin Integration Tests', () => { + let tempDir: string; + let ctx: any; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'throughline-test-')); + ctx = { directory: tempDir, worktree: tempDir }; + + try { + execSync('git init', { cwd: tempDir, stdio: 'pipe' }); + execSync('git config user.name "Test User"', { cwd: tempDir, stdio: 'pipe' }); + execSync('git config user.email "test@example.com"', { cwd: tempDir, stdio: 'pipe' }); + } catch { + // git not available — tests that need it check independently + } + }); + + afterEach(() => { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + /** Path to a session's buffer file, derived the same way the plugin derives it. */ + function bufferPath(sessionID: string): string { + return join(tlDataDir(ctx), 'buffer', `session-${tlSafeSid(sessionID)}.md`); + } + + function readBuffer(sessionID: string): string { + const path = bufferPath(sessionID); + assert.ok(existsSync(path), `expected buffer file to exist: ${path}`); + return readFileSync(path, 'utf-8'); + } + + describe('Plugin Loading', () => { + it('should load the plugin and expose the expected hooks', async () => { + const hooks = await pluginFn(ctx); + assert.ok(hooks); + assert.equal(typeof hooks['chat.message'], 'function'); + assert.equal(typeof hooks['tool.execute.after'], 'function'); + assert.equal(typeof hooks['experimental.chat.system.transform'], 'function'); + assert.equal(typeof hooks.event, 'function'); + }); + }); + + // These drive the plugin exactly the way OpenCode itself would: through + // the hooks object returned by pluginFn(), using SDK-shaped event and + // hook-input/output objects. This is the regression gate for the + // event-unwrapping (session.*), UserMessage.parts, and lowercase-tool-id + // bugs — each only surfaces when exercised through index.ts, not when the + // inner hook function is called directly with a hand-shaped object. + describe('End-to-end through the plugin hooks object (real SDK shapes)', () => { + it('session.created bootstraps the data dir and queues a context block', async () => { + const hooks = await pluginFn(ctx); + const sessionID = 'e2e-created'; + + await hooks.event!({ event: sessionCreatedEvent(sessionID) as any }); + + assert.ok(existsSync(tlDataDir(ctx)), 'data dir should exist after session.created'); + }); + + it('experimental.chat.system.transform injects the queued context block exactly once', async () => { + const hooks = await pluginFn(ctx); + const sessionID = 'e2e-transform'; + + await hooks.event!({ event: sessionCreatedEvent(sessionID) as any }); + + const output1 = { system: [] as string[] }; + await hooks['experimental.chat.system.transform']!( + { sessionID, model: {} as any }, + output1, + ); + assert.equal(output1.system.length, 1, 'first transform call should inject the block'); + assert.ok(output1.system[0].includes('throughline')); + + const output2 = { system: [] as string[] }; + await hooks['experimental.chat.system.transform']!( + { sessionID, model: {} as any }, + output2, + ); + assert.equal(output2.system.length, 0, 'second call for the same session should inject nothing'); + }); + + it('chat.message captures a real UserMessage/parts payload to the buffer', async () => { + const hooks = await pluginFn(ctx); + const sessionID = 'e2e-chat'; + + await hooks['chat.message']!( + { sessionID } as any, + userMessageOutput('investigate the flaky test') as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**prompt**')); + assert.ok(content.includes('investigate the flaky test')); + }); + + it('chat.message ignores assistant messages', async () => { + const hooks = await pluginFn(ctx); + const sessionID = 'e2e-chat-assistant'; + + await hooks['chat.message']!( + { sessionID } as any, + assistantMessageOutput('here is my answer') as any, + ); + + assert.ok(!existsSync(bufferPath(sessionID)), 'assistant-only turn should not create a buffer'); + }); + + it('tool.execute.after captures OpenCode\'s real lowercase tool ids', async () => { + const hooks = await pluginFn(ctx); + const sessionID = 'e2e-tool-bash'; + + await hooks['tool.execute.after']!( + { tool: 'bash', sessionID, callID: 'c1', args: { command: 'echo hi' } } as any, + toolOutput() as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**bash**')); + assert.ok(content.includes('echo hi')); + }); + + it('tool.execute.after does NOT capture Claude Code-style PascalCase tool names', async () => { + const hooks = await pluginFn(ctx); + const sessionID = 'e2e-tool-pascal'; + + // Regression guard: OpenCode never sends "Bash" — only "bash". If this + // starts writing a line again, the tool-id casing regressed. + await hooks['tool.execute.after']!( + { tool: 'Bash', sessionID, callID: 'c1', args: { command: 'echo hi' } } as any, + toolOutput() as any, + ); + + assert.ok(!existsSync(bufferPath(sessionID)), 'PascalCase tool id should not be captured'); + }); + + it('session.idle and session.compacted resolve sessionID from event.properties', async () => { + const hooks = await pluginFn(ctx); + const sessionID = 'e2e-idle-compact'; + + await hooks['tool.execute.after']!( + { tool: 'bash', sessionID, callID: 'c1', args: { command: 'ls' } } as any, + toolOutput() as any, + ); + + await hooks.event!({ event: sessionCompactedEvent(sessionID) as any }); + await hooks.event!({ event: sessionIdleEvent(sessionID) as any }); + + const content = readBuffer(sessionID); + assert.ok(content.includes('compaction-boundary')); + assert.ok(content.includes('session-ended')); + }); + }); + + describe('Session Created Hook', () => { + it('creates the data directory and returns null or a string', async () => { + const result = await sessionCreated(ctx, { sessionID: 'test-session-123' }); + assert.ok(existsSync(tlDataDir(ctx))); + assert.ok(result === null || typeof result === 'string'); + }); + + it('includes a HANDOFF.md pointer when one exists', async () => { + const dataDir = tlDataDir(ctx); + mkdirSync(dataDir, { recursive: true }); + writeFileSync(join(dataDir, 'HANDOFF.md'), '# Handoff\nLast Updated: 2026-01-01\n'); + + const result = await sessionCreated(ctx, { sessionID: 'test-session-handoff' }); + assert.ok(result, 'expected a context block when HANDOFF.md exists'); + assert.ok(result!.includes('HANDOFF.md')); + assert.ok(result!.includes('Last Updated: 2026-01-01')); + }); + + it('sanitizes a dangerous session ID without throwing', async () => { + const dangerousId = 'test/123|dangerous'; + await assert.doesNotReject(() => sessionCreated(ctx, { sessionID: dangerousId })); + assert.ok(existsSync(tlDataDir(ctx))); + }); + }); + + describe('Chat Message Hook (direct call)', () => { + it('captures a user prompt to the buffer', async () => { + const sessionID = 'test-session-chat'; + await chatMessage(ctx, { sessionID } as any, userMessageOutput('This is a test user prompt') as any); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**prompt**')); + assert.ok(content.includes('test user prompt')); + }); + + it('creates the buffer dir on the very first capture of a session', async () => { + // Regression guard: chat.message used to skip the mkdir that + // tool.execute.after does, so a session's opening prompt (always the + // first capture event) silently vanished. + const sessionID = 'test-session-first-prompt'; + assert.ok(!existsSync(join(tlDataDir(ctx), 'buffer')), 'buffer dir should not exist yet'); + + await chatMessage(ctx, { sessionID } as any, userMessageOutput('first ever message') as any); + + const content = readBuffer(sessionID); + assert.ok(content.includes('first ever message')); + }); + + it('redacts a recognizable token prefix in user prompts', async () => { + // redactPrompt() is deliberately structural-only (PEM / auth schemes / + // known token prefixes) — it does NOT do generic keyword=value + // matching like redact() does, because that corrupts ordinary prose + // ("bearer of good news" → "Bearer ***"). A bare "password: secret123" + // is intentionally NOT masked here; see utils/redaction.ts. + const sessionID = 'test-session-redact'; + await chatMessage( + ctx, + { sessionID } as any, + userMessageOutput('set the token to ghp_abc123def456ghi789 before you push') as any, + ); + + const content = readBuffer(sessionID); + assert.ok(!content.includes('ghp_abc123def456ghi789')); + assert.ok(content.includes('ghp_***')); + }); + + it('does not capture non-user messages', async () => { + const sessionID = 'test-session-assistant'; + await chatMessage(ctx, { sessionID } as any, assistantMessageOutput('This is an assistant response') as any); + + assert.ok(!existsSync(bufferPath(sessionID))); + }); + }); + + describe('Tool Execute After Hook (direct call, real OpenCode tool ids)', () => { + it('captures bash tool executions', async () => { + const sessionID = 'test-session-bash'; + await toolExecuteAfter( + ctx, + { tool: 'bash', sessionID, callID: 'call-123', args: { command: 'echo "Hello, World!"' } } as any, + toolOutput() as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**bash**')); + assert.ok(content.includes('echo "Hello, World!"')); + }); + + it('captures edit tool executions using filePath', async () => { + const testFile = join(tempDir, 'test-file.txt'); + writeFileSync(testFile, 'original content'); + + const sessionID = 'test-session-edit'; + await toolExecuteAfter( + ctx, + { tool: 'edit', sessionID, callID: 'call-456', args: { filePath: testFile } } as any, + toolOutput() as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**edit**')); + assert.ok(content.includes('test-file.txt')); + }); + + it('captures grep tool executions', async () => { + const sessionID = 'test-session-grep'; + await toolExecuteAfter( + ctx, + { tool: 'grep', sessionID, callID: 'call-789', args: { pattern: 'hello world', path: '.' } } as any, + toolOutput() as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**grep**')); + assert.ok(content.includes('hello world')); + }); + + it('captures webfetch tool executions', async () => { + const sessionID = 'test-session-webfetch'; + await toolExecuteAfter( + ctx, + { tool: 'webfetch', sessionID, callID: 'call-101', args: { url: 'https://example.com' } } as any, + toolOutput() as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**webfetch**')); + assert.ok(content.includes('example.com')); + }); + + it('captures websearch tool executions with prose-safe redaction', async () => { + const sessionID = 'test-session-websearch'; + await toolExecuteAfter( + ctx, + { tool: 'websearch', sessionID, callID: 'call-102', args: { query: 'how to fix token refresh bug' } } as any, + toolOutput() as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**websearch**')); + // Prose-safe redaction must not mangle a "token" that isn't a secret. + assert.ok(content.includes('token refresh bug')); + }); + + it('captures task tool executions', async () => { + const sessionID = 'test-session-task'; + await toolExecuteAfter( + ctx, + { + tool: 'task', + sessionID, + callID: 'call-103', + args: { description: 'audit the redaction rules', subagent_type: 'general-purpose' }, + } as any, + toolOutput() as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**agent**')); + assert.ok(content.includes('general-purpose')); + assert.ok(content.includes('audit the redaction rules')); + }); + + it('does not capture read or glob (deliberately excluded, noisy tools)', async () => { + const sessionID = 'test-session-noisy'; + await toolExecuteAfter( + ctx, + { tool: 'read', sessionID, callID: 'c1', args: { filePath: '/x' } } as any, + toolOutput() as any, + ); + await toolExecuteAfter( + ctx, + { tool: 'glob', sessionID, callID: 'c2', args: { pattern: '**/*.ts' } } as any, + toolOutput() as any, + ); + + assert.ok(!existsSync(bufferPath(sessionID))); + }); + + it('captures MCP tools by name only', async () => { + const sessionID = 'test-session-mcp'; + await toolExecuteAfter( + ctx, + { tool: 'mcp__github__create_issue', sessionID, callID: 'c1', args: { title: 'x' } } as any, + toolOutput() as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('**mcp__github__create_issue**')); + }); + + it('redacts sensitive info in tool args', async () => { + const sessionID = 'test-session-sensitive'; + await toolExecuteAfter( + ctx, + { + tool: 'bash', + sessionID, + callID: 'call-112', + args: { command: 'curl -H "Authorization: Bearer secret123" https://api.example.com' }, + } as any, + toolOutput() as any, + ); + + const content = readBuffer(sessionID); + assert.ok(!content.includes('secret123')); + }); + + it('appends `[failed]` when the tool result reports an error', async () => { + const sessionID = 'test-session-failed'; + await toolExecuteAfter( + ctx, + { tool: 'bash', sessionID, callID: 'c1', args: { command: 'false' } } as any, + toolOutput({ metadata: { is_error: true } }) as any, + ); + + const content = readBuffer(sessionID); + assert.ok(content.includes('[failed]')); + }); + }); + + describe('Session Compacted Hook', () => { + it('stamps a compaction boundary in the buffer', async () => { + const sessionID = 'test-session-compact'; + await toolExecuteAfter( + ctx, + { tool: 'bash', sessionID, callID: 'call-123', args: { command: 'ls' } } as any, + toolOutput() as any, + ); + + await sessionCompacted(ctx, { sessionID }); + + const content = readBuffer(sessionID); + assert.ok(content.includes('compaction-boundary')); + assert.ok(content.includes('auto')); + }); + + it('does not duplicate a trailing compaction boundary marker', async () => { + const sessionID = 'test-session-no-dup'; + const path = bufferPath(sessionID); + const bufferDir = join(tlDataDir(ctx), 'buffer'); + execSync(`mkdir -p "${bufferDir}"`); + writeFileSync( + path, + '- `x` **bash** `ls`\n\n', + ); + + await sessionCompacted(ctx, { sessionID }); + + const content = readFileSync(path, 'utf-8'); + const boundaryCount = (content.match(/compaction-boundary/g) || []).length; + assert.strictEqual(boundaryCount, 1); + }); + }); + + describe('Session Idle Hook', () => { + it('stamps a session-ended marker in the buffer', async () => { + const sessionID = 'test-session-idle'; + await toolExecuteAfter( + ctx, + { tool: 'bash', sessionID, callID: 'call-789', args: { command: 'pwd' } } as any, + toolOutput() as any, + ); + + await sessionIdle(ctx, { sessionID }); + + const content = readBuffer(sessionID); + assert.ok(content.includes('session-ended')); + assert.ok(content.includes('(idle)')); + }); + + it('replaces a still-trailing marker instead of stacking duplicates (last-wins)', async () => { + // session.idle fires after EVERY turn in OpenCode, not once at exit — + // repeated idles with no activity in between must not accumulate markers. + const sessionID = 'test-session-repeat-idle'; + await toolExecuteAfter( + ctx, + { tool: 'bash', sessionID, callID: 'c1', args: { command: 'ls' } } as any, + toolOutput() as any, + ); + + await sessionIdle(ctx, { sessionID }); + await sessionIdle(ctx, { sessionID }); + await sessionIdle(ctx, { sessionID }); + + const content = readBuffer(sessionID); + const endedCount = (content.match(/session-ended/g) || []).length; + assert.strictEqual(endedCount, 1, 'repeated idles must not stack markers'); + }); + + it('moves the marker to the true end when activity resumes after an idle', async () => { + const sessionID = 'test-session-resume-after-idle'; + await toolExecuteAfter( + ctx, + { tool: 'bash', sessionID, callID: 'c1', args: { command: 'ls' } } as any, + toolOutput() as any, + ); + await sessionIdle(ctx, { sessionID }); + + // Activity resumes — the buffer picks back up after the marker. + await toolExecuteAfter( + ctx, + { tool: 'bash', sessionID, callID: 'c2', args: { command: 'pwd' } } as any, + toolOutput() as any, + ); + await sessionIdle(ctx, { sessionID }); + + const content = readBuffer(sessionID); + const endedCount = (content.match(/session-ended/g) || []).length; + assert.strictEqual(endedCount, 1, 'still only one marker'); + + const lines = content.trim().split('\n'); + assert.ok( + lines[lines.length - 1].startsWith('