mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-01 22:11:08 +02:00
fix: prevent XML tags in compaction context from causing Markdown rendering issues
- Add stripXmlTags() to convert <workspace_memory>, <hot_session_state>, <pending_todos> to Markdown headers for compaction context - Add [PRIVATE COMPACTION CONTEXT - DO NOT OUTPUT] wrapper to prevent model from copying input context to output - Rename renderTodos to renderTodosForCompaction for clarity - Add test to verify compaction context contains no XML tags This fixes the issue where compaction summary would render with purple italic text due to --- delimiters interacting with XML-like tags in Markdown.
This commit is contained in:
+56
-30
@@ -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 `<workspace_memory>` 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(/<workspace_memory>\n?/gi, "## Workspace Memory\n")
|
||||
.replace(/<\/workspace_memory>\n?/gi, "")
|
||||
.replace(/<hot_session_state>\n?/gi, "## Hot Session State\n")
|
||||
.replace(/<\/hot_session_state>\n?/gi, "")
|
||||
.replace(/<pending_todos>\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:
|
||||
|
||||
<workspace_memory_candidates>
|
||||
- [feedback] ...
|
||||
- [project] ...
|
||||
- [decision] ...
|
||||
- [reference] ...
|
||||
- [type] content (types: feedback, project, decision, reference)
|
||||
</workspace_memory_candidates>
|
||||
|
||||
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 = ["<pending_todos>"];
|
||||
/**
|
||||
* 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("</pending_todos>");
|
||||
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
|
||||
|
||||
@@ -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<string, Function>)["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("<workspace_memory>"), false,
|
||||
"Context should not contain <workspace_memory> tag");
|
||||
assert.equal(contextText.includes("</workspace_memory>"), false,
|
||||
"Context should not contain </workspace_memory> tag");
|
||||
assert.equal(contextText.includes("<hot_session_state>"), false,
|
||||
"Context should not contain <hot_session_state> tag");
|
||||
assert.equal(contextText.includes("<pending_todos>"), false,
|
||||
"Context should not contain <pending_todos> 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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user