feat: auto-register plugin-compaction-init slash command

Adds a custom OpenCode command that scaffolds the compaction memory
file with ## Keep and ## Discard sections of classification rules.
The configured memoryFile path is substituted into both the command
description and the prompt template, and any arguments passed after
the command name are appended as user instructions.
This commit is contained in:
2026-08-11 12:48:11 +05:30
parent edb5bb47f4
commit e8c76ea922
4 changed files with 165 additions and 1 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog # 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 ## 0.2.4
- Normalize npm package repository metadata. - Normalize npm package repository metadata.
+5
View File
@@ -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. Create `.opencode/compaction.md` in the project when you have project-specific context to preserve. The file is optional.
### /plugin-compaction-init <instructions>
- 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 ## Options
| Option | Default | Description | | Option | Default | Description |
+50
View File
@@ -17,6 +17,38 @@ const defaultCompletionMarker =
"opencode-plugin-compaction-prompt: Custom compaction done."; "opencode-plugin-compaction-prompt: Custom compaction done.";
const skippedCompactionMarker = const skippedCompactionMarker =
"opencode-plugin-compaction-prompt: No custom compaction applied."; "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 { function asString(value: unknown): string | undefined {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -106,6 +138,24 @@ export const CompactionPromptPlugin: Plugin = async (
}; };
return { return {
config: async (cfg): Promise<void> => {
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 ( "experimental.session.compacting": async (
_input, _input,
output, output,
+106 -1
View File
@@ -2,7 +2,10 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { CompactionPromptPlugin } from "../src/index.js"; import {
buildInitCommandTemplate,
CompactionPromptPlugin,
} from "../src/index.js";
const temporaryDirectories: string[] = []; 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<string, { description: string; template: string }>;
} = {};
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<string, { description: string; template: string }>;
} = {};
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<string, { description: string; template: string }>;
} = {
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");
});
});