diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b258b..e1ebd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.3.0 + +- Auto-register a `/plugin-compaction-init` slash command that scaffolds the compaction memory file with `## Keep` and `## Discard` sections of classification rules. User-supplied arguments are appended to extend the defaults. + ## 0.2.4 - Normalize npm package repository metadata. diff --git a/README.md b/README.md index 7fa92ad..0755a9b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,11 @@ You can also add it manually to your global `opencode.jsonc`: Create `.opencode/compaction.md` in the project when you have project-specific context to preserve. The file is optional. +### /plugin-compaction-init + +- Writes the memory file at the configured `memoryFile` path with a top instruction line and `## Keep` and `## Discard` sections containing classification rules for which messages to preserve or drop. +- **Recommended**: pass user instructions to extend the default rules with concrete topics — specific areas of discussion you want kept or dropped. + ## Options | Option | Default | Description | diff --git a/src/index.ts b/src/index.ts index 2b06710..b3fd736 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,38 @@ const defaultCompletionMarker = "opencode-plugin-compaction-prompt: Custom compaction done."; const skippedCompactionMarker = "opencode-plugin-compaction-prompt: No custom compaction applied."; +const initCommandName = "plugin-compaction-init"; + +/** + * Builds the prompt the auto-registered `/plugin-compaction-init` slash + * command sends to the model. `$ARGUMENTS$` is left unsubstituted so + * OpenCode substitutes it at invocation time. + * + * @param memoryFile Literal path to the memory file, inserted verbatim. + * @returns The full command-body template. + */ +export function buildInitCommandTemplate(memoryFile: string): string { + return `You are creating the file \`${memoryFile}\` (relative to the current project root). It is a meta-classification of which messages to preserve and which to discard during session compaction, not a summary of details. + +Follow this format: + +\`\`\` +Keep all details related to the keep topics below, and discard everything related to the discard topics below. + +## Keep + +## Discard +\`\`\` + +\`## Keep\` contains one bullet per active in-progress topic. Each bullet is a **classification rule** describing how to recognize messages that discuss that topic, including its requirements, design decisions, dependency information, and ongoing discussions needed to continue the work. + +\`## Discard\` contains one bullet per older or already-finished topic. Each bullet is a **classification rule** describing how to recognize messages to drop — features/tasks already shipped, abandoned, or otherwise not relevant to the next session, including any debug loops or repeated fix attempts that have already concluded. Add a final catch-all bullet: + +- Any other older discussions not mentioned above. + +$ARGUMENTS$ +`; +} function asString(value: unknown): string | undefined { if (typeof value !== "string") { @@ -106,6 +138,24 @@ export const CompactionPromptPlugin: Plugin = async ( }; return { + config: async (cfg): Promise => { + if (cfg.command?.[initCommandName]) { + await context.client.app.log({ + body: { + level: "warn", + message: `Skipping registration of "${initCommandName}": already defined in config.`, + service: "opencode-plugin-compaction-prompt", + }, + }); + return; + } + + cfg.command ??= {}; + cfg.command[initCommandName] = { + description: `Create ${memoryFile} [compaction file]`, + template: buildInitCommandTemplate(memoryFile), + }; + }, "experimental.session.compacting": async ( _input, output, diff --git a/test/index.test.ts b/test/index.test.ts index 7b59e05..52a0c4e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,7 +2,10 @@ 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"; +import { + buildInitCommandTemplate, + CompactionPromptPlugin, +} from "../src/index.js"; const temporaryDirectories: string[] = []; @@ -159,3 +162,105 @@ describe("CompactionPromptPlugin", () => { ); }); }); + +describe("buildInitCommandTemplate", () => { + test("substitutes the configured memory file path verbatim", () => { + const template = buildInitCommandTemplate("docs/agent-memory.md"); + + expect(template).toContain("`docs/agent-memory.md`"); + expect(template).not.toContain(".opencode/compaction.md"); + }); + + test("contains the file skeleton and both section headings", () => { + const template = buildInitCommandTemplate(".opencode/compaction.md"); + + expect(template).toContain("```\nKeep all details related to"); + expect(template).toContain("## Keep\n"); + expect(template).toContain("## Discard\n"); + }); + + test("leaves $ARGUMENTS$ unsubstituted for OpenCode to fill in", () => { + const template = buildInitCommandTemplate(".opencode/compaction.md"); + + expect(template.trimEnd().endsWith("$ARGUMENTS$")).toBe(true); + }); +}); + +describe("CompactionPromptPlugin config hook", () => { + test("registers the plugin-compaction-init command by default", async () => { + const context = await createContext(); + const hooks = await CompactionPromptPlugin(context as never); + const cfg: { + command?: Record; + } = {}; + + await hooks.config?.(cfg as never); + + expect(cfg.command?.["plugin-compaction-init"]).toBeDefined(); + expect( + cfg.command?.["plugin-compaction-init"].description.length, + ).toBeGreaterThan(0); + expect(cfg.command?.["plugin-compaction-init"].template).toContain( + ".opencode/compaction.md", + ); + expect(cfg.command?.["plugin-compaction-init"].template).toContain( + "## Keep", + ); + expect(cfg.command?.["plugin-compaction-init"].template).toContain( + "## Discard", + ); + expect(cfg.command?.["plugin-compaction-init"].template).toContain( + "$ARGUMENTS$", + ); + expect(context.logs).toHaveLength(0); + }); + + test("uses the configured memoryFile in the registered template", async () => { + const context = await createContext(); + const hooks = await CompactionPromptPlugin(context as never, { + memoryFile: "notes/agent.md", + }); + const cfg: { + command?: Record; + } = {}; + + await hooks.config?.(cfg as never); + + expect(cfg.command?.["plugin-compaction-init"].template).toContain( + "`notes/agent.md`", + ); + expect(cfg.command?.["plugin-compaction-init"].template).not.toContain( + ".opencode/compaction.md", + ); + }); + + test("skips registration and logs a warning when the command is already defined", async () => { + const context = await createContext(); + const hooks = await CompactionPromptPlugin(context as never); + const userTemplate = "user-provided template body"; + const cfg: { + command?: Record; + } = { + command: { + "plugin-compaction-init": { + description: "user description", + template: userTemplate, + }, + }, + }; + + await hooks.config?.(cfg as never); + + expect(cfg.command?.["plugin-compaction-init"].template).toBe(userTemplate); + expect(context.logs).toHaveLength(1); + expect(context.logs[0]).toMatchObject({ + body: { + level: "warn", + service: "opencode-plugin-compaction-prompt", + }, + }); + expect( + (context.logs[0] as { body?: { message?: string } }).body?.message, + ).toContain("plugin-compaction-init"); + }); +});