diff --git a/docs/superpowers/plans/2026-04-26-memory-plugin-quality-fixes.md b/docs/superpowers/plans/2026-04-26-memory-plugin-quality-fixes.md index 83480de..0c60502 100644 --- a/docs/superpowers/plans/2026-04-26-memory-plugin-quality-fixes.md +++ b/docs/superpowers/plans/2026-04-26-memory-plugin-quality-fixes.md @@ -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 標籤 `` → 紫色斜體 +2. 第二次嘗試:HTML 註釋 `` → 仍然紫色斜體 +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** diff --git a/src/plugin.ts b/src/plugin.ts index b6769a7..522ddcb 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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 diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 58a6633..5b29339 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -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).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(""), false, - "Context should not contain tag"); - assert.equal(contextText.includes(""), false, - "Context should not contain tag"); - assert.equal(contextText.includes(""), false, - "Context should not contain tag"); - assert.equal(contextText.includes(""), false, - "Context should not contain tag"); + // Should NOT contain XML-like tags + assert.equal(prompt!.includes(""), false); + assert.equal(prompt!.includes(""), false); + assert.equal(prompt!.includes(""), false); + assert.equal(prompt!.includes(""), 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("