mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
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:
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user