From ffb04772518bcf4acfdee4060e94d13d5bf01fe1 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 13:21:15 +0800 Subject: [PATCH 01/11] fix: unify workspace memory quality gate --- src/extractors.ts | 32 ++----- src/memory-quality.ts | 96 +++++++++++++++++++++ tests/extractors.test.ts | 6 +- tests/fixtures/memory-quality-current-28.ts | 74 ++++++++++++++++ tests/memory-quality-eval.test.ts | 53 +++++++++++- 5 files changed, 230 insertions(+), 31 deletions(-) create mode 100644 src/memory-quality.ts create mode 100644 tests/fixtures/memory-quality-current-28.ts diff --git a/src/extractors.ts b/src/extractors.ts index 1b9bc54..3190d2f 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -1,6 +1,7 @@ import { createHash } from "crypto"; import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; +import { assessMemoryQuality } from "./memory-quality.ts"; function id(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -51,7 +52,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] { // 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해 /(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim, // 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配 - /(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim, + /(?:^|\n)\s*(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/gim, // save/add to memory /(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/gim, // commit to memory @@ -199,7 +200,7 @@ function normalizeCandidateBody(body: string): { text: string; hadTrigger: boole /(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im, /(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im, /(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im, - /(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/im, + /(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/im, /(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im, /(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im, ]; @@ -273,35 +274,12 @@ function shouldAcceptWorkspaceMemoryCandidate( const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length; if (pathCount > 2) return false; - // Session-specific progress snapshots for project type - if (entry.type === "project") { - if (isProjectSnapshotViolation(text)) return false; - } + const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" }); + if (!quality.accepted) return false; return true; } -function isProjectSnapshotViolation(text: string): boolean { - // Test/suite counts - if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true; - if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true; - - // File counts with snapshot/process context only, not static limits - if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) { - const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text); - const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text); - if (hasSnapshotContext && !hasLimitContext) return true; - } - - // Phase/Wave/Sprint/Milestone/Task progress - if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) { - if (/completed|done|finished|完成/i.test(text)) return true; - } - if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true; - - return false; -} - /** * Extract candidate block from summary using multiple formats. * Supports: Plain text label, Markdown section, legacy XML. diff --git a/src/memory-quality.ts b/src/memory-quality.ts new file mode 100644 index 0000000..8b4a0eb --- /dev/null +++ b/src/memory-quality.ts @@ -0,0 +1,96 @@ +import type { LongTermMemoryEntry, LongTermSource } from "./types.ts"; + +export type MemoryQualityInput = Pick & { + source?: LongTermSource; +}; + +export type MemoryQualityResult = { + accepted: boolean; + reasons: string[]; +}; + +export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult { + const reasons: string[] = []; + const text = entry.text.trim(); + + if (text.length === 0) reasons.push("empty"); + if (isProgressSnapshotViolation(text)) reasons.push("progress_snapshot"); + if (isRawErrorViolation(text)) reasons.push("raw_error"); + if (isCommitOrCiViolation(text)) reasons.push("commit_or_ci_snapshot"); + if (isPathHeavyViolation(text)) reasons.push("path_heavy"); + if (isTemporaryStatusViolation(text)) reasons.push("temporary_status"); + if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback"); + if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision"); + + return { accepted: reasons.length === 0, reasons }; +} + +export function isProgressSnapshotViolation(text: string): boolean { + if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true; + if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true; + + if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) { + const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text); + const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text); + if (hasSnapshotContext && !hasLimitContext) return true; + } + + if (/\b(?:completed|done|finished|implemented|added|updated|fixed|reviewed|passed|modified)\b/i.test(text)) { + if (/\b(?:wave|phase|task|plan|pr|commit|ci|test|suite|implementation|session|change|fix|review|file)\b/i.test(text)) return true; + } + if (/(?:已完成|完成|修復|实现|實作).{0,40}(?:wave|phase|task|plan|PR|測試|测试|實作|实现|修復)/iu.test(text)) return true; + if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) { + if (/completed|done|finished|完成|已完成/i.test(text)) return true; + } + if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true; + if (/\b(?:currently|right now|latest change|previous session|last wave|next step)\b/i.test(text)) return true; + return false; +} + +export function isFeedbackQualityViolation(text: string): boolean { + const stablePreference = /\b(?:user|the user)\s+(?:prefers|wants|asked|expects|requires|likes|dislikes)\b/i.test(text) + || /\b(?:prefer|preference|going forward|from now on|always|never)\b/i.test(text) + || /(?:使用者|用戶|用户).{0,12}(?:偏好|希望|要求|想要)/u.test(text) + || /(?:以後|以后|請|请).{0,20}(?:使用|回答|保持|避免)/u.test(text); + + if (stablePreference) return false; + + const internalNote = /\b(?:implemented|updated|fixed|reviewed|added|changed|modified|created|writes|wrote)\b/i.test(text); + if (internalNote) return true; + + return true; +} + +export function isDecisionQualityViolation(text: string): boolean { + const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text) + || /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text); + if (!futureRule) return true; + if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true; + if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true; + return false; +} + +function isRawErrorViolation(text: string): boolean { + if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true; + if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true; + return false; +} + +function isCommitOrCiViolation(text: string): boolean { + if (/\b[0-9a-f]{7,40}\b/.test(text)) return true; + if (/\bCI\b.*\b(?:passed|failed|run|compatibility|flaky)\b/i.test(text)) return true; + if (/\b(?:passed|failed|run|compatibility|flaky)\b.*\bCI\b/i.test(text)) return true; + if (/\bcompatibility\s+run\s+\d+/i.test(text)) return true; + return false; +} + +function isPathHeavyViolation(text: string): boolean { + const pathCount = (text.match(/\/[\w.-]+(?:\/[\w.-]+)+/g) || []).length; + return pathCount > 2; +} + +function isTemporaryStatusViolation(text: string): boolean { + if (/^(currently|now|pending|in progress|todo|wip)\b/i.test(text)) return true; + if (/\b(?:run npm test|tests? are running|next reply|before continuing)\b/i.test(text)) return true; + return false; +} diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index 09dbec3..a348f1f 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -223,7 +223,7 @@ test("parseWorkspaceMemoryCandidates accepts bracketless candidate format", () = Memory candidates: - project Backend health improvements organized into phased milestones - reference Scrypt 參數必須是 N=16384, r=8, p=1 -- feedback 端口 9473 可能被舊進程佔用,需殺掉後重啟 +- feedback User prefers Traditional Chinese memory summaries - decision Use output.prompt to replace the default compaction template `; @@ -451,14 +451,14 @@ test("parseWorkspaceMemoryCandidates allows benign ignore/instruction wording", Memory candidates: - [project] Use .gitignore to ignore generated files. - [reference] Instruction parser supports Markdown sections and bracketed memory types. -- [decision] Prompt context uses a frozen workspace snapshot plus hot session state. +- [decision] Use a frozen workspace snapshot plus hot session state for prompt context. `; const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 3); assert.equal(items[0].text, "Use .gitignore to ignore generated files."); assert.equal(items[1].text, "Instruction parser supports Markdown sections and bracketed memory types."); - assert.equal(items[2].text, "Prompt context uses a frozen workspace snapshot plus hot session state."); + assert.equal(items[2].text, "Use a frozen workspace snapshot plus hot session state for prompt context."); }); test("parseWorkspaceMemoryCandidates rejects direct system prompt override attempts", () => { diff --git a/tests/fixtures/memory-quality-current-28.ts b/tests/fixtures/memory-quality-current-28.ts new file mode 100644 index 0000000..692a2ff --- /dev/null +++ b/tests/fixtures/memory-quality-current-28.ts @@ -0,0 +1,74 @@ +import type { LongTermMemoryEntry } from "../../src/types.ts"; + +const now = "2026-04-28T00:00:00.000Z"; + +function mem( + id: string, + type: LongTermMemoryEntry["type"], + text: string, + source: LongTermMemoryEntry["source"] = "compaction", +): LongTermMemoryEntry { + return { + id, + type, + text, + source, + confidence: source === "explicit" ? 1 : 0.75, + status: "active", + createdAt: now, + updatedAt: now, + }; +} + +export const reviewerCurrent28Fixture: LongTermMemoryEntry[] = [ + // High-value durable entries. These should survive. + mem("good_feedback_language", "feedback", "User prefers architecture reviews in Traditional Chinese", "explicit"), + mem("good_feedback_direct", "feedback", "User wants direct architecture feedback with concrete file paths", "explicit"), + mem("good_feedback_no_manual_cleanup", "feedback", "User prefers automatic memory cleanup over manual cleanup instructions", "explicit"), + mem("good_decision_no_extra_api", "decision", "Do not add extra LLM API calls for memory consolidation"), + mem("good_decision_no_semantic_merge", "decision", "Memory dedupe must use exact canonical keys and generic URL/path identity only"), + mem("good_decision_no_render_tracking", "decision", "Do not use rendered-memory access tracking as evidence"), + mem("good_reference_frozen", "reference", "Workspace memory is rendered as a frozen system[1] snapshot; pending memories remain in hot session state until compaction"), + mem("good_project_plugin", "project", "The project is an OpenCode plugin using TypeScript and local JSON stores"), + mem("good_reference_accounting", "reference", "Promotion accounting reports promoted, absorbed, superseded, and rejected outcomes"), + + // Pseudo feedback/decision/progress snapshots. These should be superseded/rejected. + mem("bad_feedback_wave_done", "feedback", "Wave 1 completed successfully and all tests passed"), + mem("bad_feedback_plan_done", "feedback", "Plan 1 critical stability fixes were implemented"), + mem("bad_feedback_session_note", "feedback", "The assistant reviewed the code reviewer feedback and updated the plan"), + mem("bad_feedback_impl_note", "feedback", "Implemented owner-aware pending journal cleanup in plugin.ts"), + mem("bad_decision_commit", "decision", "Commit 53aa6d3 completed consolidation accounting"), + mem("bad_decision_tests", "decision", "180 tests pass and 0 tests fail after the latest change"), + mem("bad_decision_pr_status", "decision", "PR1 is done and PR2 is ready to start"), + mem("bad_project_files", "project", "Modified src/plugin.ts src/workspace-memory.ts src/pending-journal.ts during the last wave"), + mem("bad_project_wave", "project", "Wave 3 finished after cache bounds and Bearer redaction were added"), + mem("bad_reference_commit", "reference", "Commit a762e86 contains the owner scope fix"), + mem("bad_reference_ci", "reference", "CI compatibility run 25033906652 passed"), + mem("bad_reference_error", "reference", "TypeError: Cannot read properties of undefined"), + mem("bad_project_current", "project", "Currently running npm test before continuing"), + + // Borderline implementation facts. Reject unless they are written as future rules. + mem("bad_decision_impl_detail", "decision", "dedupeLongTermEntriesWithAccounting was updated in the previous session"), + mem("bad_feedback_internal", "feedback", "The migration writes to disk when redaction changes content"), + mem("bad_reference_tmp", "reference", "storage.test.ts had a flaky cross-process test in CI"), + + // Durable future-facing rules. These should survive. + mem("good_decision_quality", "decision", "Reject completion and progress statements before storing compaction memory candidates"), + mem("good_decision_quality_shared", "decision", "Use one shared memory quality gate for extraction and migration"), + mem("good_reference_quality_migration", "reference", "Quality cleanup migration supersedes low-quality compaction memories and does not touch explicit memories"), +]; + +export const expectedAcceptedFixtureIds = new Set([ + "good_feedback_language", + "good_feedback_direct", + "good_feedback_no_manual_cleanup", + "good_decision_no_extra_api", + "good_decision_no_semantic_merge", + "good_decision_no_render_tracking", + "good_reference_frozen", + "good_project_plugin", + "good_reference_accounting", + "good_decision_quality", + "good_decision_quality_shared", + "good_reference_quality_migration", +]); diff --git a/tests/memory-quality-eval.test.ts b/tests/memory-quality-eval.test.ts index 67ce55e..ea3023b 100644 --- a/tests/memory-quality-eval.test.ts +++ b/tests/memory-quality-eval.test.ts @@ -1,6 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; +import { extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; +import { assessMemoryQuality } from "../src/memory-quality.ts"; +import { expectedAcceptedFixtureIds, reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts"; const acceptedCases = [ { @@ -64,6 +66,18 @@ const rejectedCases = [ name: "temporary pending task", line: "- [decision] currently: run npm test before the next reply", }, + { + name: "misclassified feedback completion snapshot", + line: "- [feedback] Wave 1 completed successfully and all tests passed", + }, + { + name: "misclassified decision implementation note", + line: "- [decision] Implemented owner-aware cleanup in plugin.ts", + }, + { + name: "session internal review note", + line: "- [feedback] The assistant reviewed the code reviewer feedback and updated the plan", + }, ] as const; for (const item of acceptedCases) { @@ -91,3 +105,40 @@ ${item.line} assert.equal(entries.length, 0); }); } + +test("reviewer current-28 fixture keeps durable memories and rejects pseudo memories", () => { + for (const entry of reviewerCurrent28Fixture) { + const result = assessMemoryQuality(entry); + assert.equal( + result.accepted, + expectedAcceptedFixtureIds.has(entry.id), + `${entry.id}: ${entry.text} -> ${result.reasons.join(",")}`, + ); + } +}); + +test("progress snapshot rejection is type independent", () => { + for (const type of ["feedback", "project", "decision", "reference"] as const) { + const result = assessMemoryQuality({ type, text: "Wave 2 completed successfully", source: "compaction" }); + assert.equal(result.accepted, false, `${type} progress snapshots must reject`); + assert.ok(result.reasons.includes("progress_snapshot")); + } +}); + +test("feedback must be stable user preference or instruction", () => { + assert.equal(assessMemoryQuality({ type: "feedback", text: "User prefers concise architecture reviews", source: "compaction" }).accepted, true); + assert.equal(assessMemoryQuality({ type: "feedback", text: "Implemented owner-aware cleanup in plugin.ts", source: "compaction" }).accepted, false); +}); + +test("decision must be future-facing rule, not completed implementation note", () => { + assert.equal(assessMemoryQuality({ type: "decision", text: "Do not add semantic merge to memory dedupe", source: "compaction" }).accepted, true); + assert.equal(assessMemoryQuality({ type: "decision", text: "Use the cache boundary that was chosen in ADR-2 for future memory rendering", source: "compaction" }).accepted, true); + assert.equal(assessMemoryQuality({ type: "decision", text: "Added semantic merge tests in the previous wave", source: "compaction" }).accepted, false); +}); + +test("explicit memories bypass extraction quality gate", () => { + const entries = extractExplicitMemories("remember: Wave 1 completed successfully and all tests passed"); + assert.equal(entries.length, 1); + assert.equal(entries[0].source, "explicit"); + assert.match(entries[0].text, /Wave 1 completed/); +}); From b21347c12b38b9b5028eb31600cec9f1fe0ceab1 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 13:24:43 +0800 Subject: [PATCH 02/11] fix: tighten compaction memory candidate prompt --- src/plugin.ts | 50 +++++++++++++++++++------------------------- tests/plugin.test.ts | 25 ++++++++++++++++++++-- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index ebe86e9..594935d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -108,45 +108,37 @@ function buildCompactionPrompt(privateContext: string): string { "", "## Relevant Files", "", - "At the end of the summary, extract durable memory entries for future sessions.", + "At the end of the summary, include a Memory candidates section only if there are durable facts that will change future behavior.", "", - "Memory quality bar:", - "Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.", - "", - "Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.", - "", - "When unsure, skip it. Fewer high-signal memories are better than many low-value ones.", + "CRITICAL MEMORY RULES:", + "- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.", + "- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.", + "- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.", + "- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.", + "- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.", + "- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.", + "- If unsure, skip it.", "", "Good memory examples:", "- [feedback] User prefers architecture reviews in Traditional Chinese.", - "- [decision] Use frozen workspace memory snapshots plus ephemeral hot state for cache stability.", - "- [project] The plugin should piggyback memory extraction on OpenCode compaction and avoid extra LLM calls.", - "- [reference] Workspace memory appears in frozen system[1]; pending memories appear in hot session state until compaction.", + "- [decision] Do not add semantic merge to memory dedupe.", + "- [project] This repository is an OpenCode plugin using local JSON stores.", + "- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.", "", "Bad memory examples to skip:", - "- 42 tests passed.", "- Wave 2 completed successfully.", - "- Modified 5 files.", - "- commit 4309cb8 contains the latest fix.", - "- TypeError: Cannot read properties of undefined.", - "- Currently running npm test.", - "", - "A memory should still be useful if a new agent opens this workspace next week.", - "", - "Only extract facts that are likely to stay true across sessions.", - "Do not extract session-specific progress like exact test counts, file counts, or phase numbers.", - "For progress, extract the stable goal or durable milestone, not the current number.", - "For references, extract configuration values that do not usually change between sessions.", - "For feedback, extract unresolved issues or user preferences that future sessions need to know.", - "Use exactly this candidate format, including square brackets around the type:", + "- 180 tests passed and CI is green.", + "- Implemented owner-aware cleanup in plugin.ts.", + "- The assistant reviewed code reviewer feedback and updated the plan.", + "- Commit a762e86 contains the owner scope fix.", "", + "Format when there ARE durable memories:", "Memory candidates:", - "- [feedback] content", - "- [project] content", - "- [decision] content", - "- [reference] content", + "- [feedback|decision|project|reference] future-facing durable fact", "", - "Do not write '- project content'; write '- [project] content'.", + "Format when there are NO durable memories:", + "Memory candidates:", + "(none)", "", "Background context, use this to inform the summary above.", "Do not output this context verbatim:", diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 7611aae..daa7401 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -297,9 +297,9 @@ test("compaction hook sets output.prompt with ---free template", async () => { "Prompt should include concrete positive memory examples"); assert.equal(prompt!.includes("Bad memory examples to skip:"), true, "Prompt should include concrete negative memory examples"); - assert.equal(prompt!.includes("42 tests passed"), true, + assert.equal(prompt!.includes("180 tests passed"), true, "Prompt should explicitly reject test-count snapshots"); - assert.equal(prompt!.includes("commit 4309cb8"), true, + assert.equal(prompt!.includes("Commit a762e86"), true, "Prompt should explicitly reject commit-hash snapshots"); // Should contain our context data (hot session state) @@ -317,6 +317,27 @@ test("compaction hook sets output.prompt with ---free template", async () => { } }); +test("compaction prompt forbids progress and session-internal memory candidates", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-")); + try { + const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); + const output = { prompt: "", context: [] as string[] }; + + await (plugin as Record)["experimental.session.compacting"]( + { sessionID: "prompt-session", model: {} }, + output, + ); + + assert.match(output.prompt, /CRITICAL MEMORY RULES/); + assert.match(output.prompt, /NO completion or progress statements/i); + assert.match(output.prompt, /NO session-internal implementation notes/i); + assert.match(output.prompt, /feedback ONLY/i); + assert.match(output.prompt, /Most compactions should produce ZERO memories/i); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + test("compaction hook merges existing output.context from other plugins", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); From 6a80f4b0478aac58fd0ea9e46c1762ba255b4d58 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 13:29:28 +0800 Subject: [PATCH 03/11] fix: auto-supersede low-quality compaction memories --- src/workspace-memory.ts | 44 ++++++++++- tests/workspace-memory.test.ts | 130 ++++++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 037f093..1039d83 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -2,10 +2,12 @@ import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; +import { assessMemoryQuality } from "./memory-quality.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; const MIGRATION_ID = "2026-04-26-p0-cleanup"; +const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup"; const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`; @@ -188,6 +190,7 @@ export async function normalizeWorkspaceMemoryWithAccounting( // One-time migration for legacy snapshot violations result = runMigrationP0Cleanup(result, nowIso); + result = runMigrationQualityCleanup(result, nowIso); // P0 accounting only considers active entries. Entries that were already // superseded before this normalization are preserved in storage; entries that @@ -282,7 +285,7 @@ export function runMigrationP0Cleanup( } const entries = store.entries.map(entry => { - if (entry.source === "explicit") return entry; + if (entry.source !== "compaction") return entry; if (entry.type !== "project") return entry; if (isProjectSnapshotViolation(entry.text)) { @@ -304,6 +307,45 @@ export function runMigrationP0Cleanup( }; } +function runMigrationQualityCleanup( + store: WorkspaceMemoryStore, + nowIso: string, +): WorkspaceMemoryStore { + if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) { + return store; + } + + let changed = false; + const entries = store.entries.map(entry => { + if (entry.source !== "compaction") return entry; + if (entry.status === "superseded") return entry; + + const quality = assessMemoryQuality(entry); + if (quality.accepted) return entry; + + changed = true; + const tags = new Set([ + ...(entry.tags ?? []), + "quality_cleanup", + ...quality.reasons.map(reason => `quality:${reason}`), + ]); + + return { + ...entry, + status: "superseded" as const, + updatedAt: nowIso, + tags: [...tags], + }; + }); + + return { + ...store, + entries, + migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID], + updatedAt: changed ? nowIso : store.updatedAt, + }; +} + function sourcePriority(source: LongTermMemoryEntry["source"]): number { if (source === "explicit") return 3; if (source === "manual") return 2; diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 615f2cc..a906767 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -5,7 +5,7 @@ import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; import { LONG_TERM_LIMITS } from "../src/types.ts"; -import { workspaceMemoryPath } from "../src/paths.ts"; +import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts"; import { renderWorkspaceMemory, enforceLongTermLimits, @@ -21,6 +21,7 @@ import { saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; +import { reviewerCurrent28Fixture, expectedAcceptedFixtureIds } from "./fixtures/memory-quality-current-28.ts"; function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { const now = new Date().toISOString(); @@ -953,6 +954,133 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt); }); +test("quality cleanup migration supersedes low-quality compaction memories from current-28 fixture", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-")); + try { + const now = new Date().toISOString(); + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: reviewerCurrent28Fixture, + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + const activeIds = new Set(loaded.entries.filter(entry => entry.status === "active").map(entry => entry.id)); + const supersededIds = new Set(loaded.entries.filter(entry => entry.status === "superseded").map(entry => entry.id)); + + for (const entry of reviewerCurrent28Fixture) { + if (expectedAcceptedFixtureIds.has(entry.id)) { + assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`); + } else { + assert.equal(supersededIds.has(entry.id), true, `${entry.id} should be superseded`); + } + } + + assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration dedupes tags", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-tags-")); + try { + const now = new Date().toISOString(); + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [{ + id: "bad_with_tags", + type: "feedback", + text: "Wave 1 completed successfully and all tests passed", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + tags: ["quality_cleanup", "quality:progress_snapshot"], + }], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + const tags = loaded.entries[0].tags ?? []; + assert.equal(tags.filter(tag => tag === "quality_cleanup").length, 1); + assert.equal(tags.filter(tag => tag === "quality:progress_snapshot").length, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration does not supersede explicit memories", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-explicit-")); + try { + const now = new Date().toISOString(); + const explicitBadShape = { + id: "explicit_progress_like", + type: "feedback" as const, + text: "Wave 1 completed successfully and all tests passed", + source: "explicit" as const, + confidence: 1, + status: "active" as const, + createdAt: now, + updatedAt: now, + }; + + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [explicitBadShape], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + assert.equal(loaded.entries[0].status, "active"); + assert.equal(loaded.entries[0].source, "explicit"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration does not supersede manual memories", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-manual-")); + try { + const now = new Date().toISOString(); + const manualBadShape = { + id: "manual_progress_like", + type: "feedback" as const, + text: "Wave 1 completed successfully and all tests passed", + source: "manual" as const, + confidence: 0.9, + status: "active" as const, + createdAt: now, + updatedAt: now, + }; + + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [manualBadShape], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + assert.equal(loaded.entries[0].status, "active"); + assert.equal(loaded.entries[0].source, "manual"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("renderWorkspaceMemory excludes superseded entries", () => { const now = new Date().toISOString(); const store: WorkspaceMemoryStore = { From 465edfabf158d0c3ff428807b8188925089f50ad Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 13:34:33 +0800 Subject: [PATCH 04/11] fix: unify all memory quality rules in single module --- src/extractors.ts | 26 ++------------------------ src/memory-quality.ts | 13 +++++++++++++ src/workspace-memory.ts | 26 ++------------------------ tests/memory-quality-eval.test.ts | 17 +++++++++++++++++ tests/workspace-memory.test.ts | 14 +++++++------- 5 files changed, 41 insertions(+), 55 deletions(-) diff --git a/src/extractors.ts b/src/extractors.ts index 3190d2f..d06babd 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -224,8 +224,8 @@ function extractFirstPath(text: string): string | undefined { } /** - * Quality gate for workspace memory candidates. - * Rejects low-quality entries like git hashes, error messages, etc. + * Acceptance gate for workspace memory candidates. + * Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts. */ function shouldAcceptWorkspaceMemoryCandidate( entry: { @@ -246,34 +246,12 @@ function shouldAcceptWorkspaceMemoryCandidate( return false; } - // Git history / commit hash - if (/\b[0-9a-f]{7,40}\b/.test(text)) return false; - if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return false; - - // Raw error / stack trace - if (/^\s*(Error|TypeError|ReferenceError|SyntaxError):/i.test(text)) return false; - if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return false; - - // Active file list - if (/^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text)) return false; - - // Temporary progress - if (/^(currently|now|pending|in progress|todo|wip):/i.test(text)) return false; - - // Code signature / API doc - if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false; - if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false; - // Indirect Prompt Injection / Adversarial Instructions // Rejects attempts to overwrite system behavior or "ignore" rules. // comparative "instead of" is allowed. if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false; if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false; - // Path-heavy facts (rediscoverable from repo) - const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length; - if (pathCount > 2) return false; - const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" }); if (!quality.accepted) return false; diff --git a/src/memory-quality.ts b/src/memory-quality.ts index 8b4a0eb..bf046b8 100644 --- a/src/memory-quality.ts +++ b/src/memory-quality.ts @@ -19,6 +19,8 @@ export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityRes if (isCommitOrCiViolation(text)) reasons.push("commit_or_ci_snapshot"); if (isPathHeavyViolation(text)) reasons.push("path_heavy"); if (isTemporaryStatusViolation(text)) reasons.push("temporary_status"); + if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot"); + if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature"); if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback"); if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision"); @@ -77,6 +79,7 @@ function isRawErrorViolation(text: string): boolean { } function isCommitOrCiViolation(text: string): boolean { + if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return true; if (/\b[0-9a-f]{7,40}\b/.test(text)) return true; if (/\bCI\b.*\b(?:passed|failed|run|compatibility|flaky)\b/i.test(text)) return true; if (/\b(?:passed|failed|run|compatibility|flaky)\b.*\bCI\b/i.test(text)) return true; @@ -94,3 +97,13 @@ function isTemporaryStatusViolation(text: string): boolean { if (/\b(?:run npm test|tests? are running|next reply|before continuing)\b/i.test(text)) return true; return false; } + +function isActiveFileSnapshotViolation(text: string): boolean { + return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text); +} + +function isCodeOrApiSignatureViolation(text: string): boolean { + if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return true; + if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return true; + return false; +} diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 1039d83..af64f0d 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -2,7 +2,7 @@ import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; -import { assessMemoryQuality } from "./memory-quality.ts"; +import { assessMemoryQuality, isProgressSnapshotViolation } from "./memory-quality.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; @@ -254,28 +254,6 @@ export function redactCredentials(text: string): string { return result; } -export function isProjectSnapshotViolation(text: string): boolean { - // Test/suite counts - if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true; - if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true; - - // File counts with snapshot context, excluding limit statements - if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) { - const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text); - const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text); - if (hasSnapshotContext && !hasLimitContext) return true; - } - - // Phase/Wave/Sprint/Milestone/Task progress - if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) { - if (/completed|done|finished|完成/i.test(text)) return true; - } - - if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true; - - return false; -} - export function runMigrationP0Cleanup( store: WorkspaceMemoryStore, nowIso: string, @@ -288,7 +266,7 @@ export function runMigrationP0Cleanup( if (entry.source !== "compaction") return entry; if (entry.type !== "project") return entry; - if (isProjectSnapshotViolation(entry.text)) { + if (isProgressSnapshotViolation(entry.text)) { return { ...entry, status: "superseded" as const, diff --git a/tests/memory-quality-eval.test.ts b/tests/memory-quality-eval.test.ts index ea3023b..cb8e3e8 100644 --- a/tests/memory-quality-eval.test.ts +++ b/tests/memory-quality-eval.test.ts @@ -136,6 +136,23 @@ test("decision must be future-facing rule, not completed implementation note", ( assert.equal(assessMemoryQuality({ type: "decision", text: "Added semantic merge tests in the previous wave", source: "compaction" }).accepted, false); }); +test("shared quality gate owns extractor low-quality syntax rejections", () => { + const rejected = [ + { type: "project" as const, text: "fix: add new feature" }, + { type: "reference" as const, text: "modified src/plugin.ts" }, + { type: "reference" as const, text: "function buildCompactionPrompt(privateContext: string): string" }, + { type: "reference" as const, text: "GET /api/sessions" }, + ]; + + for (const entry of rejected) { + assert.equal( + assessMemoryQuality({ ...entry, source: "compaction" }).accepted, + false, + `${entry.type}: ${entry.text}`, + ); + } +}); + test("explicit memories bypass extraction quality gate", () => { const entries = extractExplicitMemories("remember: Wave 1 completed successfully and all tests passed"); assert.equal(entries.length, 1); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index a906767..6898e29 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -15,12 +15,12 @@ import { workspaceMemoryExactKey, workspaceMemoryIdentityKey, redactCredentials, - isProjectSnapshotViolation, runMigrationP0Cleanup, loadWorkspaceMemory, saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; +import { isProgressSnapshotViolation } from "../src/memory-quality.ts"; import { reviewerCurrent28Fixture, expectedAcceptedFixtureIds } from "./fixtures/memory-quality-current-28.ts"; function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { @@ -890,13 +890,13 @@ test("redactCredentials is idempotent and also redacts rationale text", () => { assert.equal(migrated.entries[0].rationale, "password: [REDACTED]"); }); -test("isProjectSnapshotViolation detects wave progress and avoids limit context false positives", () => { - assert.equal(isProjectSnapshotViolation("1237 tests pass, 226 suites"), true); - assert.equal(isProjectSnapshotViolation("USB 同步:37 個文件"), true); - assert.equal(isProjectSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true); +test("shared progress snapshot rule detects wave progress and avoids limit context false positives", () => { + assert.equal(isProgressSnapshotViolation("1237 tests pass, 226 suites"), true); + assert.equal(isProgressSnapshotViolation("USB 同步:37 個文件"), true); + assert.equal(isProgressSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true); - assert.equal(isProjectSnapshotViolation("Upload limit is 10 files"), false); - assert.equal(isProjectSnapshotViolation("Project supports 5 test suites"), false); + assert.equal(isProgressSnapshotViolation("Upload limit is 10 files"), false); + assert.equal(isProgressSnapshotViolation("Project supports 5 test suites"), false); }); test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs once", () => { From f7139f0844253e78dbc26d013a49c63c672dcc4e Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 13:37:14 +0800 Subject: [PATCH 05/11] chore: prepare v1.4.0 release --- CHANGELOG.md | 21 ++++++++++++++++++++ RELEASE_NOTES.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46de017..d913833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2026-04-28 + +### Added + +- Unified memory quality gate in `src/memory-quality.ts` as the single source of truth for all memory quality rules. +- CRITICAL MEMORY RULES in compaction prompt with explicit good/bad examples. +- Auto-supersede migration `2026-04-28-quality-cleanup` that marks low-quality compaction memories as superseded on first load. + +### Changed + +- Memory quality rules now apply to all memory types, not just project entries. +- Compaction prompt explicitly instructs model that most compactions should produce zero memories. +- Low-quality compaction memories (progress snapshots, implementation notes, session-internal notes) are automatically superseded during workspace memory normalization. + +### Migration Notes + +- Existing low-quality `source: "compaction"` entries will be marked as `superseded` once on first load after upgrade. +- Explicit and manual memories are never affected by quality cleanup. +- Superseded entries are retained on disk with `quality_cleanup` tags for audit purposes. +- Migration is idempotent and runs exactly once per workspace. + ## [1.3.3] - 2026-04-28 ### Fixed diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3d5c045..de0435a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,55 @@ # Release Notes +## 1.4.0 (2026-04-28) + +### Memory Quality Cleanup + +This minor release automatically improves memory quality for all existing users on upgrade. Low-quality compaction memories are identified and superseded without requiring manual cleanup. + +### What Changed + +- **Unified quality gate**: All memory types (feedback, decision, project, reference) now share the same quality rules instead of only project entries having a quality check. +- **Hardened compaction prompt**: The model is explicitly instructed that most compactions should produce zero memories, with clear good/bad examples. +- **Auto-supersede migration**: On first load after upgrade, existing low-quality `compaction` memories are automatically marked as `superseded` with quality tags. Explicit and manual memories are never affected. + +### What Gets Cleaned Up + +Low-quality memory patterns that are now rejected/superseded: + +- Progress snapshots: "Wave 1 completed successfully", "180 tests passed" +- Session-internal notes: "The assistant reviewed feedback and updated the plan" +- Implementation notes: "Implemented X in plugin.ts" +- Commit/CI references: "Commit a762e86 contains the fix" +- Raw errors and stack traces +- Temporary status: "Currently running npm test" + +### Migration Behavior + +- Runs exactly once per workspace (idempotent, non-destructive) +- Only affects `source: "compaction"` entries +- Explicit/manual memories are protected +- Superseded entries retain `status: "superseded"` and quality tags for audit +- No user action required + +### Upgrade Notes + +- No configuration changes required. +- Existing workspace memory files are automatically cleaned on first load. +- The OpenCode config entry stays the same: + +```json +{ + "plugin": ["opencode-working-memory"] +} +``` + +### Validation + +- `npm test` (196 tests) +- `npm run typecheck` + +--- + ## 1.3.2 (2026-04-27) ### CI Compatibility Patch diff --git a/package.json b/package.json index 6d44fd6..8f05d3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.3.3", + "version": "1.4.0", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "index.ts", From 9991c95ff672112379b912bb1c576311f12954b7 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 14:15:34 +0800 Subject: [PATCH 06/11] fix(memory): make quality cleanup migration conservative --- src/memory-quality.ts | 15 +++++ src/workspace-memory.ts | 15 +++-- tests/memory-quality-eval.test.ts | 16 +++++- tests/workspace-memory.test.ts | 91 +++++++++++++++++++++++++++++-- 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/src/memory-quality.ts b/src/memory-quality.ts index bf046b8..c473b8b 100644 --- a/src/memory-quality.ts +++ b/src/memory-quality.ts @@ -9,6 +9,21 @@ export type MemoryQualityResult = { reasons: string[]; }; +export const HARD_QUALITY_REASONS: ReadonlySet = new Set([ + "empty", + "progress_snapshot", + "raw_error", + "commit_or_ci_snapshot", + "temporary_status", + "active_file_snapshot", + "code_or_api_signature", + "path_heavy", +]); + +export function isHardQualityReason(reason: string): boolean { + return HARD_QUALITY_REASONS.has(reason); +} + export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult { const reasons: string[] = []; const text = entry.text.trim(); diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index af64f0d..3000d7f 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -2,7 +2,7 @@ import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; -import { assessMemoryQuality, isProgressSnapshotViolation } from "./memory-quality.ts"; +import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; @@ -188,9 +188,11 @@ export async function normalizeWorkspaceMemoryWithAccounting( }; }); - // One-time migration for legacy snapshot violations - result = runMigrationP0Cleanup(result, nowIso); + // One-time migrations for legacy/low-quality snapshot violations. + // Run quality cleanup first so hard violations receive quality audit tags + // before the older P0 project-only cleanup marks progress snapshots. result = runMigrationQualityCleanup(result, nowIso); + result = runMigrationP0Cleanup(result, nowIso); // P0 accounting only considers active entries. Entries that were already // superseded before this normalization are preserved in storage; entries that @@ -285,7 +287,7 @@ export function runMigrationP0Cleanup( }; } -function runMigrationQualityCleanup( +export function runMigrationQualityCleanup( store: WorkspaceMemoryStore, nowIso: string, ): WorkspaceMemoryStore { @@ -301,11 +303,14 @@ function runMigrationQualityCleanup( const quality = assessMemoryQuality(entry); if (quality.accepted) return entry; + const hardReasons = quality.reasons.filter(isHardQualityReason); + if (hardReasons.length === 0) return entry; + changed = true; const tags = new Set([ ...(entry.tags ?? []), "quality_cleanup", - ...quality.reasons.map(reason => `quality:${reason}`), + ...hardReasons.map(reason => `quality:${reason}`), ]); return { diff --git a/tests/memory-quality-eval.test.ts b/tests/memory-quality-eval.test.ts index cb8e3e8..c51c0a7 100644 --- a/tests/memory-quality-eval.test.ts +++ b/tests/memory-quality-eval.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; -import { assessMemoryQuality } from "../src/memory-quality.ts"; +import { assessMemoryQuality, isHardQualityReason } from "../src/memory-quality.ts"; import { expectedAcceptedFixtureIds, reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts"; const acceptedCases = [ @@ -159,3 +159,17 @@ test("explicit memories bypass extraction quality gate", () => { assert.equal(entries[0].source, "explicit"); assert.match(entries[0].text, /Wave 1 completed/); }); + +test("hard quality reasons exclude soft whitelist failures", () => { + assert.equal(isHardQualityReason("progress_snapshot"), true); + assert.equal(isHardQualityReason("raw_error"), true); + assert.equal(isHardQualityReason("commit_or_ci_snapshot"), true); + assert.equal(isHardQualityReason("temporary_status"), true); + assert.equal(isHardQualityReason("active_file_snapshot"), true); + assert.equal(isHardQualityReason("code_or_api_signature"), true); + assert.equal(isHardQualityReason("path_heavy"), true); + assert.equal(isHardQualityReason("empty"), true); + + assert.equal(isHardQualityReason("bad_feedback"), false); + assert.equal(isHardQualityReason("bad_decision"), false); +}); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 6898e29..04ac928 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -20,8 +20,8 @@ import { saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; -import { isProgressSnapshotViolation } from "../src/memory-quality.ts"; -import { reviewerCurrent28Fixture, expectedAcceptedFixtureIds } from "./fixtures/memory-quality-current-28.ts"; +import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts"; +import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts"; function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { const now = new Date().toISOString(); @@ -954,7 +954,84 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt); }); -test("quality cleanup migration supersedes low-quality compaction memories from current-28 fixture", async () => { +test("quality cleanup migration preserves soft-only feedback and decision violations", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-soft-preserve-")); + try { + const now = new Date().toISOString(); + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [ + { + id: "soft_feedback", + type: "feedback", + text: "UI 要統一風格:兩個表格都要 scrollable,約 20 rows", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + }, + { + id: "soft_decision", + type: "decision", + text: "Product branding is \"OpenCode Working Memory\" without \"Plugin\" in the name", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: 45, + }, + ], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + assert.equal(loaded.entries.find(e => e.id === "soft_feedback")?.status, "active"); + assert.equal(loaded.entries.find(e => e.id === "soft_decision")?.status, "active"); + assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration supersedes hard quality violations", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-hard-supersede-")); + try { + const now = new Date().toISOString(); + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [{ + id: "hard_progress", + type: "project", + text: "測試套件:1237 tests pass, 226 suites", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: 60, + }], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + const entry = loaded.entries.find(e => e.id === "hard_progress"); + assert.equal(entry?.status, "superseded"); + assert.ok(entry?.tags?.includes("quality_cleanup")); + assert.ok(entry?.tags?.includes("quality:progress_snapshot")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration supersedes only hard violations from current fixture", async () => { const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-")); try { const now = new Date().toISOString(); @@ -972,10 +1049,12 @@ test("quality cleanup migration supersedes low-quality compaction memories from const supersededIds = new Set(loaded.entries.filter(entry => entry.status === "superseded").map(entry => entry.id)); for (const entry of reviewerCurrent28Fixture) { - if (expectedAcceptedFixtureIds.has(entry.id)) { - assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`); - } else { + const quality = assessMemoryQuality(entry); + const hasHardReason = quality.reasons.some(isHardQualityReason); + if (entry.source === "compaction" && !quality.accepted && hasHardReason) { assert.equal(supersededIds.has(entry.id), true, `${entry.id} should be superseded`); + } else { + assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`); } } From 7427221640aee6f9914f7125ac251da6c17971c3 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 14:17:17 +0800 Subject: [PATCH 07/11] feat(memory): add local quality cleanup audit logs --- src/extractors.ts | 32 ++++++++++++++++- src/paths.ts | 8 +++++ src/workspace-memory.ts | 65 +++++++++++++++++++++++++++++----- tests/extractors.test.ts | 49 +++++++++++++++++++++++-- tests/workspace-memory.test.ts | 47 ++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 12 deletions(-) diff --git a/src/extractors.ts b/src/extractors.ts index d06babd..1fd4514 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -1,7 +1,10 @@ import { createHash } from "crypto"; +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { assessMemoryQuality } from "./memory-quality.ts"; +import { extractionRejectionLogPath } from "./paths.ts"; function id(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -227,6 +230,24 @@ function extractFirstPath(text: string): string | undefined { * Acceptance gate for workspace memory candidates. * Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts. */ +type ExtractionRejectionLogEntry = { + timestamp: string; + type: LongTermType; + text: string; + reasons: string[]; + source: "compaction"; +}; + +async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise { + try { + const path = extractionRejectionLogPath(); + await mkdir(dirname(path), { recursive: true }); + await appendFile(path, JSON.stringify(entry) + "\n", "utf8"); + } catch (error) { + console.error("[memory] failed to write extraction rejection log:", error); + } +} + function shouldAcceptWorkspaceMemoryCandidate( entry: { type: LongTermType; @@ -253,7 +274,16 @@ function shouldAcceptWorkspaceMemoryCandidate( if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false; const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" }); - if (!quality.accepted) return false; + if (!quality.accepted) { + void logExtractionRejection({ + timestamp: new Date().toISOString(), + type: entry.type, + text, + reasons: quality.reasons, + source: "compaction", + }); + return false; + } return true; } diff --git a/src/paths.ts b/src/paths.ts index 1d11c29..85c4ae5 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -28,3 +28,11 @@ export async function sessionStatePath(root: string, sessionID: string): Promise const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32); return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`); } + +export function migrationLogPath(migrationId: string): string { + return join(dataHome(), "opencode-working-memory", "migration-logs", `${migrationId}.jsonl`); +} + +export function extractionRejectionLogPath(): string { + return join(dataHome(), "opencode-working-memory", "extraction-rejections.jsonl"); +} diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 3000d7f..15a978e 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -1,6 +1,8 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; -import { workspaceKey, workspaceMemoryPath } from "./paths.ts"; +import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts"; @@ -50,6 +52,21 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & { events: MemoryConsolidationEvent[]; }; +export type QualityCleanupMigrationLogEntry = { + migrationId: string; + timestamp: string; + workspaceKey: string; + workspaceRoot: string; + entryId: string; + type: LongTermMemoryEntry["type"]; + source: LongTermMemoryEntry["source"]; + text: string; + reasons: string[]; + hardReasons: string[]; + beforeStatus: "active"; + afterStatus: "superseded"; +}; + export async function emptyWorkspaceMemory(root: string): Promise { return { version: 1, @@ -191,7 +208,13 @@ export async function normalizeWorkspaceMemoryWithAccounting( // One-time migrations for legacy/low-quality snapshot violations. // Run quality cleanup first so hard violations receive quality audit tags // before the older P0 project-only cleanup marks progress snapshots. - result = runMigrationQualityCleanup(result, nowIso); + const qualityCleanup = runMigrationQualityCleanup(result, nowIso); + result = qualityCleanup.store; + if (qualityCleanup.events.length > 0) { + await appendQualityCleanupMigrationLog(qualityCleanup.events).catch(error => { + console.error("[memory] failed to write quality cleanup migration log:", error); + }); + } result = runMigrationP0Cleanup(result, nowIso); // P0 accounting only considers active entries. Entries that were already @@ -287,14 +310,22 @@ export function runMigrationP0Cleanup( }; } +async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationLogEntry[]): Promise { + if (events.length === 0) return; + const path = migrationLogPath(QUALITY_CLEANUP_MIGRATION_ID); + await mkdir(dirname(path), { recursive: true }); + await appendFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n", "utf8"); +} + export function runMigrationQualityCleanup( store: WorkspaceMemoryStore, nowIso: string, -): WorkspaceMemoryStore { +): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } { if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) { - return store; + return { store, events: [] }; } + const events: QualityCleanupMigrationLogEntry[] = []; let changed = false; const entries = store.entries.map(entry => { if (entry.source !== "compaction") return entry; @@ -307,6 +338,21 @@ export function runMigrationQualityCleanup( if (hardReasons.length === 0) return entry; changed = true; + events.push({ + migrationId: QUALITY_CLEANUP_MIGRATION_ID, + timestamp: nowIso, + workspaceKey: store.workspace.key, + workspaceRoot: store.workspace.root, + entryId: entry.id, + type: entry.type, + source: entry.source, + text: entry.text, + reasons: quality.reasons, + hardReasons, + beforeStatus: "active", + afterStatus: "superseded", + }); + const tags = new Set([ ...(entry.tags ?? []), "quality_cleanup", @@ -322,10 +368,13 @@ export function runMigrationQualityCleanup( }); return { - ...store, - entries, - migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID], - updatedAt: changed ? nowIso : store.updatedAt, + store: { + ...store, + entries, + migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID], + updatedAt: changed ? nowIso : store.updatedAt, + }, + events, }; } diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index a348f1f..6c53563 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -1,6 +1,22 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { extractErrorsFromBash, extractExplicitMemories } from "../src/extractors.ts"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { extractErrorsFromBash, extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; + +async function waitForFile(path: string, attempts = 20): Promise { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + return await readFile(path, "utf8"); + } catch (error) { + lastError = error; + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + throw lastError; +} // ============================================ // Task 1: extractErrorsFromBash tests @@ -129,8 +145,6 @@ test("extractExplicitMemories captures multiple memories in same message", () => // Task 7: Compaction quality gate tests // ============================================ -import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; - test("parseWorkspaceMemoryCandidates rejects short text", () => { const summary = ` ## Memory Candidates @@ -281,6 +295,35 @@ Memory candidates: assert.equal(items.length, 0, "Exact test counts are session snapshots, not durable memory"); }); +test("parseWorkspaceMemoryCandidates logs quality gate rejections locally", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-reject-data-")); + const previousXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = dataHome; + + try { + const summary = ` +Memory candidates: +- feedback Wave 1 completed successfully and all tests passed +`; + + const items = parseWorkspaceMemoryCandidates(summary); + + assert.equal(items.length, 0); + const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl"); + const lines = (await waitForFile(logPath)).trim().split("\n"); + assert.equal(lines.length, 1); + const event = JSON.parse(lines[0]); + assert.equal(event.type, "feedback"); + assert.equal(event.text, "Wave 1 completed successfully and all tests passed"); + assert.deepEqual(event.reasons, ["progress_snapshot", "bad_feedback"]); + assert.equal(event.source, "compaction"); + } finally { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + await rm(dataHome, { recursive: true, force: true }); + } +}); + test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => { const summary = ` Memory candidates: diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 04ac928..266ed8a 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -1031,6 +1031,53 @@ test("quality cleanup migration supersedes hard quality violations", async () => } }); +test("quality cleanup migration writes audit log for hard supersedes", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-audit-root-")); + const dataHome = await mkdtemp(join(tmpdir(), "wm-quality-audit-data-")); + const previousXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = dataHome; + + try { + const now = new Date().toISOString(); + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [{ + id: "hard_progress", + type: "project", + text: "測試套件:1237 tests pass, 226 suites", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: 60, + }], + migrations: [], + updatedAt: now, + }); + + await loadWorkspaceMemory(root); + + const logPath = join(dataHome, "opencode-working-memory", "migration-logs", "2026-04-28-quality-cleanup.jsonl"); + const lines = (await readFile(logPath, "utf8")).trim().split("\n"); + assert.equal(lines.length, 1); + const event = JSON.parse(lines[0]); + assert.equal(event.migrationId, "2026-04-28-quality-cleanup"); + assert.equal(event.entryId, "hard_progress"); + assert.deepEqual(event.hardReasons, ["progress_snapshot"]); + assert.equal(event.beforeStatus, "active"); + assert.equal(event.afterStatus, "superseded"); + assert.equal(event.text, "測試套件:1237 tests pass, 226 suites"); + } finally { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + await rm(root, { recursive: true, force: true }); + await rm(dataHome, { recursive: true, force: true }); + } +}); + test("quality cleanup migration supersedes only hard violations from current fixture", async () => { const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-")); try { From 56d7ef9a68f72a535efafce4e2f919b7f8ca85c3 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 14:17:43 +0800 Subject: [PATCH 08/11] test(memory): add real workspace quality cleanup regression fixture --- tests/fixtures/real-workspaces-snapshot.ts | 67 ++++++++++++++++++++++ tests/workspace-memory.test.ts | 39 +++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tests/fixtures/real-workspaces-snapshot.ts diff --git a/tests/fixtures/real-workspaces-snapshot.ts b/tests/fixtures/real-workspaces-snapshot.ts new file mode 100644 index 0000000..1ea0134 --- /dev/null +++ b/tests/fixtures/real-workspaces-snapshot.ts @@ -0,0 +1,67 @@ +import type { LongTermMemoryEntry } from "../../src/types.ts"; + +export type RealWorkspaceFixtureEntry = LongTermMemoryEntry & { + expectedAfterMigration: "active" | "superseded"; + expectation: string; +}; + +const baseTimestamp = "2026-04-28T00:00:00.000Z"; + +function mem( + id: string, + type: LongTermMemoryEntry["type"], + text: string, + expectedAfterMigration: "active" | "superseded", + expectation: string, +): RealWorkspaceFixtureEntry { + return { + id, + type, + text, + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: baseTimestamp, + updatedAt: baseTimestamp, + staleAfterDays: type === "feedback" ? undefined : 45, + expectedAfterMigration, + expectation, + }; +} + +export const REAL_WORKSPACE_FIXTURES: Record = { + "medical-atlas": [ + mem("ma_ui_rule", "feedback", "UI 要統一風格:兩個表格都要 scrollable,約 20 rows", "active", "durable UI rule without user preference keyword"), + mem("ma_csp_rule", "feedback", "架構師建議中期將 CSP 改為 nonce/hash,而非 'unsafe-inline'", "active", "durable architecture recommendation"), + mem("ma_form_rule", "decision", "Form 添加防御性 action/method 屬性,避免 JS 失效時 GET 首頁", "active", "declarative design rule"), + mem("ma_logging_rule", "decision", "Cloud Logging filter 需支援多種 log 格式(jsonPayload.event_type, jsonPayload.message, textPayload)", "active", "durable spec using 需支援"), + ], + "opencode-record": [ + mem("or_phase_snapshot", "project", "後端健康改進計劃已完成 Phase 1-4", "superseded", "progress snapshot"), + mem("or_test_snapshot", "project", "測試套件:1237 tests pass, 226 suites", "superseded", "test count snapshot"), + mem("or_sync_snapshot", "project", "USB 同步:37 個文件(bundles, server, frontend, tests, docs)", "superseded", "file sync snapshot"), + ], + "agent-reports": [ + mem("ar_plan_decision", "feedback", "架構師建議執行 P3 前先確認有實際需求", "active", "durable plan decision"), + mem("ar_reviewer_fallback", "feedback", "`comprehensive-code-reviewer` subagent unreliable; use `phase-verifier` as fallback", "active", "durable workaround rule"), + mem("ar_wave_rule", "feedback", "每個 Wave 結束要找 verifier 確認,全部結束找 code review", "active", "durable workflow rule"), + mem("ar_remote_headers", "decision", "Remote headers 透過 `requestInit: { headers }` 傳入 `StreamableHTTPClientTransport`", "active", "declarative API rule"), + mem("ar_signal_order", "decision", "Graceful process cleanup signal order: SIGINT (300ms) → SIGTERM (700ms) → SIGKILL", "active", "durable process cleanup spec"), + mem("ar_ownership", "decision", "`McpRuntimeState` ownership model: CLI owns both runtime and mcpRuntime, dispose order is runtime first", "active", "durable ownership model"), + mem("ar_retry_policy", "decision", "Recovery retry policy: only once per tool call, only for transport/session failures", "active", "durable retry policy"), + ], + "pdf-extraction": [ + mem("pe_user_cycle", "feedback", "User 要求完整的 plan-review-feedback-modify-verify 循環,不是直接執行", "active", "mixed-language user workflow preference"), + mem("pe_ollama_batch", "feedback", "Ollama 大批量嵌入需要控制批次大小(20-50)和請求間隔", "active", "durable operational knowledge"), + mem("pe_option_b", "decision", "Phase 2 Fix 採用 Option B:multi-profile search grouping", "active", "design decision using 採用"), + mem("pe_single_source", "decision", "MCP source 維持單一 `book`,書籍身份在 source ID", "active", "design constraint using 維持"), + mem("pe_endpoint", "decision", "Ollama endpoint is `/api/embed` (not `/api/embeddings`) with `\"input\"` field", "active", "declarative API fact"), + mem("pe_filter_pipeline", "decision", "Filter pipeline: pre-chunk filtering (not post-chunk) to prevent embedding contamination", "active", "durable architecture rule"), + mem("pe_do_not_delete", "decision", "不刪除孤立的 reference-like 行(正文中的 \"et al.\" 等是合法引用)", "active", "do-not rule not matching current 不要 pattern"), + ], + "self-repo": [ + mem("sr_author_credit", "feedback", "User insists on preserving external contributor author credit and uses merge workflow", "active", "durable preference using insists"), + mem("sr_branding", "decision", "Product branding is \"OpenCode Working Memory\" without \"Plugin\" in the name", "active", "durable branding rule"), + mem("sr_changelog", "decision", "CHANGELOG version scope follows git tags: changes from v1.2.3 tag through HEAD belong to next version", "active", "durable release rule"), + ], +}; diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 266ed8a..91f92f9 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -16,12 +16,14 @@ import { workspaceMemoryIdentityKey, redactCredentials, runMigrationP0Cleanup, + runMigrationQualityCleanup, loadWorkspaceMemory, saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts"; import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts"; +import { REAL_WORKSPACE_FIXTURES } from "./fixtures/real-workspaces-snapshot.ts"; function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { const now = new Date().toISOString(); @@ -1078,6 +1080,43 @@ test("quality cleanup migration writes audit log for hard supersedes", async () } }); +test("quality cleanup migration regression against real workspace samples", async () => { + const failures: string[] = []; + const now = "2026-04-28T00:00:00.000Z"; + + for (const [workspaceName, fixtureEntries] of Object.entries(REAL_WORKSPACE_FIXTURES)) { + const root = `/fixture/${workspaceName}`; + const store = { + version: 1, + workspace: { root, key: workspaceName.padEnd(16, "0").slice(0, 16) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: fixtureEntries.map(({ expectedAfterMigration, expectation, ...entry }) => entry), + migrations: [], + updatedAt: now, + }; + + const result = runMigrationQualityCleanup(store, now).store; + const byId = new Map(result.entries.map(entry => [entry.id, entry])); + + for (const original of fixtureEntries) { + const after = byId.get(original.id); + if (!after) { + failures.push(`${workspaceName}/${original.id}: missing after migration`); + continue; + } + if (after.status !== original.expectedAfterMigration) { + failures.push( + `${workspaceName}/${original.id}: expected ${original.expectedAfterMigration}, got ${after.status}\n` + + ` text: ${original.text.slice(0, 120)}\n` + + ` why: ${original.expectation}`, + ); + } + } + } + + assert.equal(failures.length, 0, `Regression failures:\n${failures.join("\n")}`); +}); + test("quality cleanup migration supersedes only hard violations from current fixture", async () => { const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-")); try { From e8c95a62ec913787570696819dced23f2a90dded Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 14:19:18 +0800 Subject: [PATCH 09/11] docs(memory): document conservative quality cleanup migration --- CHANGELOG.md | 24 +++++++++--------------- README.md | 2 ++ scripts/dev/dry-run-migration.ts | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 scripts/dev/dry-run-migration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d913833..66db849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,24 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.4.0] - 2026-04-28 -### Added - -- Unified memory quality gate in `src/memory-quality.ts` as the single source of truth for all memory quality rules. -- CRITICAL MEMORY RULES in compaction prompt with explicit good/bad examples. -- Auto-supersede migration `2026-04-28-quality-cleanup` that marks low-quality compaction memories as superseded on first load. - -### Changed +### Memory Quality Cleanup -- Memory quality rules now apply to all memory types, not just project entries. -- Compaction prompt explicitly instructs model that most compactions should produce zero memories. -- Low-quality compaction memories (progress snapshots, implementation notes, session-internal notes) are automatically superseded during workspace memory normalization. +- Unified quality gate for compaction memory candidates and cleanup checks. +- Rewritten compaction memory prompt to reduce over-production of low-quality memories. +- Conservative one-time quality cleanup migration (`2026-04-28-quality-cleanup`) that supersedes only high-confidence garbage patterns: progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries. +- Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, and architecture decisions. +- Migration audit log: `~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`. +- Extraction rejection log: `~/.local/share/opencode-working-memory/extraction-rejections.jsonl`. -### Migration Notes +### Recovery note -- Existing low-quality `source: "compaction"` entries will be marked as `superseded` once on first load after upgrade. -- Explicit and manual memories are never affected by quality cleanup. -- Superseded entries are retained on disk with `quality_cleanup` tags for audit purposes. -- Migration is idempotent and runs exactly once per workspace. +The cleanup migration changes matching entries to `status: "superseded"`; it does not delete the entry. If a useful memory is superseded, inspect the migration audit log and restore by changing that entry back to `status: "active"` in the workspace's `workspace-memory.json`. The migration runs once per workspace. ## [1.3.3] - 2026-04-28 diff --git a/README.md b/README.md index 9614372..2760930 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ It includes guards for: The goal is to remember durable facts, not every detail. +Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y". + ## Configuration OpenCode Working Memory works out of the box. diff --git a/scripts/dev/dry-run-migration.ts b/scripts/dev/dry-run-migration.ts new file mode 100644 index 0000000..807411d --- /dev/null +++ b/scripts/dev/dry-run-migration.ts @@ -0,0 +1,17 @@ +import { loadWorkspaceMemory } from "../../src/workspace-memory.ts"; + +const roots = [ + "/Users/sd_wo/work/opencode-working-memory", + "/Users/sd_wo/Documents/projects/Pre-cancer-atlas", + "/Users/sd_wo/work/opencode-record", + "/Users/sd_wo/work/pathology-agent-reports", + "/Users/sd_wo/work/pathology-extraction", +]; + +for (const root of roots) { + console.log(`Loading workspace memory: ${root}`); + const store = await loadWorkspaceMemory(root); + const active = store.entries.filter(entry => entry.status !== "superseded").length; + const superseded = store.entries.filter(entry => entry.status === "superseded").length; + console.log(` active=${active} superseded=${superseded} migrations=${(store.migrations ?? []).join(",")}`); +} From 8da39c7a9d126dfd6b53877ee7e987d9b930df15 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 14:29:28 +0800 Subject: [PATCH 10/11] fix(memory): address quality cleanup audit findings --- .gitignore | 3 + CHANGELOG.md | 18 +++-- RELEASE_NOTES.md | 66 ++++++++++++----- scripts/dev/dry-run-migration.ts | 47 ++++++++++-- src/extractors.ts | 11 ++- src/workspace-memory.ts | 15 +++- tests/extractors.test.ts | 29 ++++++++ tests/fixtures/real-workspaces-snapshot.ts | 58 +++++++-------- tests/workspace-memory.test.ts | 83 ++++++++++++++++++++++ 9 files changed, 267 insertions(+), 63 deletions(-) diff --git a/.gitignore b/.gitignore index d0becba..6ebddb7 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ pnpm-lock.yaml # Superpowers local planning artifacts docs/superpowers/plans/ + +# Local migration dry-run roots +scripts/dev/dry-run-roots.local.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 66db849..9559f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.4.0] - 2026-04-28 -### Memory Quality Cleanup +### Added + +- Local migration audit log for the `2026-04-28-quality-cleanup` migration: + `~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`. +- Local extraction rejection log for rejected compaction memory candidates: + `~/.local/share/opencode-working-memory/extraction-rejections.jsonl`. +- Sanitized real-workspace regression fixtures for memory cleanup migration behavior. + +### Changed -- Unified quality gate for compaction memory candidates and cleanup checks. +- Unified memory quality rules in a shared quality gate for compaction memory candidates and cleanup checks. - Rewritten compaction memory prompt to reduce over-production of low-quality memories. -- Conservative one-time quality cleanup migration (`2026-04-28-quality-cleanup`) that supersedes only high-confidence garbage patterns: progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries. -- Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, and architecture decisions. -- Migration audit log: `~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`. -- Extraction rejection log: `~/.local/share/opencode-working-memory/extraction-rejections.jsonl`. +- Changed quality cleanup migration to be conservative: it supersedes only high-confidence garbage patterns, including progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries. +- Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, user workflow preferences, and architecture decisions. ### Recovery note diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index de0435a..d3ad70f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,37 +4,69 @@ ### Memory Quality Cleanup -This minor release automatically improves memory quality for all existing users on upgrade. Low-quality compaction memories are identified and superseded without requiring manual cleanup. +This release improves automatic workspace memory quality without risking broad cleanup of useful existing memories. + +The quality gate is now shared across compaction extraction and migration checks, the compaction prompt is stricter about what should become durable memory, and the one-time migration is intentionally conservative. ### What Changed -- **Unified quality gate**: All memory types (feedback, decision, project, reference) now share the same quality rules instead of only project entries having a quality check. -- **Hardened compaction prompt**: The model is explicitly instructed that most compactions should produce zero memories, with clear good/bad examples. -- **Auto-supersede migration**: On first load after upgrade, existing low-quality `compaction` memories are automatically marked as `superseded` with quality tags. Explicit and manual memories are never affected. +- **Unified quality rules**: memory quality checks now live in one shared module and apply consistently across feedback, decisions, project facts, and references. +- **Stricter compaction output**: the compaction prompt now tells the model to save fewer memories and prefer durable facts, user preferences, architecture decisions, and hard-to-rediscover references. +- **Conservative migration cleanup**: the `2026-04-28-quality-cleanup` migration only supersedes high-confidence garbage patterns, not every rejected memory. +- **Audit logs**: automatic migration cleanup writes local JSONL audit records so superseded entries can be inspected and restored. +- **Extraction rejection logs**: newly rejected compaction candidates are logged locally to help calibrate future quality rules. +- **Regression coverage**: migration behavior is tested against sanitized real-workspace patterns to prevent mass false positives from coming back. ### What Gets Cleaned Up -Low-quality memory patterns that are now rejected/superseded: +The migration may supersede existing `source: "compaction"` memories only when they match hard garbage patterns: -- Progress snapshots: "Wave 1 completed successfully", "180 tests passed" -- Session-internal notes: "The assistant reviewed feedback and updated the plan" -- Implementation notes: "Implemented X in plugin.ts" -- Commit/CI references: "Commit a762e86 contains the fix" +- Empty entries +- Progress snapshots, such as "Wave 1 completed successfully" +- Test or suite count snapshots, such as "180 tests passed" - Raw errors and stack traces -- Temporary status: "Currently running npm test" +- Commit or CI snapshots +- Temporary status notes, such as "Currently running npm test" +- Active file snapshots +- Code or API signatures +- Path-heavy entries that are just rediscoverable file lists + +### What Is Protected + +The migration does not supersede entries whose only issue is a soft heuristic failure, such as: + +- `bad_feedback` +- `bad_decision` + +This protects useful declarative memories like: + +- Product branding rules +- API facts +- Release rules +- Architecture decisions +- User workflow preferences + +Explicit and manual memories are also protected. ### Migration Behavior -- Runs exactly once per workspace (idempotent, non-destructive) -- Only affects `source: "compaction"` entries -- Explicit/manual memories are protected -- Superseded entries retain `status: "superseded"` and quality tags for audit -- No user action required +- Runs once per workspace. +- Only affects active `source: "compaction"` entries. +- Marks matching entries as `status: "superseded"` instead of deleting them. +- Adds `quality_cleanup` and `quality:` tags to superseded entries. +- Writes audit logs to: + `~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl` +- Writes extraction rejection logs to: + `~/.local/share/opencode-working-memory/extraction-rejections.jsonl` + +### Recovery + +If a useful memory is superseded, inspect the migration audit log and restore the entry by changing its status back to `"active"` in the workspace's `workspace-memory.json`. ### Upgrade Notes - No configuration changes required. -- Existing workspace memory files are automatically cleaned on first load. +- Existing workspace memory files remain compatible. - The OpenCode config entry stays the same: ```json @@ -45,7 +77,7 @@ Low-quality memory patterns that are now rejected/superseded: ### Validation -- `npm test` (196 tests) +- `npm test` - `npm run typecheck` --- diff --git a/scripts/dev/dry-run-migration.ts b/scripts/dev/dry-run-migration.ts index 807411d..76a2e4f 100644 --- a/scripts/dev/dry-run-migration.ts +++ b/scripts/dev/dry-run-migration.ts @@ -1,12 +1,45 @@ +/** + * Local helper to trigger migration on workspace roots. + * + * Usage: + * MIGRATION_DRY_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/dry-run-migration.ts + * + * Or create a local file (gitignored): + * echo "/path/to/workspace1" > scripts/dev/dry-run-roots.local.txt + * echo "/path/to/workspace2" >> scripts/dev/dry-run-roots.local.txt + * bun run scripts/dev/dry-run-migration.ts + */ + +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { loadWorkspaceMemory } from "../../src/workspace-memory.ts"; -const roots = [ - "/Users/sd_wo/work/opencode-working-memory", - "/Users/sd_wo/Documents/projects/Pre-cancer-atlas", - "/Users/sd_wo/work/opencode-record", - "/Users/sd_wo/work/pathology-agent-reports", - "/Users/sd_wo/work/pathology-extraction", -]; +async function getRoots(): Promise { + // Priority 1: environment variable + const envRoots = process.env.MIGRATION_DRY_RUN_ROOTS; + if (envRoots) { + return envRoots.split(":").filter(root => root.length > 0); + } + + // Priority 2: local file + const localFile = join(import.meta.dirname, "dry-run-roots.local.txt"); + if (existsSync(localFile)) { + const content = await readFile(localFile, "utf8"); + return content.trim().split("\n").filter(root => root.length > 0); + } + + // No roots configured + console.log("No workspace roots configured."); + console.log("Set MIGRATION_DRY_RUN_ROOTS=/path/a:/path/b or create dry-run-roots.local.txt"); + return []; +} + +const roots = await getRoots(); + +if (roots.length === 0) { + process.exit(0); +} for (const root of roots) { console.log(`Loading workspace memory: ${root}`); diff --git a/src/extractors.ts b/src/extractors.ts index 1fd4514..39feaa4 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -248,6 +248,15 @@ async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promi } } +function redactSensitiveText(text: string): string { + return text + .replace(/bearer\s+[a-zA-Z0-9._-]+/gi, "bearer [REDACTED]") + .replace(/token[=:]\s*[a-zA-Z0-9._-]+/gi, "token=[REDACTED]") + .replace(/password[=:]\s*[a-zA-Z0-9._-]+/gi, "password=[REDACTED]") + .replace(/secret[=:]\s*[a-zA-Z0-9._-]+/gi, "secret=[REDACTED]") + .replace(/api[-_]?key[=:]\s*[a-zA-Z0-9._-]+/gi, "api_key=[REDACTED]"); +} + function shouldAcceptWorkspaceMemoryCandidate( entry: { type: LongTermType; @@ -278,7 +287,7 @@ function shouldAcceptWorkspaceMemoryCandidate( void logExtractionRejection({ timestamp: new Date().toISOString(), type: entry.type, - text, + text: redactSensitiveText(text), reasons: quality.reasons, source: "compaction", }); diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 15a978e..78083bd 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -208,14 +208,23 @@ export async function normalizeWorkspaceMemoryWithAccounting( // One-time migrations for legacy/low-quality snapshot violations. // Run quality cleanup first so hard violations receive quality audit tags // before the older P0 project-only cleanup marks progress snapshots. + const beforeQualityCleanup = result; const qualityCleanup = runMigrationQualityCleanup(result, nowIso); result = qualityCleanup.store; + let skipRemainingMigrations = false; if (qualityCleanup.events.length > 0) { - await appendQualityCleanupMigrationLog(qualityCleanup.events).catch(error => { + try { + await appendQualityCleanupMigrationLog(qualityCleanup.events); + } catch (error) { console.error("[memory] failed to write quality cleanup migration log:", error); - }); + console.error("[memory] aborting migration to maintain audit trail integrity"); + result = beforeQualityCleanup; + skipRemainingMigrations = true; + } + } + if (!skipRemainingMigrations) { + result = runMigrationP0Cleanup(result, nowIso); } - result = runMigrationP0Cleanup(result, nowIso); // P0 accounting only considers active entries. Entries that were already // superseded before this normalization are preserved in storage; entries that diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index 6c53563..1818d67 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -324,6 +324,35 @@ Memory candidates: } }); +test("parseWorkspaceMemoryCandidates redacts secrets in extraction rejection log", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-")); + const previousXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = dataHome; + + try { + const summary = ` +Memory candidates: +- reference TypeError: bearer sk_test token=tok123 password=pass123 secret=sec123 api_key=key123 +`; + + const items = parseWorkspaceMemoryCandidates(summary); + + assert.equal(items.length, 0); + const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl"); + const lines = (await waitForFile(logPath)).trim().split("\n"); + assert.equal(lines.length, 1); + const event = JSON.parse(lines[0]); + assert.equal( + event.text, + "TypeError: bearer [REDACTED] token=[REDACTED] password=[REDACTED] secret=[REDACTED] api_key=[REDACTED]", + ); + } finally { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + await rm(dataHome, { recursive: true, force: true }); + } +}); + test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => { const summary = ` Memory candidates: diff --git a/tests/fixtures/real-workspaces-snapshot.ts b/tests/fixtures/real-workspaces-snapshot.ts index 1ea0134..0bdce94 100644 --- a/tests/fixtures/real-workspaces-snapshot.ts +++ b/tests/fixtures/real-workspaces-snapshot.ts @@ -30,38 +30,38 @@ function mem( } export const REAL_WORKSPACE_FIXTURES: Record = { - "medical-atlas": [ - mem("ma_ui_rule", "feedback", "UI 要統一風格:兩個表格都要 scrollable,約 20 rows", "active", "durable UI rule without user preference keyword"), - mem("ma_csp_rule", "feedback", "架構師建議中期將 CSP 改為 nonce/hash,而非 'unsafe-inline'", "active", "durable architecture recommendation"), - mem("ma_form_rule", "decision", "Form 添加防御性 action/method 屬性,避免 JS 失效時 GET 首頁", "active", "declarative design rule"), - mem("ma_logging_rule", "decision", "Cloud Logging filter 需支援多種 log 格式(jsonPayload.event_type, jsonPayload.message, textPayload)", "active", "durable spec using 需支援"), + "workspace-alpha": [ + mem("alpha_ui_rule", "feedback", "UI should have consistent style: both tables scrollable, about 20 rows", "active", "durable UI rule without user preference keyword"), + mem("alpha_csp_rule", "feedback", "Architecture recommendation: migrate the content security policy to nonce or hash rules rather than unsafe inline scripts", "active", "durable architecture recommendation"), + mem("alpha_form_rule", "decision", "Form uses defensive action and method attributes so the fallback does not navigate to the home page when scripts fail", "active", "declarative design rule"), + mem("alpha_logging_rule", "decision", "Cloud logging filter supports multiple log formats: structured event type, structured message, and text payload", "active", "durable declarative logging spec"), ], - "opencode-record": [ - mem("or_phase_snapshot", "project", "後端健康改進計劃已完成 Phase 1-4", "superseded", "progress snapshot"), - mem("or_test_snapshot", "project", "測試套件:1237 tests pass, 226 suites", "superseded", "test count snapshot"), - mem("or_sync_snapshot", "project", "USB 同步:37 個文件(bundles, server, frontend, tests, docs)", "superseded", "file sync snapshot"), + "workspace-beta": [ + mem("beta_phase_snapshot", "project", "Backend health improvement plan completed Phase 1-4", "superseded", "progress snapshot"), + mem("beta_test_snapshot", "project", "Test suite: 1237 tests pass, 226 suites", "superseded", "test count snapshot"), + mem("beta_sync_snapshot", "project", "External drive synced 37 files including bundles, service, frontend, tests, and docs", "superseded", "file sync snapshot"), ], - "agent-reports": [ - mem("ar_plan_decision", "feedback", "架構師建議執行 P3 前先確認有實際需求", "active", "durable plan decision"), - mem("ar_reviewer_fallback", "feedback", "`comprehensive-code-reviewer` subagent unreliable; use `phase-verifier` as fallback", "active", "durable workaround rule"), - mem("ar_wave_rule", "feedback", "每個 Wave 結束要找 verifier 確認,全部結束找 code review", "active", "durable workflow rule"), - mem("ar_remote_headers", "decision", "Remote headers 透過 `requestInit: { headers }` 傳入 `StreamableHTTPClientTransport`", "active", "declarative API rule"), - mem("ar_signal_order", "decision", "Graceful process cleanup signal order: SIGINT (300ms) → SIGTERM (700ms) → SIGKILL", "active", "durable process cleanup spec"), - mem("ar_ownership", "decision", "`McpRuntimeState` ownership model: CLI owns both runtime and mcpRuntime, dispose order is runtime first", "active", "durable ownership model"), - mem("ar_retry_policy", "decision", "Recovery retry policy: only once per tool call, only for transport/session failures", "active", "durable retry policy"), + "workspace-gamma": [ + mem("gamma_need_check", "feedback", "Architecture recommendation: confirm actual demand before executing the later priority phase", "active", "durable plan decision"), + mem("gamma_review_fallback", "feedback", "Primary review automation can be unreliable; use phase verification as the fallback", "active", "durable workaround rule"), + mem("gamma_wave_rule", "feedback", "Each wave should end with verifier confirmation, and the full implementation should end with code review", "active", "durable workflow rule"), + mem("gamma_remote_headers", "decision", "Remote headers are passed through the HTTP transport request initialization headers option", "active", "declarative API rule"), + mem("gamma_signal_order", "decision", "Graceful process cleanup signal order: interrupt for 300ms, terminate for 700ms, then kill", "active", "durable process cleanup spec"), + mem("gamma_ownership", "decision", "Runtime state ownership model: the command-line entrypoint owns both runtime objects, and disposal order is primary runtime first", "active", "durable ownership model"), + mem("gamma_retry_policy", "decision", "Recovery retry policy: only once per tool call, only for transport or session failures", "active", "durable retry policy"), ], - "pdf-extraction": [ - mem("pe_user_cycle", "feedback", "User 要求完整的 plan-review-feedback-modify-verify 循環,不是直接執行", "active", "mixed-language user workflow preference"), - mem("pe_ollama_batch", "feedback", "Ollama 大批量嵌入需要控制批次大小(20-50)和請求間隔", "active", "durable operational knowledge"), - mem("pe_option_b", "decision", "Phase 2 Fix 採用 Option B:multi-profile search grouping", "active", "design decision using 採用"), - mem("pe_single_source", "decision", "MCP source 維持單一 `book`,書籍身份在 source ID", "active", "design constraint using 維持"), - mem("pe_endpoint", "decision", "Ollama endpoint is `/api/embed` (not `/api/embeddings`) with `\"input\"` field", "active", "declarative API fact"), - mem("pe_filter_pipeline", "decision", "Filter pipeline: pre-chunk filtering (not post-chunk) to prevent embedding contamination", "active", "durable architecture rule"), - mem("pe_do_not_delete", "decision", "不刪除孤立的 reference-like 行(正文中的 \"et al.\" 等是合法引用)", "active", "do-not rule not matching current 不要 pattern"), + "workspace-delta": [ + mem("delta_user_cycle", "feedback", "User requires a complete plan, review, feedback, modify, and verify loop rather than direct execution", "active", "user workflow preference"), + mem("delta_batching", "feedback", "Large-batch embedding requires controlled batch size around 20 to 50 items and a delay between requests", "active", "durable operational knowledge"), + mem("delta_option_b", "decision", "Phase 2 fix adopted Option B: grouped search across multiple profiles", "active", "design decision using adopted"), + mem("delta_single_source", "decision", "MCP source keeps a single generic source type, with item identity encoded in the source ID", "active", "design constraint using keeps"), + mem("delta_endpoint", "decision", "Embedding service endpoint is `/api/embed` rather than `/api/embeddings`, with the input field in the request body", "active", "declarative API fact"), + mem("delta_filter_pipeline", "decision", "Filter pipeline uses pre-chunk filtering rather than post-chunk filtering to prevent embedding contamination", "active", "durable architecture rule"), + mem("delta_do_not_delete", "decision", "Do not delete isolated reference-like lines because citation fragments in body text can be valid references", "active", "do-not rule"), ], - "self-repo": [ - mem("sr_author_credit", "feedback", "User insists on preserving external contributor author credit and uses merge workflow", "active", "durable preference using insists"), - mem("sr_branding", "decision", "Product branding is \"OpenCode Working Memory\" without \"Plugin\" in the name", "active", "durable branding rule"), - mem("sr_changelog", "decision", "CHANGELOG version scope follows git tags: changes from v1.2.3 tag through HEAD belong to next version", "active", "durable release rule"), + "workspace-epsilon": [ + mem("epsilon_author_credit", "feedback", "User insists on preserving external contributor author credit and uses merge workflow", "active", "durable preference using insists"), + mem("epsilon_branding", "decision", "Product branding is \"Generic Working Memory\" without \"Plugin\" in the name", "active", "durable branding rule"), + mem("epsilon_changelog", "decision", "Changelog version scope follows release tags: changes from the previous version tag through the current branch belong to the next version", "active", "durable release rule"), ], }; diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 91f92f9..ed42ca5 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -1080,6 +1080,89 @@ test("quality cleanup migration writes audit log for hard supersedes", async () } }); +test("quality cleanup migration aborts supersede when audit log cannot be written", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-")); + const dataHome = join(sandbox, "xdg-data-home"); + const root = join(sandbox, "workspace"); + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const previousConsoleError = console.error; + process.env.XDG_DATA_HOME = dataHome; + console.error = () => {}; + + try { + await mkdir(root, { recursive: true }); + const now = "2026-04-28T00:00:00.000Z"; + const storePath = await workspaceMemoryPath(root); + await mkdir(dirname(storePath), { recursive: true }); + await writeFile(storePath, JSON.stringify({ + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [{ + id: "hard_progress", + type: "project", + text: "Test suite: 1237 tests pass, 226 suites", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: 60, + }], + migrations: [], + updatedAt: now, + }, null, 2), "utf8"); + + const blockedLogDir = join(dataHome, "opencode-working-memory", "migration-logs"); + await writeFile(blockedLogDir, "not a directory", "utf8"); + + const loaded = await loadWorkspaceMemory(root); + const persisted = JSON.parse(await readFile(storePath, "utf8")) as WorkspaceMemoryStore; + + assert.equal(loaded.entries.find(entry => entry.id === "hard_progress")?.status, "active"); + assert.equal(persisted.entries.find(entry => entry.id === "hard_progress")?.status, "active"); + assert.equal(loaded.migrations?.includes("2026-04-28-quality-cleanup"), false); + assert.equal(persisted.migrations?.includes("2026-04-28-quality-cleanup"), false); + } finally { + console.error = previousConsoleError; + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + await rm(sandbox, { recursive: true, force: true }); + } +}); + +test("real workspace regression fixture is de-identified and English-only", () => { + const cjkText = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; + const identifyingTerms = [ + "medical-atlas", + "opencode-record", + "agent-reports", + "pdf-extraction", + "self-repo", + "OpenCode Working Memory", + ]; + const failures: string[] = []; + + for (const [workspaceName, fixtureEntries] of Object.entries(REAL_WORKSPACE_FIXTURES)) { + if (identifyingTerms.some(term => workspaceName.includes(term))) { + failures.push(`${workspaceName}: workspace key should be generalized`); + } + + for (const entry of fixtureEntries) { + if (cjkText.test(entry.text)) { + failures.push(`${workspaceName}/${entry.id}: text must be English-only`); + } + for (const term of identifyingTerms) { + if (entry.text.includes(term)) { + failures.push(`${workspaceName}/${entry.id}: text contains identifying term ${term}`); + } + } + } + } + + assert.equal(failures.length, 0, `Fixture privacy failures:\n${failures.join("\n")}`); +}); + test("quality cleanup migration regression against real workspace samples", async () => { const failures: string[] = []; const now = "2026-04-28T00:00:00.000Z"; From 60b9ca75c8e7d483b3aa46f52294801b7447ac3d Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 14:50:30 +0800 Subject: [PATCH 11/11] fix(memory): isolate test workspace cleanup --- .gitignore | 3 +- CHANGELOG.md | 2 + README.md | 10 + RELEASE_NOTES.md | 18 ++ package.json | 3 +- scripts/dev/cleanup-workspaces.ts | 100 +++++++ ...{dry-run-migration.ts => run-migration.ts} | 14 +- src/extractors.ts | 12 +- src/redaction.ts | 73 +++++ src/workspace-cleanup.ts | 282 ++++++++++++++++++ src/workspace-memory.ts | 51 +--- tests/setup-xdg-data-home.ts | 21 ++ tests/workspace-cleanup.test.ts | 171 +++++++++++ tests/workspace-memory.test.ts | 2 +- 14 files changed, 692 insertions(+), 70 deletions(-) create mode 100644 scripts/dev/cleanup-workspaces.ts rename scripts/dev/{dry-run-migration.ts => run-migration.ts} (71%) create mode 100644 src/redaction.ts create mode 100644 src/workspace-cleanup.ts create mode 100644 tests/setup-xdg-data-home.ts create mode 100644 tests/workspace-cleanup.test.ts diff --git a/.gitignore b/.gitignore index 6ebddb7..53177b9 100644 --- a/.gitignore +++ b/.gitignore @@ -52,5 +52,6 @@ pnpm-lock.yaml # Superpowers local planning artifacts docs/superpowers/plans/ -# Local migration dry-run roots +# Local dev/admin script inputs +scripts/dev/run-migration-roots.local.txt scripts/dev/dry-run-roots.local.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9559f72..c7c28d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Local extraction rejection log for rejected compaction memory candidates: `~/.local/share/opencode-working-memory/extraction-rejections.jsonl`. - Sanitized real-workspace regression fixtures for memory cleanup migration behavior. +- Safe workspace residue cleanup tooling that dry-runs by default and quarantines definite temp/test workspace stores instead of deleting them. ### Changed @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rewritten compaction memory prompt to reduce over-production of low-quality memories. - Changed quality cleanup migration to be conservative: it supersedes only high-confidence garbage patterns, including progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries. - Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, user workflow preferences, and architecture decisions. +- Isolated test runs under a temporary `XDG_DATA_HOME` so test workspaces no longer pollute real local workspace memory data. ### Recovery note diff --git a/README.md b/README.md index 2760930..5b877d6 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,15 @@ The goal is to remember durable facts, not every detail. Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y". +For local development cleanup, use: + +```bash +npm run cleanup:workspaces -- --dry-run +npm run cleanup:workspaces -- --quarantine +``` + +The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces. + ## Configuration OpenCode Working Memory works out of the box. @@ -212,6 +221,7 @@ cd opencode-working-memory npm install npm test npm run typecheck +npm run cleanup:workspaces -- --dry-run ``` ## Requirements diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d3ad70f..307b7f7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -16,6 +16,8 @@ The quality gate is now shared across compaction extraction and migration checks - **Audit logs**: automatic migration cleanup writes local JSONL audit records so superseded entries can be inspected and restored. - **Extraction rejection logs**: newly rejected compaction candidates are logged locally to help calibrate future quality rules. - **Regression coverage**: migration behavior is tested against sanitized real-workspace patterns to prevent mass false positives from coming back. +- **Workspace cleanup tooling**: a dev/admin cleanup command can dry-run or quarantine definite temp/test workspace residues without deleting unknown missing-root workspaces. +- **Test storage isolation**: test runs now use a temporary `XDG_DATA_HOME`, preventing fixture workspaces from polluting real local memory data. ### What Gets Cleaned Up @@ -63,6 +65,22 @@ Explicit and manual memories are also protected. If a useful memory is superseded, inspect the migration audit log and restore the entry by changing its status back to `"active"` in the workspace's `workspace-memory.json`. +### Workspace Residue Cleanup + +If old test/temp workspace stores already exist locally, inspect them first: + +```bash +npm run cleanup:workspaces -- --dry-run +``` + +To move definite temp/test residues into a local quarantine folder instead of deleting them: + +```bash +npm run cleanup:workspaces -- --quarantine +``` + +The cleanup command skips existing workspace roots and unknown missing-root workspaces by default. + ### Upgrade Notes - No configuration changes required. diff --git a/package.json b/package.json index 8f05d3b..7487aed 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "scripts": { "build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"", "typecheck": "tsc --noEmit", - "test": "node --test --experimental-strip-types tests/*.test.ts", + "test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts", + "cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts", "check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test" }, "keywords": [ diff --git a/scripts/dev/cleanup-workspaces.ts b/scripts/dev/cleanup-workspaces.ts new file mode 100644 index 0000000..8230364 --- /dev/null +++ b/scripts/dev/cleanup-workspaces.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Safely inspect or quarantine stale test/temp workspace memory stores. + * + * Default mode is dry-run. Quarantine moves only definite temp/test residues. + * Unknown missing roots are reported but skipped unless --include-orphans is set. + */ + +import { cleanupWorkspaceResidues } from "../../src/workspace-cleanup.ts"; + +type CliOptions = { + mode: "dry-run" | "quarantine"; + dataHome?: string; + olderThanDays?: number; + includeOrphans: boolean; +}; + +function usage(): string { + return `Usage: + npm run cleanup:workspaces -- --dry-run + npm run cleanup:workspaces -- --quarantine + npm run cleanup:workspaces -- --quarantine --older-than-days 1 + +Options: + --dry-run List candidates without moving anything (default) + --quarantine Move definite temp/test residues to quarantine + --data-home Override XDG data home for testing/admin work + --older-than-days Only consider workspace dirs older than n days + --include-orphans Also quarantine missing non-temp roots (off by default) + --help Show this help +`; +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { mode: "dry-run", includeOrphans: false }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case "--dry-run": + options.mode = "dry-run"; + break; + case "--quarantine": + options.mode = "quarantine"; + break; + case "--data-home": + options.dataHome = argv[++i]; + if (!options.dataHome) throw new Error("--data-home requires a path"); + break; + case "--older-than-days": { + const value = Number(argv[++i]); + if (!Number.isFinite(value) || value < 0) throw new Error("--older-than-days requires a non-negative number"); + options.olderThanDays = value; + break; + } + case "--include-orphans": + options.includeOrphans = true; + break; + case "--help": + case "-h": + console.log(usage()); + process.exit(0); + default: + throw new Error(`Unknown option: ${arg}\n${usage()}`); + } + } + + return options; +} + +const options = parseArgs(process.argv.slice(2)); +const result = await cleanupWorkspaceResidues({ + dataHome: options.dataHome, + mode: options.mode, + includeOrphans: options.includeOrphans, + minAgeMs: options.olderThanDays === undefined ? undefined : options.olderThanDays * 24 * 60 * 60 * 1_000, +}); + +console.log(`Mode: ${result.mode}`); +console.log(`Scanned: ${result.results.length}`); +console.log(`Candidates: ${result.candidates.length}`); + +if (result.candidates.length > 0) { + console.log("\nCandidates:"); + for (const candidate of result.candidates) { + console.log(`- ${candidate.workspaceKey} ${candidate.classification} root=${candidate.root ?? ""}`); + console.log(` reasons=${candidate.reasons.join(",")}`); + } +} + +if (result.quarantined.length > 0) { + console.log(`\nQuarantined: ${result.quarantined.length}`); + console.log(`Quarantine dir: ${result.quarantineDir}`); +} + +const unknownOrphans = result.results.filter(item => item.classification === "orphan_unknown"); +if (unknownOrphans.length > 0 && !options.includeOrphans) { + console.log(`\nUnknown missing-root workspaces skipped: ${unknownOrphans.length}`); + console.log("Use --include-orphans only after manually confirming they are safe to quarantine."); +} diff --git a/scripts/dev/dry-run-migration.ts b/scripts/dev/run-migration.ts similarity index 71% rename from scripts/dev/dry-run-migration.ts rename to scripts/dev/run-migration.ts index 76a2e4f..4e86226 100644 --- a/scripts/dev/dry-run-migration.ts +++ b/scripts/dev/run-migration.ts @@ -2,12 +2,12 @@ * Local helper to trigger migration on workspace roots. * * Usage: - * MIGRATION_DRY_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/dry-run-migration.ts + * MIGRATION_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/run-migration.ts * * Or create a local file (gitignored): - * echo "/path/to/workspace1" > scripts/dev/dry-run-roots.local.txt - * echo "/path/to/workspace2" >> scripts/dev/dry-run-roots.local.txt - * bun run scripts/dev/dry-run-migration.ts + * echo "/path/to/workspace1" > scripts/dev/run-migration-roots.local.txt + * echo "/path/to/workspace2" >> scripts/dev/run-migration-roots.local.txt + * bun run scripts/dev/run-migration.ts */ import { existsSync } from "node:fs"; @@ -17,13 +17,13 @@ import { loadWorkspaceMemory } from "../../src/workspace-memory.ts"; async function getRoots(): Promise { // Priority 1: environment variable - const envRoots = process.env.MIGRATION_DRY_RUN_ROOTS; + const envRoots = process.env.MIGRATION_RUN_ROOTS; if (envRoots) { return envRoots.split(":").filter(root => root.length > 0); } // Priority 2: local file - const localFile = join(import.meta.dirname, "dry-run-roots.local.txt"); + const localFile = join(import.meta.dirname, "run-migration-roots.local.txt"); if (existsSync(localFile)) { const content = await readFile(localFile, "utf8"); return content.trim().split("\n").filter(root => root.length > 0); @@ -31,7 +31,7 @@ async function getRoots(): Promise { // No roots configured console.log("No workspace roots configured."); - console.log("Set MIGRATION_DRY_RUN_ROOTS=/path/a:/path/b or create dry-run-roots.local.txt"); + console.log("Set MIGRATION_RUN_ROOTS=/path/a:/path/b or create run-migration-roots.local.txt"); return []; } diff --git a/src/extractors.ts b/src/extractors.ts index 39feaa4..4370e81 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -5,6 +5,7 @@ import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from ". import { LONG_TERM_LIMITS } from "./types.ts"; import { assessMemoryQuality } from "./memory-quality.ts"; import { extractionRejectionLogPath } from "./paths.ts"; +import { redactCredentials } from "./redaction.ts"; function id(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -248,15 +249,6 @@ async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promi } } -function redactSensitiveText(text: string): string { - return text - .replace(/bearer\s+[a-zA-Z0-9._-]+/gi, "bearer [REDACTED]") - .replace(/token[=:]\s*[a-zA-Z0-9._-]+/gi, "token=[REDACTED]") - .replace(/password[=:]\s*[a-zA-Z0-9._-]+/gi, "password=[REDACTED]") - .replace(/secret[=:]\s*[a-zA-Z0-9._-]+/gi, "secret=[REDACTED]") - .replace(/api[-_]?key[=:]\s*[a-zA-Z0-9._-]+/gi, "api_key=[REDACTED]"); -} - function shouldAcceptWorkspaceMemoryCandidate( entry: { type: LongTermType; @@ -287,7 +279,7 @@ function shouldAcceptWorkspaceMemoryCandidate( void logExtractionRejection({ timestamp: new Date().toISOString(), type: entry.type, - text: redactSensitiveText(text), + text: redactCredentials(text), reasons: quality.reasons, source: "compaction", }); diff --git a/src/redaction.ts b/src/redaction.ts new file mode 100644 index 0000000..da9506e --- /dev/null +++ b/src/redaction.ts @@ -0,0 +1,73 @@ +/** + * Shared redaction utilities for sensitive credential patterns. + * Used by both workspace memory normalization and extraction rejection logging. + */ + +// Password labels in multiple languages +const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i; + +// Username labels in multiple languages +const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i; + +// Sensitive key labels +const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i; + +// Secret value pattern (excludes common delimiters and brackets) +const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`; + +// Prefix patterns for different credential types +const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; +const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; +const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; +const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`; +const BEARER_PREFIX = String.raw`(Bearer\s+)`; + +/** + * Redacts sensitive credentials from text. + * Handles: + * - PINs in multiple formats + * - Username/password pairs + * - Standalone passwords + * - Bearer tokens + * - API keys, secrets, credentials, auth tokens, private keys + * + * Supports multiple languages and delimiters (ASCII and CJK). + */ +export function redactCredentials(text: string): string { + let result = text; + + // 1. PIN + result = result.replace( + new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), + "$1[REDACTED]", + ); + + // 2. Username+password pair + result = result.replace( + new RegExp( + String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, + "gi", + ), + "$1[REDACTED]$3$4[REDACTED]", + ); + + // 3. Standalone password + result = result.replace( + new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), + "$1[REDACTED]", + ); + + // 4. Bearer tokens (but not "bearer token:" labels) + result = result.replace( + new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"), + "$1[REDACTED]", + ); + + // 5. Sensitive keys/tokens + result = result.replace( + new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), + "$1[REDACTED]", + ); + + return result; +} \ No newline at end of file diff --git a/src/workspace-cleanup.ts b/src/workspace-cleanup.ts new file mode 100644 index 0000000..32f2a2a --- /dev/null +++ b/src/workspace-cleanup.ts @@ -0,0 +1,282 @@ +import { existsSync } from "node:fs"; +import { appendFile, mkdir, readFile, readdir, rename, stat } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { dataHome as defaultDataHome } from "./paths.ts"; + +export type WorkspaceCleanupClassification = + | "test_temp_definite" + | "orphan_unknown" + | "live_or_existing" + | "invalid_or_unreadable"; + +export type WorkspaceCleanupResult = { + workspaceKey: string; + workspaceDir: string; + root?: string; + rootExists: boolean; + classification: WorkspaceCleanupClassification; + reasons: string[]; + entryCount?: number; + migrations?: string[]; + updatedAt?: string; +}; + +export type WorkspaceCleanupScanOptions = { + dataHome?: string; + nowMs?: number; + minAgeMs?: number; + includeOrphans?: boolean; +}; + +export type WorkspaceCleanupScan = { + results: WorkspaceCleanupResult[]; + candidates: WorkspaceCleanupResult[]; +}; + +export type WorkspaceCleanupMode = "dry-run" | "quarantine"; + +export type WorkspaceCleanupOptions = WorkspaceCleanupScanOptions & { + mode?: WorkspaceCleanupMode; + now?: Date; +}; + +export type WorkspaceCleanupQuarantineEvent = WorkspaceCleanupResult & { + from: string; + to: string; + quarantinedAt: string; +}; + +export type WorkspaceCleanupRunResult = WorkspaceCleanupScan & { + mode: WorkspaceCleanupMode; + quarantined: WorkspaceCleanupQuarantineEvent[]; + quarantineDir?: string; +}; + +type WorkspaceMemoryShape = { + workspace?: { + root?: unknown; + key?: unknown; + }; + entries?: unknown[]; + migrations?: unknown[]; + updatedAt?: unknown; +}; + +const DEFAULT_MIN_AGE_MS = 10 * 60 * 1_000; + +const KNOWN_TEST_ROOT_PREFIXES = [ + "memory-plugin-test-", + "memory-plugin-prompt-", + "wm-", + "wm-quality-", + "wm-accounting-", + "wm-redact-", + "wm-normalized-", + "wm-ordering-", + "wm-extraction-", +]; + +function normalizePathForComparison(path: string): string { + return resolve(path).replace(/\/+$/, ""); +} + +function isInsidePath(path: string, parent: string): boolean { + const normalizedPath = normalizePathForComparison(path); + const normalizedParent = normalizePathForComparison(parent); + return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`); +} + +export function isTempRoot(root: string, osTmpdir = tmpdir()): boolean { + const normalized = normalizePathForComparison(root); + const normalizedTmp = normalizePathForComparison(osTmpdir); + + if (isInsidePath(normalized, normalizedTmp)) return true; + if (isInsidePath(normalized, "/tmp")) return true; + if (isInsidePath(normalized, "/private/tmp")) return true; + + return /^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/.test(normalized); +} + +export function isKnownTestWorkspaceRoot(root: string): boolean { + const name = basename(root); + return KNOWN_TEST_ROOT_PREFIXES.some(prefix => name.startsWith(prefix)); +} + +function classifyCandidate(result: WorkspaceCleanupResult, includeOrphans: boolean): boolean { + if (result.reasons.includes("recent_workspace_dir")) return false; + if (result.reasons.includes("lock_present")) return false; + if (result.classification === "test_temp_definite") return true; + return includeOrphans && result.classification === "orphan_unknown"; +} + +export async function classifyWorkspaceDir( + workspaceDir: string, + options: { nowMs?: number; minAgeMs?: number } = {}, +): Promise { + const workspaceKey = basename(workspaceDir); + const reasons: string[] = []; + const memoryPath = join(workspaceDir, "workspace-memory.json"); + + if (existsSync(`${memoryPath}.lock`)) { + reasons.push("lock_present"); + } + + let stats; + try { + stats = await stat(workspaceDir); + } catch { + return { + workspaceKey, + workspaceDir, + rootExists: false, + classification: "invalid_or_unreadable", + reasons: ["workspace_dir_unreadable"], + }; + } + + const minAgeMs = options.minAgeMs ?? DEFAULT_MIN_AGE_MS; + const nowMs = options.nowMs ?? Date.now(); + if (minAgeMs > 0 && nowMs - stats.mtimeMs < minAgeMs) { + reasons.push("recent_workspace_dir"); + } + + let store: WorkspaceMemoryShape; + try { + store = JSON.parse(await readFile(memoryPath, "utf8")) as WorkspaceMemoryShape; + } catch { + return { + workspaceKey, + workspaceDir, + rootExists: false, + classification: "invalid_or_unreadable", + reasons: [...reasons, "invalid_json"], + }; + } + + const root = typeof store.workspace?.root === "string" ? store.workspace.root : undefined; + const key = typeof store.workspace?.key === "string" ? store.workspace.key : workspaceKey; + const entryCount = Array.isArray(store.entries) ? store.entries.length : undefined; + const migrations = Array.isArray(store.migrations) ? store.migrations.filter((item): item is string => typeof item === "string") : undefined; + const updatedAt = typeof store.updatedAt === "string" ? store.updatedAt : undefined; + + if (!root) { + return { + workspaceKey: key, + workspaceDir, + rootExists: false, + classification: "invalid_or_unreadable", + reasons: [...reasons, "missing_workspace_root"], + entryCount, + migrations, + updatedAt, + }; + } + + const rootExists = existsSync(root); + if (rootExists) { + return { + workspaceKey: key, + workspaceDir, + root, + rootExists, + classification: "live_or_existing", + reasons: [...reasons, "root_exists"], + entryCount, + migrations, + updatedAt, + }; + } + + reasons.push("root_missing"); + const tempRoot = isTempRoot(root); + const testRoot = isKnownTestWorkspaceRoot(root); + if (tempRoot) reasons.push("root_under_temp"); + if (testRoot) reasons.push(`test_prefix_${KNOWN_TEST_ROOT_PREFIXES.find(prefix => basename(root).startsWith(prefix))?.replace(/-$/, "") ?? basename(root)}`); + + return { + workspaceKey: key, + workspaceDir, + root, + rootExists, + classification: tempRoot || testRoot ? "test_temp_definite" : "orphan_unknown", + reasons, + entryCount, + migrations, + updatedAt, + }; +} + +function workspacesDir(dataHome: string): string { + return join(dataHome, "opencode-working-memory", "workspaces"); +} + +export async function scanWorkspaceResidues(options: WorkspaceCleanupScanOptions = {}): Promise { + const root = workspacesDir(options.dataHome ?? defaultDataHome()); + const results: WorkspaceCleanupResult[] = []; + + let entries: string[]; + try { + entries = await readdir(root); + } catch { + return { results, candidates: [] }; + } + + for (const entry of entries) { + const workspaceDir = join(root, entry); + const stats = await stat(workspaceDir).catch(() => undefined); + if (!stats?.isDirectory()) continue; + + results.push(await classifyWorkspaceDir(workspaceDir, { + nowMs: options.nowMs, + minAgeMs: options.minAgeMs, + })); + } + + return { + results, + candidates: results.filter(result => classifyCandidate(result, options.includeOrphans ?? false)), + }; +} + +function quarantineName(now: Date): string { + return `workspace-cleanup-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`; +} + +export async function cleanupWorkspaceResidues(options: WorkspaceCleanupOptions = {}): Promise { + const mode = options.mode ?? "dry-run"; + const now = options.now ?? new Date(); + const scan = await scanWorkspaceResidues({ + dataHome: options.dataHome, + nowMs: options.nowMs, + minAgeMs: options.minAgeMs, + includeOrphans: options.includeOrphans, + }); + + if (mode === "dry-run" || scan.candidates.length === 0) { + return { ...scan, mode, quarantined: [] }; + } + + const dataHome = options.dataHome ?? defaultDataHome(); + const quarantineDir = join(dataHome, "opencode-working-memory", "quarantine", quarantineName(now)); + const quarantined: WorkspaceCleanupQuarantineEvent[] = []; + + for (const candidate of scan.candidates) { + const destination = join(quarantineDir, "workspaces", candidate.workspaceKey); + await mkdir(dirname(destination), { recursive: true }); + await rename(candidate.workspaceDir, destination); + + const event: WorkspaceCleanupQuarantineEvent = { + ...candidate, + from: candidate.workspaceDir, + to: destination, + quarantinedAt: now.toISOString(), + }; + quarantined.push(event); + + await mkdir(quarantineDir, { recursive: true }); + await appendFile(join(quarantineDir, "manifest.jsonl"), JSON.stringify(event) + "\n", "utf8"); + } + + return { ...scan, mode, quarantined, quarantineDir }; +} diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 78083bd..12d74bb 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -5,24 +5,13 @@ import { LONG_TERM_LIMITS } from "./types.ts"; import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts"; +import { redactCredentials } from "./redaction.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; const MIGRATION_ID = "2026-04-26-p0-cleanup"; const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup"; -const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`; - -const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i; -const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i; -const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i; - -const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; -const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; -const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; -const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`; -const BEARER_PREFIX = String.raw`(Bearer\s+)`; - export type MemoryConsolidationReason = | "promoted" | "absorbed_exact" @@ -250,44 +239,6 @@ export async function normalizeWorkspaceMemoryWithAccounting( }; } -export function redactCredentials(text: string): string { - let result = text; - - // 1. PIN - result = result.replace( - new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), - "$1[REDACTED]", - ); - - // 2. Username+password pair - result = result.replace( - new RegExp( - String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, - "gi", - ), - "$1[REDACTED]$3$4[REDACTED]", - ); - - // 3. Standalone password - result = result.replace( - new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), - "$1[REDACTED]", - ); - - // 4. Standalone sensitive keys/tokens - result = result.replace( - new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"), - "$1[REDACTED]", - ); - - result = result.replace( - new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"), - "$1[REDACTED]", - ); - - return result; -} - export function runMigrationP0Cleanup( store: WorkspaceMemoryStore, nowIso: string, diff --git a/tests/setup-xdg-data-home.ts b/tests/setup-xdg-data-home.ts new file mode 100644 index 0000000..3d45624 --- /dev/null +++ b/tests/setup-xdg-data-home.ts @@ -0,0 +1,21 @@ +import { after } from "node:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const previousXdgDataHome = process.env.XDG_DATA_HOME; +const previousTestFlag = process.env.OPENCODE_WORKING_MEMORY_TEST; +const testDataHome = await mkdtemp(join(tmpdir(), "opencode-working-memory-test-xdg-")); + +process.env.XDG_DATA_HOME = testDataHome; +process.env.OPENCODE_WORKING_MEMORY_TEST = "1"; + +after(async () => { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + + if (previousTestFlag === undefined) delete process.env.OPENCODE_WORKING_MEMORY_TEST; + else process.env.OPENCODE_WORKING_MEMORY_TEST = previousTestFlag; + + await rm(testDataHome, { recursive: true, force: true }); +}); diff --git a/tests/workspace-cleanup.test.ts b/tests/workspace-cleanup.test.ts new file mode 100644 index 0000000..cfe5ec0 --- /dev/null +++ b/tests/workspace-cleanup.test.ts @@ -0,0 +1,171 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + classifyWorkspaceDir, + cleanupWorkspaceResidues, + scanWorkspaceResidues, +} from "../src/workspace-cleanup.ts"; + +async function writeWorkspaceStore(dataHome: string, key: string, root: string): Promise { + const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", key); + await mkdir(workspaceDir, { recursive: true }); + await writeFile( + join(workspaceDir, "workspace-memory.json"), + JSON.stringify({ + version: 1, + workspace: { root, key }, + limits: { maxRenderedChars: 5200, maxEntries: 28 }, + entries: [], + updatedAt: "2026-04-28T00:00:00.000Z", + }, null, 2), + "utf8", + ); + return workspaceDir; +} + +test("workspace cleanup classifies missing temp test roots as definite residue", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingTempRoot = join(tmpdir(), "memory-plugin-test-missing-root"); + await rm(missingTempRoot, { recursive: true, force: true }); + const workspaceDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot); + + const result = await classifyWorkspaceDir(workspaceDir); + + assert.equal(result.classification, "test_temp_definite"); + assert.equal(result.rootExists, false); + assert.ok(result.reasons.includes("root_missing")); + assert.ok(result.reasons.some(reason => reason.startsWith("root_under_temp"))); + assert.ok(result.reasons.includes("test_prefix_memory-plugin-test")); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup keeps existing temp roots live", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + const liveRoot = await mkdtemp(join(tmpdir(), "wm-quality-live-root-")); + try { + const workspaceDir = await writeWorkspaceStore(dataHome, "live", liveRoot); + + const result = await classifyWorkspaceDir(workspaceDir); + + assert.equal(result.classification, "live_or_existing"); + assert.equal(result.rootExists, true); + } finally { + await rm(dataHome, { recursive: true, force: true }); + await rm(liveRoot, { recursive: true, force: true }); + } +}); + +test("workspace cleanup reports missing non-temp roots as unknown orphans", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingNonTempRoot = `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`; + const workspaceDir = await writeWorkspaceStore(dataHome, "orphan", missingNonTempRoot); + + const result = await classifyWorkspaceDir(workspaceDir); + + assert.equal(result.classification, "orphan_unknown"); + assert.equal(result.rootExists, false); + assert.ok(result.reasons.includes("root_missing")); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup reports invalid stores without moving them", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", "invalid"); + await mkdir(workspaceDir, { recursive: true }); + await writeFile(join(workspaceDir, "workspace-memory.json"), "{ invalid", "utf8"); + + const result = await classifyWorkspaceDir(workspaceDir); + + assert.equal(result.classification, "invalid_or_unreadable"); + assert.ok(result.reasons.includes("invalid_json")); + + const cleanup = await cleanupWorkspaceResidues({ dataHome, mode: "quarantine" }); + assert.equal(cleanup.quarantined.length, 0); + assert.equal(existsSync(workspaceDir), true); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup dry-run scans definite residue without moving it", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingTempRoot = join(tmpdir(), "wm-accounting-missing-root"); + await rm(missingTempRoot, { recursive: true, force: true }); + const workspaceDir = await writeWorkspaceStore(dataHome, "dryrun", missingTempRoot); + + const result = await cleanupWorkspaceResidues({ dataHome, minAgeMs: 0 }); + + assert.equal(result.mode, "dry-run"); + assert.equal(result.candidates.length, 1); + assert.equal(result.quarantined.length, 0); + assert.equal(existsSync(workspaceDir), true); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup quarantine moves definite residue and writes manifest", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingTempRoot = join(tmpdir(), "wm-redact-missing-root"); + await rm(missingTempRoot, { recursive: true, force: true }); + const definiteDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot); + const orphanDir = await writeWorkspaceStore(dataHome, "orphan", `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`); + + const result = await cleanupWorkspaceResidues({ + dataHome, + mode: "quarantine", + minAgeMs: 0, + now: new Date("2026-04-28T12:00:00.000Z"), + }); + + assert.equal(result.quarantined.length, 1); + assert.equal(result.quarantined[0]?.workspaceKey, "definite"); + assert.equal(existsSync(definiteDir), false); + assert.equal(existsSync(orphanDir), true); + assert.ok(result.quarantineDir); + assert.equal(existsSync(join(result.quarantineDir!, "workspaces", "definite", "workspace-memory.json")), true); + + const manifest = await readFile(join(result.quarantineDir!, "manifest.jsonl"), "utf8"); + const event = JSON.parse(manifest.trim()); + assert.equal(event.workspaceKey, "definite"); + assert.equal(event.classification, "test_temp_definite"); + assert.equal(event.root, missingTempRoot); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); + +test("workspace cleanup skips recently updated definite residue", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-")); + try { + const missingTempRoot = join(tmpdir(), "wm-extraction-missing-root"); + await rm(missingTempRoot, { recursive: true, force: true }); + const workspaceDir = await writeWorkspaceStore(dataHome, "recent", missingTempRoot); + + const stats = await stat(workspaceDir); + const result = await scanWorkspaceResidues({ + dataHome, + nowMs: stats.mtimeMs + 1_000, + minAgeMs: 10 * 60 * 1_000, + }); + + assert.equal(result.candidates.length, 0); + assert.equal(result.results.find(item => item.workspaceKey === "recent")?.classification, "test_temp_definite"); + assert.ok(result.results.find(item => item.workspaceKey === "recent")?.reasons.includes("recent_workspace_dir")); + } finally { + await rm(dataHome, { recursive: true, force: true }); + } +}); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index ed42ca5..bd48ee9 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -14,13 +14,13 @@ import { normalizeWorkspaceMemoryWithAccounting, workspaceMemoryExactKey, workspaceMemoryIdentityKey, - redactCredentials, runMigrationP0Cleanup, runMigrationQualityCleanup, loadWorkspaceMemory, saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; +import { redactCredentials } from "../src/redaction.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts"; import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts"; import { REAL_WORKSPACE_FIXTURES } from "./fixtures/real-workspaces-snapshot.ts";