diff --git a/src/plugin.ts b/src/plugin.ts index 3f2c9d3..1df9222 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -46,38 +46,65 @@ import { } from "./opencode.ts"; /** - * Generate the memory candidate instruction to include in compaction context. + * Strip XML-like tags from text for Markdown-neutral rendering. + * Converts `` blocks to plain "Workspace memory:" sections. */ -function memoryCandidateInstruction(): string { +function stripXmlTags(text: string): string { + if (!text) return ""; + + // Replace XML tag pairs with Markdown headers + return text + .replace(/\n?/gi, "## Workspace Memory\n") + .replace(/<\/workspace_memory>\n?/gi, "") + .replace(/\n?/gi, "## Hot Session State\n") + .replace(/<\/hot_session_state>\n?/gi, "") + .replace(/\n?/gi, "## Pending Todos\n") + .replace(/<\/pending_todos>\n?/gi, ""); +} + +/** + * Generate instructions for the compaction model. + * IMPORTANT: These instructions make clear what should NOT be in the final output. + */ +function compactionContextHeader(): string { return ` -At the end of the compaction summary, include: +[PRIVATE COMPACTION CONTEXT - DO NOT OUTPUT] +The following sections are PRIVATE INPUT for updating the compaction summary. +DO NOT copy these sections, their headings, or their contents verbatim. +Use the facts only to update the normal summary sections (Goal, Progress, etc.). + +At the VERY END of your summary, you MAY include ONE output block: -- [feedback] ... -- [project] ... -- [decision] ... -- [reference] ... +- [type] content (types: feedback, project, decision, reference) -Only include durable information useful across future sessions in this exact workspace. -Do NOT include active file lists, raw errors, temporary progress, stack traces, code signatures, API docs, git history, or facts easily rediscovered from the repository. -For decisions, include rationale in one sentence. -If nothing qualifies, output an empty block. +Only include truly durable information useful across FUTURE sessions. +If nothing qualifies, omit the block entirely. + +[END PRIVATE COMPACTION CONTEXT] `.trim(); } /** - * Render todos for compaction context. + * Generate the memory candidate instruction. + * This is included in compactionContextHeader() above. */ -function renderTodos(todos: Array<{ content: string; status: string; priority?: string }>): string { - if (todos.length === 0) return ""; +function memoryCandidateInstruction(): string { + return ""; +} - const lines = [""]; +/** + * Render todos for compaction context (Markdown-neutral format). + */ +function renderTodosForCompaction(todos: Array<{ content: string; status: string; priority?: string }>): string { + if (todos.length === 0) return ""; + const lines = ["## Pending Todos"]; for (const todo of todos) { const priority = todo.priority ? ` [${todo.priority}]` : ""; - lines.push(`- ${todo.content}${priority}`); + const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○"; + lines.push(`- ${status} ${todo.content}${priority}`); } - lines.push(""); return lines.join("\n"); } @@ -300,37 +327,36 @@ export const MemoryV2Plugin: Plugin = async (input) => { // Sub-agents don't need compaction support if (await isSubAgent(sessionID)) return; - // Add compaction context with memory, hot state, todos, and instruction + // Build private context with Markdown-neutral format const contextParts: string[] = []; - // 1. Frozen workspace memory + // 1. Frozen workspace memory (strip XML tags for compaction context) const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID); const workspacePrompt = renderWorkspaceMemory(workspaceMemory); if (workspacePrompt) { - contextParts.push(workspacePrompt); + contextParts.push(stripXmlTags(workspacePrompt)); } - // 2. Hot session state + // 2. Hot session state (strip XML tags for compaction context) const sessionState = await loadSessionState(directory, sessionID); const hotPrompt = renderHotSessionState(sessionState, directory); if (hotPrompt) { - contextParts.push(hotPrompt); + contextParts.push(stripXmlTags(hotPrompt)); } - // 3. Pending todos from OpenCode + // 3. Pending todos from OpenCode (Markdown-neutral format) const todos = await pendingTodos(client, sessionID); - const todosPrompt = renderTodos(todos); + const todosPrompt = renderTodosForCompaction(todos); if (todosPrompt) { contextParts.push(todosPrompt); } - // 4. Memory candidate instruction - contextParts.push(memoryCandidateInstruction()); + // Combine into single private context block + const privateContext = contextParts.length > 0 + ? `${compactionContextHeader()}\n\n${contextParts.join("\n\n")}` + : compactionContextHeader(); - // Add to compaction context (output.context is an array) - for (const part of contextParts) { - output.context.push(part); - } + output.context.push(privateContext); }, // Handle session events diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index b81b30d..2cf555b 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -189,6 +189,59 @@ test("tool.execute.after: exitCode non-zero creates open error", async () => { "exitCode non-zero should create open error"); assert.equal(state.openErrors[0].category, "typecheck"); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("experimental.session.compacting: context contains no XML tags for compaction", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const client = mockRootClient(); + const plugin = await MemoryV2Plugin({ directory: tmpDir, client }); + + // Create a session state with some data + await saveSessionState(tmpDir, { + version: 1, + sessionID: "test-session-compaction", + turn: 1, + updatedAt: new Date().toISOString(), + activeFiles: [{ path: "/src/index.ts", action: "edit", count: 5, lastSeen: Date.now() }], + openErrors: [], + recentDecisions: [{ text: "Test decision", rationale: "Testing", source: "user", createdAt: Date.now() }], + }); + + // Call the compaction hook + const output = { context: [] as string[] }; + await (plugin as Record)["experimental.session.compacting"]( + { sessionID: "test-session-compaction" }, + output + ); + + // The context should be a single string (private context block) + assert.equal(output.context.length, 1, "Context should be a single block"); + + const contextText = output.context[0]; + + // Verify: context should NOT contain XML-like tags that confuse Markdown + assert.equal(contextText.includes(""), false, + "Context should not contain tag"); + assert.equal(contextText.includes(""), false, + "Context should not contain tag"); + assert.equal(contextText.includes(""), false, + "Context should not contain tag"); + assert.equal(contextText.includes(""), false, + "Context should not contain tag"); + + // Verify: context should contain the private context header + assert.equal(contextText.includes("[PRIVATE COMPACTION CONTEXT - DO NOT OUTPUT]"), true, + "Context should contain private context header"); + + // Verify: context should contain Markdown headers instead + assert.equal(contextText.includes("## Workspace Memory") || contextText.includes("## Hot Session State") || contextText.includes("## Pending Todos"), true, + "Context should use Markdown headers instead of XML tags"); + } finally { await rm(tmpDir, { recursive: true, force: true }); }