fix: replace default compaction template to prevent purple italic rendering

Root cause: OpenCode's default compaction template uses --- separators.
When our plugin adds structured context (Memory candidates: format), the
model strictly follows the template, outputting --- at position 0. The
markdown textmate grammar treats this as YAML frontmatter, applying the
'comment' syntax scope (purple + italic in themes like palenight).

Fix: Set output.prompt in the compacting hook to replace the entire
template with a ---free version. Uses only ## Markdown headings and
explicitly forbids YAML frontmatter, horizontal rules, and delimiter
lines. Preserves context from other plugins by merging output.context.

- Replace compactionContextHeader() with buildCompactionPrompt()
- Set output.prompt instead of pushing to output.context
- Merge existing output.context from other plugins before clearing
- Add 'Instructions' section to the template (per architect review)
- Update tests: verify output.prompt, ---free format, context merging
This commit is contained in:
Ralph Chang
2026-04-26 15:46:41 +08:00
parent 721544e7a8
commit 5e9ada6859
3 changed files with 172 additions and 72 deletions
@@ -982,12 +982,34 @@ for (const { text, expected } of QUALITY_GATE_TESTS) {
### 本週:PR-1
1. Baseline snapshot
2. Task 1: inline exitCode + 收窄 extractErrorsFromBash + **plugin hook regression test**
3. Task 2: budget-aware render + **min envelope handling**
4. Task 3: remove bare `always` + **ensure all patterns have `g` flag**
2. Task 1: inline exitCode + 收窄 extractErrorsFromBash + **plugin hook regression test** ✅ DONE
3. Task 2: budget-aware render + **min envelope handling** ✅ DONE
4. Task 3: remove bare `always` + **ensure all patterns have `g` flag** ✅ DONE
5. Manual verification
6. Cleanup false positives
### Hotfix: 紫色斜體渲染問題
**問題**Plugin compaction context 輸出在 OpenCode UI 中顯示為紫色斜體。
**根因分析**
1. 第一次嘗試:XML 標籤 `<workspace_memory>` → 紫色斜體
2. 第二次嘗試:HTML 註釋 `<!-- workspace_memory_candidates -->` → 仍然紫色斜體
3. 第三次嘗試:Markdown 標題 `## Memory Candidates` → 紫色(無斜體)
4. 第四次嘗試:純文本標籤 `Memory candidates:` → 無特殊渲染 ✅
**解決方案**:架構師建議使用純文本標籤,避免所有 Markdown/XML/HTML 語法。
**修改內容**
- `src/plugin.ts`: `compactionContextHeader()` 改用 `Memory candidates:` 標籤
- `src/plugin.ts`: `renderTodosForCompaction()` 改用 `Pending todos:` 標籤
- `src/extractors.ts`: `extractCandidateBlock()` 支援純文本格式解析(primary)
- `src/workspace-memory.ts`: `renderWorkspaceMemory()` 使用純文本 `Workspace memory:` 標籤
- `src/session-state.ts`: `renderHotSessionState()` 使用純文本 `Hot session state:` 標籤
- 移除 `stripXmlTags()` 函數(不再需要)
**測試**42 個測試全部通過。
### 下週:PR-2
5. Task 5: canonical exact dedupe + **source priority**
+98 -42
View File
@@ -46,28 +46,63 @@ import {
} from "./opencode.ts";
/**
* Generate instructions for the compaction model.
* Build the complete compaction prompt.
*
* Replaces OpenCode's default template (which uses --- separators that trigger
* YAML frontmatter comment scope in markdown rendering, producing purple italic text).
* Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter,
* horizontal rules, and delimiter lines.
*
* @param privateContext - Background context (workspace memory, hot session state,
* pending todos) from our plugin and any other plugins. Shown to the model to
* inform the summary but not copied verbatim.
*/
function compactionContextHeader(): string {
return `
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:
Memory candidates:
- [feedback] content
- [project] content
- [decision] content
- [reference] content
`.trim();
}
/**
* Generate the memory candidate instruction.
* This is included in compactionContextHeader() above.
*/
function memoryCandidateInstruction(): string {
return "";
function buildCompactionPrompt(privateContext: string): string {
return [
"Provide a detailed summary for continuing our conversation above.",
"Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.",
"",
"Do not call any tools. Respond only with the summary text.",
"Respond in the same language as the user's messages in the conversation.",
"",
"Formatting rules:",
"- Start the response with \"## Goal\".",
"- Use Markdown headings only.",
"- Do not output YAML frontmatter.",
"- Do not output horizontal rules.",
"- Do not wrap the summary in delimiter lines such as ---.",
"- Do not use code fences around the summary.",
"",
"Use this structure:",
"",
"## Goal",
"",
"## Instructions",
"",
"## Progress",
"",
"## Key Decisions",
"",
"## Discoveries",
"",
"## Next Steps",
"",
"## Relevant Files",
"",
"At the end of the summary, extract durable memory entries for future",
"sessions using these labels:",
"",
"Memory candidates:",
"- [feedback] content",
"- [project] content",
"- [decision] content",
"- [reference] content",
"",
"Background context, use this to inform the summary above.",
"Do not output this context verbatim:",
"",
privateContext,
].join("\n");
}
/**
@@ -295,7 +330,17 @@ export const MemoryV2Plugin: Plugin = async (input) => {
await processLatestUserMessage(sessionID);
},
// Add compaction context before summarization
/**
* Replace the default compaction prompt with a ---free template.
*
* OpenCode's default template wraps sections in --- separators. When the
* model follows the template (which our structured context encourages),
* the TUI renders --- at position 0 as YAML frontmatter, applying the
* "comment" syntax scope (purple italic in palenight theme).
*
* We set output.prompt to replace the entire prompt, removing all ---
* and explicitly forbidding YAML frontmatter / horizontal rules.
*/
"experimental.session.compacting": async (hookInput, output) => {
const { sessionID } = hookInput;
if (!sessionID) return;
@@ -303,36 +348,47 @@ 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[] = [];
// Preserve context injected by other plugins that ran before us.
// Setting output.prompt bypasses the default prompt + context join,
// so we must explicitly carry forward any existing output.context.
const otherContext = output.context.filter(Boolean).join("\n\n");
// 1. Frozen workspace memory
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
if (workspacePrompt) {
contextParts.push(workspacePrompt);
}
// Build our private context (workspace memory, hot state, todos)
const contextParts: string[] = [];
// 2. Hot session state
const sessionState = await loadSessionState(directory, sessionID);
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
contextParts.push(hotPrompt);
}
// 1. Frozen workspace memory
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
if (workspacePrompt) {
contextParts.push(workspacePrompt);
}
// 3. Pending todos from OpenCode (Markdown-neutral format)
// 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
const todos = await pendingTodos(client, sessionID);
const todosPrompt = renderTodosForCompaction(todos);
if (todosPrompt) {
contextParts.push(todosPrompt);
}
// Combine into single private context block
const privateContext = contextParts.length > 0
? `${compactionContextHeader()}\n\n${contextParts.join("\n\n")}`
: compactionContextHeader();
// Combine: other plugins' context first, then our private context
const privateContext = [otherContext, ...contextParts]
.filter(Boolean)
.join("\n\n");
output.context.push(privateContext);
// Replace the default prompt entirely with our ---free template
output.prompt = buildCompactionPrompt(privateContext);
// Clear context array since we consumed it into output.prompt.
// Subsequent plugins that set output.prompt will also need to check
// output.context if they want to preserve other plugin contributions.
output.context.length = 0;
},
// Handle session events
+49 -27
View File
@@ -195,7 +195,7 @@ test("tool.execute.after: exitCode non-zero creates open error", async () => {
}
});
test("experimental.session.compacting: context contains no XML tags for compaction", async () => {
test("compaction hook sets output.prompt with ---free template", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
@@ -220,53 +220,75 @@ test("experimental.session.compacting: context contains no XML tags for compacti
output
);
// The context should be a single string (private context block)
assert.equal(output.context.length, 1, "Context should be a single block");
// Should set output.prompt and clear output.context
const prompt = (output as Record<string, unknown>).prompt as string | undefined;
assert.ok(prompt, "output.prompt should be set");
assert.equal(typeof prompt, "string", "output.prompt should be a string");
assert.equal(output.context.length, 0, "output.context should be cleared after setting prompt");
const contextText = output.context[0];
// Should NOT contain YAML frontmatter separators (--- at start)
assert.equal(prompt!.includes("\n---"), false,
"Prompt should not contain --- separators on their own line");
// 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");
// Should NOT contain XML-like tags
assert.equal(prompt!.includes("<workspace_memory>"), false);
assert.equal(prompt!.includes("</workspace_memory>"), false);
assert.equal(prompt!.includes("<hot_session_state>"), false);
assert.equal(prompt!.includes("<pending_todos>"), false);
// Verify: context should NOT contain square bracket markers
assert.equal(contextText.includes("[PRIVATE COMPACTION CONTEXT"), false,
"Context should not contain square bracket markers");
// Should NOT contain HTML comments
assert.equal(prompt!.includes("<!--"), false);
// Verify: context should contain plain text headers
assert.equal(contextText.includes("Workspace memory") || contextText.includes("Hot session state") || contextText.includes("Pending todos"), true,
"Context should use plain text headers instead of XML tags");
// Should contain the ---free template heading
assert.equal(prompt!.includes("## Goal"), true,
"Prompt should use ## Goal heading, not --- separators");
// Should contain formatting rules that explicitly forbid ---
assert.equal(prompt!.includes("Do not output YAML frontmatter"), true,
"Prompt should explicitly forbid YAML frontmatter");
assert.equal(prompt!.includes("horizontal rules"), true,
"Prompt should explicitly forbid horizontal rules");
// Should contain Memory candidates format
assert.equal(prompt!.includes("Memory candidates:"), true,
"Prompt should include Memory candidates: label");
// Should contain our context data (hot session state)
assert.equal(prompt!.includes("Hot session state"), true,
"Prompt should include hot session state context");
// Verify: prompt starts with plain text, not a markup delimiter
assert.equal(prompt!.startsWith("---"), false,
"Prompt should not start with --- (YAML frontmatter)");
assert.equal(prompt!.startsWith("##"), false,
"Prompt should start with plain instructions, not a heading");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("compactionContextHeader does not require XML output", async () => {
test("compaction hook merges existing output.context from other plugins", 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[] };
// Simulate another plugin having pushed context first
const output = { context: ["Other plugin context data"] };
await (plugin as Record<string, Function>)["experimental.session.compacting"](
{ sessionID: "test-header-session" },
{ sessionID: "test-merge-context" },
output
);
const header = output.context[0] || "";
const prompt = (output as Record<string, unknown>).prompt as string | undefined;
assert.ok(prompt, "output.prompt should be set");
assert.equal(output.context.length, 0, "output.context should be cleared");
// Should instruct plain text label format (not Markdown headers)
assert.equal(header.includes("Memory candidates:"), true,
"Header should use plain text 'Memory candidates:' label");
// Should contain the other plugin's context
assert.equal(prompt!.includes("Other plugin context data"), true,
"Prompt should preserve context from other plugins");
} finally {
await rm(tmpDir, { recursive: true, force: true });