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, ): 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 { 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, ): Promise => { 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); } }, }; };