mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +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";
|
} 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 `
|
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>
|
<workspace_memory_candidates>
|
||||||
- [feedback] ...
|
- [type] content (types: feedback, project, decision, reference)
|
||||||
- [project] ...
|
|
||||||
- [decision] ...
|
|
||||||
- [reference] ...
|
|
||||||
</workspace_memory_candidates>
|
</workspace_memory_candidates>
|
||||||
|
|
||||||
Only include durable information useful across future sessions in this exact workspace.
|
Only include truly durable information useful across FUTURE sessions.
|
||||||
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.
|
If nothing qualifies, omit the block entirely.
|
||||||
For decisions, include rationale in one sentence.
|
|
||||||
If nothing qualifies, output an empty block.
|
[END PRIVATE COMPACTION CONTEXT]
|
||||||
`.trim();
|
`.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 {
|
function memoryCandidateInstruction(): string {
|
||||||
if (todos.length === 0) return "";
|
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) {
|
for (const todo of todos) {
|
||||||
const priority = todo.priority ? ` [${todo.priority}]` : "";
|
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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,37 +327,36 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
// Sub-agents don't need compaction support
|
// Sub-agents don't need compaction support
|
||||||
if (await isSubAgent(sessionID)) return;
|
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[] = [];
|
const contextParts: string[] = [];
|
||||||
|
|
||||||
// 1. Frozen workspace memory
|
// 1. Frozen workspace memory (strip XML tags for compaction context)
|
||||||
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
|
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
|
||||||
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
|
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
|
||||||
if (workspacePrompt) {
|
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 sessionState = await loadSessionState(directory, sessionID);
|
||||||
const hotPrompt = renderHotSessionState(sessionState, directory);
|
const hotPrompt = renderHotSessionState(sessionState, directory);
|
||||||
if (hotPrompt) {
|
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 todos = await pendingTodos(client, sessionID);
|
||||||
const todosPrompt = renderTodos(todos);
|
const todosPrompt = renderTodosForCompaction(todos);
|
||||||
if (todosPrompt) {
|
if (todosPrompt) {
|
||||||
contextParts.push(todosPrompt);
|
contextParts.push(todosPrompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Memory candidate instruction
|
// Combine into single private context block
|
||||||
contextParts.push(memoryCandidateInstruction());
|
const privateContext = contextParts.length > 0
|
||||||
|
? `${compactionContextHeader()}\n\n${contextParts.join("\n\n")}`
|
||||||
|
: compactionContextHeader();
|
||||||
|
|
||||||
// Add to compaction context (output.context is an array)
|
output.context.push(privateContext);
|
||||||
for (const part of contextParts) {
|
|
||||||
output.context.push(part);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Handle session events
|
// 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");
|
"exitCode non-zero should create open error");
|
||||||
assert.equal(state.openErrors[0].category, "typecheck");
|
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 {
|
} finally {
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user