mirror of
https://github.com/bendtherules/opencode-plugin-compaction-prompt.git
synced 2026-08-18 13:42:55 +00:00
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.
176 lines
5.6 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|
|
};
|