fix: use plain text labels instead of Markdown headers

- Changed '## Memory Candidates' to 'Memory candidates:' in compaction context
- Changed '## Pending Todos' to 'Pending todos:' in todo rendering
- Updated extractCandidateBlock() to parse plain text format (primary)
- Removed stripXmlTags() function (no longer needed)
- All 42 tests pass

Root cause: Markdown headings (##) render as purple in OpenCode UI,
same issue as XML tags and HTML comments. Plain text labels avoid
all special markup rendering.
This commit is contained in:
Ralph Chang
2026-04-26 15:13:58 +08:00
parent 32fa2bd454
commit 721544e7a8
8 changed files with 1056 additions and 116 deletions
+11 -7
View File
@@ -223,18 +223,22 @@ function shouldAcceptWorkspaceMemoryCandidate(entry: {
/**
* Extract candidate block from summary using multiple formats.
* Supports: HTML comment, Markdown section, legacy XML.
* Supports: Plain text label, Markdown section, legacy XML.
*/
function extractCandidateBlock(summary: string): string | null {
// 1. HTML comment block (preferred, hidden from user)
const commentMatch = summary.match(/<!--\s*workspace_memory_candidates\s*\n([\s\S]*?)-->/i);
if (commentMatch) return commentMatch[1];
// 1. Plain text label (primary format, no Markdown header)
const plainMatch = summary.match(/Memory candidates:\s*\n([\s\S]*?)(?:\n[A-Z][a-z]+ [a-z]+:|\n##\s|$)/i);
if (plainMatch) return plainMatch[1];
// 2. Markdown section (visible but clean)
const markdownMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##|$)/i);
// 2. Markdown section (legacy)
const markdownMatch = summary.match(/##\s*Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
if (markdownMatch) return markdownMatch[1];
// 3. Legacy XML block (backward compatible)
// 3. Legacy "Workspace Memory Candidates" section
const legacyMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
if (legacyMatch) return legacyMatch[1];
// 4. Legacy XML block (backward compatible)
const xmlMatch = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
if (xmlMatch) return xmlMatch[1];
+24 -50
View File
@@ -45,46 +45,20 @@ import {
pendingTodos,
} from "./opencode.ts";
/**
* Strip XML-like tags from text for Markdown-neutral rendering.
* Converts `<workspace_memory>` blocks to plain "Workspace memory:" sections.
*/
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 `
[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.).
Background context for memory extraction (do not output verbatim):
- Use facts to update Goal/Progress/Key Decisions/Next Steps sections
- At the end, emit durable future-session memories in this format:
At the VERY END of your summary, if there are durable memory candidates, include this hidden block:
<!-- workspace_memory_candidates
- [type] content (types: feedback, project, decision, reference)
-->
Only include truly durable information useful across FUTURE sessions.
If nothing qualifies, omit the block entirely.
DO NOT use XML tags like <workspace_memory_candidates>.
DO NOT start with "---" frontmatter delimiters.
[END PRIVATE COMPACTION CONTEXT]
Memory candidates:
- [feedback] content
- [project] content
- [decision] content
- [reference] content
`.trim();
}
@@ -97,11 +71,11 @@ function memoryCandidateInstruction(): string {
}
/**
* Render todos for compaction context (Markdown-neutral format).
* Render todos for compaction context (plain text format, no Markdown headers).
*/
function renderTodosForCompaction(todos: Array<{ content: string; status: string; priority?: string }>): string {
if (todos.length === 0) return "";
const lines = ["## Pending Todos"];
const lines = ["Pending todos:"];
for (const todo of todos) {
const priority = todo.priority ? ` [${todo.priority}]` : "";
const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○";
@@ -329,22 +303,22 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Sub-agents don't need compaction support
if (await isSubAgent(sessionID)) return;
// Build private context with Markdown-neutral format
const contextParts: string[] = [];
// Build private context with Markdown-neutral format
const contextParts: string[] = [];
// 1. Frozen workspace memory (strip XML tags for compaction context)
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
if (workspacePrompt) {
contextParts.push(stripXmlTags(workspacePrompt));
}
// 1. Frozen workspace memory
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
if (workspacePrompt) {
contextParts.push(workspacePrompt);
}
// 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(stripXmlTags(hotPrompt));
}
// 2. Hot session state
const sessionState = await loadSessionState(directory, sessionID);
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
contextParts.push(hotPrompt);
}
// 3. Pending todos from OpenCode (Markdown-neutral format)
const todos = await pendingTodos(client, sessionID);
+1 -2
View File
@@ -180,7 +180,7 @@ export function renderHotSessionState(state: SessionState, workspaceRoot: string
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0) return "";
const lines: string[] = ["<hot_session_state>"];
const lines: string[] = ["Hot session state (current session):"];
if (activeFiles.length > 0) {
lines.push("active_files:");
@@ -204,7 +204,6 @@ export function renderHotSessionState(state: SessionState, workspaceRoot: string
}
}
lines.push("</hot_session_state>");
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
}
+3 -6
View File
@@ -136,10 +136,8 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
// If maxChars smaller than minimum envelope, return empty string
if (maxChars < MIN_ENVELOPE_LENGTH) return "";
const closing = "</workspace_memory>";
const lines: string[] = [
"<workspace_memory>",
"Persistent workspace memory. Use as background; verify stale or code-related claims.",
"Workspace memory (cross-session, verify if stale):",
];
for (const type of ["feedback", "project", "decision", "reference"] as const) {
@@ -150,17 +148,16 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
for (const item of items) {
const line = `- ${renderEntry(item)}`;
if (wouldFit([...lines, ...sectionLines], line, closing, maxChars)) {
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
sectionLines.push(line);
}
}
if (sectionLines.length > 1 && wouldFit(lines, sectionLines[0], closing, maxChars)) {
if (sectionLines.length > 1) {
lines.push(...sectionLines);
}
}
lines.push(closing);
return lines.join("\n");
}