fix: change compaction output to HTML comment, prevent Markdown rendering issues

Root cause: Model was instructed to output <workspace_memory_candidates> XML
tags in the user-visible compaction summary, causing purple/italic rendering
when combined with --- delimiters in Markdown.

Fixes:
- compactionContextHeader(): Now instructs model to use HTML comment format
  <!-- workspace_memory_candidates ... --> which is hidden from users
- extractCandidateBlock(): New function supports 3 formats:
  1. HTML comment (preferred, hidden from user)
  2. Markdown section (visible but clean)
  3. Legacy XML (backward compatible)
- Added "DO NOT use XML tags" and "DO NOT start with ---" instructions

Tests:
- Verify compaction context header uses HTML comment format
- Test parser accepts all 3 formats (HTML comment, Markdown, legacy XML)
This commit is contained in:
Ralph Chang
2026-04-26 14:49:38 +08:00
parent 2354b62350
commit eff0d3784c
3 changed files with 120 additions and 6 deletions
+23 -3
View File
@@ -221,14 +221,34 @@ function shouldAcceptWorkspaceMemoryCandidate(entry: {
return true;
}
/**
* Extract candidate block from summary using multiple formats.
* Supports: HTML comment, 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];
// 2. Markdown section (visible but clean)
const markdownMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##|$)/i);
if (markdownMatch) return markdownMatch[1];
// 3. Legacy XML block (backward compatible)
const xmlMatch = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
if (xmlMatch) return xmlMatch[1];
return null;
}
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
const match = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
if (!match) return [];
const block = extractCandidateBlock(summary);
if (!block) return [];
const now = new Date().toISOString();
const entries: LongTermMemoryEntry[] = [];
for (const line of match[1].split("\n")) {
for (const line of block.split("\n")) {
const item = line.trim().match(/^-\s*\[(feedback|project|decision|reference)\]\s*(.+)$/i);
if (!item) continue;
const type = item[1].toLowerCase() as LongTermType;
+5 -3
View File
@@ -73,14 +73,16 @@ 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:
At the VERY END of your summary, if there are durable memory candidates, include this hidden block:
<workspace_memory_candidates>
<!-- workspace_memory_candidates
- [type] content (types: feedback, project, decision, reference)
</workspace_memory_candidates>
-->
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]
`.trim();
+92
View File
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { MemoryV2Plugin } from "../src/plugin.ts";
import { loadSessionState, saveSessionState } from "../src/session-state.ts";
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import type { OpenError } from "../src/types.ts";
// Mock client for root session (not a sub-agent)
@@ -245,4 +246,95 @@ test("experimental.session.compacting: context contains no XML tags for compacti
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("compactionContextHeader does not require XML output", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const client = mockRootClient();
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
// Call the compaction hook to get the actual header
const output = { context: [] as string[] };
await (plugin as Record<string, Function>)["experimental.session.compacting"](
{ sessionID: "test-header-session" },
output
);
const header = output.context[0] || "";
// Should instruct HTML comment format
assert.equal(header.includes("<!-- workspace_memory_candidates"), true,
"Header should instruct HTML comment format");
// Should NOT instruct legacy XML format as primary
// Note: We check that the instruction for HTML comments comes before any XML mention
const htmlCommentIndex = header.indexOf("<!-- workspace_memory_candidates");
const xmlTagIndex = header.indexOf("<workspace_memory_candidates>\n");
if (xmlTagIndex !== -1) {
assert.equal(htmlCommentIndex < xmlTagIndex, true,
"HTML comment format should be the primary instruction, not XML");
}
// Should explicitly forbid XML
assert.equal(header.includes("DO NOT use XML tags"), true,
"Header should forbid XML tags");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("parseWorkspaceMemoryCandidates accepts HTML comment format", async () => {
const summary = `
## Summary
Progress made on testing.
<!-- workspace_memory_candidates
- [decision] Use HTML comments for candidates
- [project] This repo uses Markdown for docs
-->
Next steps: continue development.
`;
const candidates = parseWorkspaceMemoryCandidates(summary);
assert.equal(candidates.length, 2, "Should parse HTML comment format");
assert.equal(candidates[0].type, "decision");
assert.equal(candidates[1].type, "project");
});
test("parseWorkspaceMemoryCandidates accepts Markdown section format", async () => {
const summary = `
## Summary
Progress made on testing.
## Workspace Memory Candidates
- [reference] Check docs at README.md
## Next Steps
Continue development.
`;
const candidates = parseWorkspaceMemoryCandidates(summary);
assert.equal(candidates.length, 1, "Should parse Markdown section format");
assert.equal(candidates[0].type, "reference");
});
test("parseWorkspaceMemoryCandidates still accepts legacy XML format", async () => {
const summary = `
## Summary
Progress made on testing.
<workspace_memory_candidates>
- [feedback] Users prefer darker themes
</workspace_memory_candidates>
Next steps: continue development.
`;
const candidates = parseWorkspaceMemoryCandidates(summary);
assert.equal(candidates.length, 1, "Should parse legacy XML format");
assert.equal(candidates[0].type, "feedback");
});