Files
bendtherules 9f549acfde fix: correct $ARGUMENTS token in plugin-compaction-init template
OpenCode's argument token is $ARGUMENTS, not $ARGUMENTS$. With the
extra dollar sign, OpenCode did not substitute the token, so the LLM
ended up writing the literal $ARGUMENTS$ line into the memory file
when no arguments were passed.
2026-08-11 12:55:37 +05:30

176 lines
5.6 KiB
TypeScript

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 =
"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") {
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<void>,
): Promise<string> {
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<Hooks> => {
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<void> => {
await context.client.app.log({
body: {
level: "error",
message,
service: "opencode-plugin-compaction-prompt",
},
});
};
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 (
_input,
output,
): Promise<void> => {
const memory = await readMemory(memoryPath, logError);
const marker =
prompt || memory ? completionMarker : skippedCompactionMarker;
const instructions = buildInstructions(prompt, marker, memory);
if (mode === "replace") {
output.prompt = instructions;
} else {
output.context.push(instructions);
}
},
};
};