commit 9b70b18295a41d2289703a7f9e8b816d88c4fca0 Author: bendtherules Date: Sat Aug 1 16:51:37 2026 +0530 feat: add compaction prompt plugin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f3d87e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bun test + - run: bun run typecheck + - run: bun run build + - run: bun run pack:check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bdd52e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..515ffeb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial standalone release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a04d76 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# opencode-plugin-compaction-prompt + +Customize OpenCode's compaction prompt to guide which context carries forward, which details are omitted, and what the next session should retain. + +## Install + +```bash +opencode plugin opencode-plugin-compaction-prompt +``` + +Or add it to `opencode.json` manually: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + [ + "opencode-plugin-compaction-prompt", + { + "memoryFile": ".opencode/compaction.md", + "mode": "append", + "completionMarker": "Custom compaction request honored." + } + ] + ] +} +``` + +Create `.opencode/compaction.md` in the project when you have project-specific context to preserve. The file is optional. + +## Options + +| Option | Default | Description | +| ------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| `memoryFile` | `.opencode/compaction.md` | File resolved relative to the active worktree. | +| `mode` | `append` | Append instructions to OpenCode's default prompt, or use `replace` to provide a complete prompt. | +| `prompt` | Built-in preservation prompt | Additional instructions used together with `memoryFile`; both are included in the compaction instructions. | +| `completionMarker` | `Custom compaction request honored.` | Exact text the model is asked to append at the end of the summary. | + +Append mode is the recommended default because it preserves OpenCode's built-in compaction behavior. Replace mode is available when the complete prompt needs to be controlled by this plugin. + +## Development + +```bash +bun install +bun test +bun run typecheck +bun run build +bun run pack:check +``` + +The npm package exposes the compiled entrypoint at `dist/index.js` and TypeScript declarations at `dist/index.d.ts`. + +## Compatibility + +The plugin uses OpenCode's `experimental.session.compacting` hook. OpenCode may change experimental plugin APIs between releases; test the package against the OpenCode version you support. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..b14e8e0 --- /dev/null +++ b/bun.lock @@ -0,0 +1,93 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "opencode-plugin-compaction-prompt", + "devDependencies": { + "@opencode-ai/plugin": "^1.18.10", + "@types/bun": "^1.3.14", + "@types/node": "^25.6.0", + "prettier": "^3.6.2", + "typescript": "^6.0.3", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@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/plugin": ["@opencode-ai/plugin@1.18.10", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.10", "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" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-l4R1ulu5wtA0+cCinzdSPkEO8ZCdIgb/ZjltzstIWQqRqeH3H880OabNaeje/ZCkSoAT8c/o9oT3tdjyFP7o7g=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.10", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-K+glWbBp5pfGepZ/6EHvuXY3IpVG2qgsEqF4z9mOiUQUrs6KWN72Vdp15Qsl2TCDbemgHWXKQKgbX3b8PSK2hg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.83", "", { "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" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "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=="], + + "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], + + "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=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "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=="], + + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/examples/compaction.md b/examples/compaction.md new file mode 100644 index 0000000..10e927e --- /dev/null +++ b/examples/compaction.md @@ -0,0 +1,6 @@ +There are two features in progress: + +- Preserve everything related to session export feature. +- Do not preserve discussions related to email notifications. + +Keep the relevant file line numbers and short code snippets. diff --git a/examples/opencode.json b/examples/opencode.json new file mode 100644 index 0000000..a66eab0 --- /dev/null +++ b/examples/opencode.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + [ + "opencode-plugin-compaction-prompt", + { + "memoryFile": ".opencode/compaction.md", + "mode": "append", + "prompt": "", + "completionMarker": "Custom compaction request honored." + } + ] + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..805f858 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "opencode-plugin-compaction-prompt", + "version": "0.1.0", + "description": "Customize OpenCode's compaction prompt to preserve important context and omit unnecessary details.", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "scripts": { + "build": "bun build src/index.ts --outdir dist --target bun --format esm --sourcemap=external && tsc -p tsconfig.build.json", + "clean": "rm -rf dist", + "test": "bun test", + "typecheck": "tsc --noEmit -p tsconfig.json", + "pack:check": "npm pack --dry-run", + "prepublishOnly": "bun run build", + "format": "prettier --write src test *.json examples/*.json examples/*.md README.md", + "format:check": "prettier --check src test *.json examples/*.json examples/*.md README.md" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.18.10", + "@types/bun": "^1.3.14", + "@types/node": "^25.6.0", + "prettier": "^3.6.2", + "typescript": "^6.0.3" + }, + "engines": { + "bun": ">=1.3.0" + }, + "keywords": [ + "opencode", + "opencode-plugin", + "compaction", + "context" + ], + "license": "MIT" +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e083e9d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,120 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { Hooks, Plugin, PluginOptions } from "@opencode-ai/plugin"; + +type CompactionMode = "append" | "replace"; + +export type CompactionOptions = PluginOptions & { + memoryFile?: unknown; + mode?: unknown; + prompt?: unknown; + completionMarker?: unknown; +}; + +const defaultMemoryFile = ".opencode/compaction.md"; +const defaultPrompt = ""; +const defaultCompletionMarker = "Custom compaction request honored."; + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmedValue = value.trim(); + return trimmedValue || undefined; +} + +function buildInstructions( + prompt: string, + completionMarker: string, + memory: string, +): string { + const sections = [ + "## User Compaction Instructions", + `These instructions take precedence over previous instructions if there is conflict. At the very end of the summary, echo exactly: **${completionMarker}**`, + prompt, + ]; + + if (memory) { + sections.push(memory); + } + + return sections.join("\n"); +} + +async function readMemory( + memoryPath: string, + logError: (message: string) => Promise, +): Promise { + try { + return (await readFile(memoryPath, "utf8")).trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + await logError(`Unable to read compaction memory file: ${memoryPath}`); + } + + return ""; + } +} + +/** + * Adds configured instructions and optional project memory to OpenCode's + * compaction prompt. + * + * Append mode preserves OpenCode's built-in compaction prompt and adds the + * plugin instructions as context. Replace mode supplies a complete prompt + * containing the plugin instructions instead. The memory file is resolved + * relative to the active worktree and may be absent. + * + * @example + * ```json + * { + * "plugin": [["opencode-plugin-compaction-prompt", { + * "memoryFile": ".opencode/compaction.md", + * "mode": "append" + * }]] + * } + * ``` + * + * @param context OpenCode's plugin context, including the active worktree. + * @param options User-provided plugin configuration. + * @returns The hooks registered by the plugin. + */ +export const CompactionPromptPlugin: Plugin = async ( + context, + options?: PluginOptions, +): Promise => { + const configured = (options ?? {}) as CompactionOptions; + const memoryFile = asString(configured.memoryFile) ?? defaultMemoryFile; + const prompt = asString(configured.prompt) ?? defaultPrompt; + const completionMarker = + asString(configured.completionMarker) ?? defaultCompletionMarker; + const mode: CompactionMode = + configured.mode === "replace" ? "replace" : "append"; + const memoryPath = path.resolve(context.worktree, memoryFile); + const logError = async (message: string): Promise => { + await context.client.app.log({ + body: { + level: "error", + message, + service: "opencode-plugin-compaction-prompt", + }, + }); + }; + + return { + "experimental.session.compacting": async ( + _input, + output, + ): Promise => { + const memory = await readMemory(memoryPath, logError); + const instructions = buildInstructions(prompt, completionMarker, memory); + + if (mode === "replace") { + output.prompt = instructions; + } else { + output.context.push(instructions); + } + }, + }; +}; diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..0fef545 --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { CompactionPromptPlugin } from "../src/index.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async (): Promise => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function createContext(): Promise<{ + worktree: string; + logs: Array; + client: { app: { log: (input: unknown) => Promise } }; +}> { + const worktree = await mkdtemp( + path.join(os.tmpdir(), "opencode-compaction-"), + ); + temporaryDirectories.push(worktree); + const logs: Array = []; + + return { + worktree, + logs, + client: { + app: { + log: async (input: unknown): Promise => { + logs.push(input); + }, + }, + }, + }; +} + +describe("CompactionPromptPlugin", () => { + test("appends instructions and memory by default", async () => { + const context = await createContext(); + await mkdir(path.join(context.worktree, ".opencode")); + await writeFile( + path.join(context.worktree, ".opencode/compaction.md"), + "Keep the active hypothesis.", + ); + const hooks = await CompactionPromptPlugin(context as never); + const output = { context: [] as string[] }; + + await hooks["experimental.session.compacting"]?.( + { sessionID: "test" }, + output, + ); + + expect(output.context[0]).toContain("Keep the active hypothesis."); + expect(output.context[0]).toContain("Custom compaction request honored."); + }); + + test("combines custom instructions with a configured memory file", async () => { + const context = await createContext(); + await writeFile( + path.join(context.worktree, "project-memory.md"), + "Preserve the active hypothesis.", + ); + const hooks = await CompactionPromptPlugin(context as never, { + memoryFile: "project-memory.md", + prompt: "Keep exact file paths.", + completionMarker: "Compaction complete.", + }); + const output = { context: ["Existing context"] }; + + await hooks["experimental.session.compacting"]?.( + { sessionID: "test" }, + output, + ); + + expect(output.context).toHaveLength(2); + expect(output.context[0]).toBe("Existing context"); + expect(output.context[1]).toContain("Keep exact file paths."); + expect(output.context[1]).toContain("Preserve the active hypothesis."); + expect(output.context[1]).toContain("Compaction complete."); + }); + + test("supports replacing the default prompt", async () => { + const context = await createContext(); + const hooks = await CompactionPromptPlugin(context as never, { + mode: "replace", + prompt: "Preserve the current implementation plan.", + }); + const output: { context: string[]; prompt?: string } = { context: [] }; + + await hooks["experimental.session.compacting"]?.( + { sessionID: "test" }, + output, + ); + + expect(output.prompt).toContain("User Compaction Instructions"); + expect(output.prompt).toContain( + "Preserve the current implementation plan.", + ); + expect(output.context).toHaveLength(0); + }); + + test("ignores a missing memory file", async () => { + const context = await createContext(); + const hooks = await CompactionPromptPlugin(context as never); + const output = { context: [] as string[] }; + + await hooks["experimental.session.compacting"]?.( + { sessionID: "test" }, + output, + ); + + expect(context.logs).toHaveLength(0); + expect(output.context).toHaveLength(1); + }); + + test("logs non-missing memory file errors without failing compaction", async () => { + const context = await createContext(); + await writeFile(path.join(context.worktree, "memory"), "not a directory"); + const hooks = await CompactionPromptPlugin(context as never, { + memoryFile: "memory/file.md", + }); + const output = { context: [] as string[] }; + + await hooks["experimental.session.compacting"]?.( + { sessionID: "test" }, + output, + ); + + expect(context.logs).toHaveLength(1); + expect(output.context).toHaveLength(1); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..b2fadfa --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..eb2dff9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["bun", "node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}