import test from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; 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 { renderWorkspaceMemory, enforceLongTermLimits, dedupeLongTermEntriesWithAccounting, enforceLongTermLimitsWithAccounting, normalizeWorkspaceMemoryWithAccounting, workspaceMemoryExactKey, redactCredentials, isProjectSnapshotViolation, runMigrationP0Cleanup, loadWorkspaceMemory, saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { const now = new Date().toISOString(); return { id, type, text, source: "compaction", confidence: 0.75, status: "active", createdAt: now, updatedAt: now, }; } /** Create an entry with a createdAt offset from now (negative = in the past) */ function agedEntry( id: string, text: string, type: LongTermMemoryEntry["type"] = "decision", opts: { daysAgo: number; source?: "compaction" | "explicit" | "manual"; staleAfterDays?: number } = { daysAgo: 0, source: "compaction" }, ): LongTermMemoryEntry { const createdAt = new Date(Date.now() - opts.daysAgo * 86400000).toISOString(); return { id, type, text, source: opts.source ?? "compaction", confidence: 0.75, status: "active", createdAt, updatedAt: createdAt, staleAfterDays: opts.staleAfterDays, }; } // ============================================ // Task 2: renderWorkspaceMemory tests // ============================================ test("renderWorkspaceMemory respects budget and fits entries", () => { const entries = Array.from({ length: 28 }, (_, i) => entry(`mem_${i}`, `Long durable memory entry ${i} `.repeat(20)) ); const store: WorkspaceMemoryStore = { version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: 700, maxEntries: 28 }, entries, updatedAt: new Date().toISOString(), }; const rendered = renderWorkspaceMemory(store); assert.ok(!rendered.includes(""), "Should not contain XML tags"); assert.ok(rendered.length <= 700, `Rendered memory must not exceed maxChars. Got: ${rendered.length}`); }); test("renderWorkspaceMemory returns empty string when maxChars too small", () => { const store: WorkspaceMemoryStore = { version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: 50, maxEntries: 28 }, entries: [entry("test", "test memory")], updatedAt: new Date().toISOString(), }; const rendered = renderWorkspaceMemory(store); assert.equal(rendered, "", "When maxChars too small for even minimal envelope, return empty string"); }); test("renderWorkspaceMemory respects small budget", () => { // Create entries that would overflow a small budget const entries = [ entry("a", "First memory entry that is reasonably long"), entry("b", "Second memory entry that is also reasonably long"), entry("c", "Third memory entry that is also reasonably long"), ]; const store: WorkspaceMemoryStore = { version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: 200, maxEntries: 28 }, entries, updatedAt: new Date().toISOString(), }; const rendered = renderWorkspaceMemory(store); assert.ok(!rendered.includes(""), "Should not contain XML tags"); assert.ok(rendered.length <= 200, `Must respect maxChars limit. Got: ${rendered.length}`); }); test("renderWorkspaceMemory returns empty for no entries", () => { const store: WorkspaceMemoryStore = { version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: 5200, maxEntries: 28 }, entries: [], updatedAt: new Date().toISOString(), }; const rendered = renderWorkspaceMemory(store); assert.equal(rendered, ""); }); // ============================================ // PR-2 Task 5 tests (for enforceLongTermLimits) // ============================================ test("enforceLongTermLimits dedupes with canonical text", () => { const now = new Date().toISOString(); const a: LongTermMemoryEntry = { id: "a", type: "decision", text: "OpenCode uses NPM CACHE for plugin loading", source: "compaction", confidence: 0.75, status: "active", createdAt: now, updatedAt: now, }; const b: LongTermMemoryEntry = { id: "b", type: "decision", text: "opencode uses npm cache for plugin loading!!!", source: "compaction", confidence: 0.8, status: "active", createdAt: now, updatedAt: now, }; const kept = enforceLongTermLimits([a, b]); assert.equal(kept.length, 1, "Should dedupe similar texts"); assert.equal(kept[0].confidence, 0.8, "Higher confidence should win for same source"); }); test("enforceLongTermLimits preserves explicit over compaction", () => { const now = new Date().toISOString(); const explicit: LongTermMemoryEntry = { id: "explicit", type: "decision", text: "Use pnpm for this project", source: "explicit", confidence: 0.5, status: "active", createdAt: now, updatedAt: now, }; const compaction: LongTermMemoryEntry = { id: "compaction", type: "decision", text: "Use pnpm for this project", source: "compaction", confidence: 0.9, status: "active", createdAt: now, updatedAt: now, }; const kept = enforceLongTermLimits([explicit, compaction]); assert.equal(kept.length, 1); assert.equal(kept[0].source, "explicit", "Explicit source should win over compaction even with lower confidence"); assert.equal(kept[0].confidence, 0.5, "Original explicit confidence preserved"); }); test("enforceLongTermLimits same source higher confidence wins", () => { const now = new Date().toISOString(); const a: LongTermMemoryEntry = { id: "a", type: "decision", text: "Project uses TypeScript", source: "compaction", confidence: 0.7, status: "active", createdAt: now, updatedAt: now, }; const b: LongTermMemoryEntry = { id: "b", type: "decision", text: "Project uses TypeScript", source: "compaction", confidence: 0.9, status: "active", createdAt: now, updatedAt: now, }; const kept = enforceLongTermLimits([a, b]); assert.equal(kept.length, 1); assert.equal(kept[0].confidence, 0.9, "Higher confidence wins for same source"); }); test("enforceLongTermLimits respects maxEntries limit", () => { const now = new Date().toISOString(); const entries = Array.from({ length: 50 }, (_, i) => ({ id: `mem_${i}`, type: "decision" as const, text: `Unique memory entry number ${i}`, source: "compaction" as const, confidence: 0.75, status: "active" as const, createdAt: now, updatedAt: now, })); const kept = enforceLongTermLimits(entries); assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`); }); test("dedupeLongTermEntriesWithAccounting reports exact duplicates as absorbed", () => { const now = new Date().toISOString(); const lower: LongTermMemoryEntry = { id: "lower", type: "decision", text: "OpenCode uses NPM CACHE for plugin loading", source: "compaction", confidence: 0.7, status: "active", createdAt: now, updatedAt: now, }; const higher: LongTermMemoryEntry = { id: "higher", type: "decision", text: "opencode uses npm cache for plugin loading!!!", source: "compaction", confidence: 0.8, status: "active", createdAt: now, updatedAt: now, }; const result = dedupeLongTermEntriesWithAccounting([lower, higher]); assert.equal(result.kept.length, 1); assert.equal(result.kept[0].id, "higher"); assert.deepEqual(result.absorbed.map(event => event.reason), ["absorbed_exact"]); assert.equal(result.absorbed[0].memory.id, "lower"); }); test("dedupeLongTermEntriesWithAccounting reports identity duplicates as absorbed", () => { const older = agedEntry( "older", "This repo uses opencode-agenthub plugin system at /Users/sd_wo/work/opencode-working-memory/", "project", { daysAgo: 5 }, ); const newer = agedEntry( "newer", "此 repo 在開發時使用 opencode-agenthub 插件系統,目錄位於 /Users/sd_wo/work/opencode-working-memory/.opencode-agenthub/", "project", { daysAgo: 0 }, ); const result = dedupeLongTermEntriesWithAccounting([older, newer]); assert.equal(result.kept.length, 1); assert.equal(result.absorbed.length, 1); assert.equal(result.absorbed[0].reason, "absorbed_identity"); assert.equal(result.absorbed[0].retainedId, result.kept[0].id); }); test("dedupeLongTermEntriesWithAccounting reports topic duplicates as superseded", () => { const older = agedEntry( "older", "Parser supports 3 formats: HTML comment, Markdown section, legacy XML", "decision", { daysAgo: 5 }, ); const newer = agedEntry( "newer", "Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML", "decision", { daysAgo: 0 }, ); const result = dedupeLongTermEntriesWithAccounting([older, newer]); assert.equal(result.kept.length, 1); assert.equal(result.kept[0].id, "newer"); assert.equal(result.superseded.length, 1); assert.equal(result.superseded[0].reason, "superseded_existing"); assert.equal(result.superseded[0].supersededId, "older"); assert.equal(result.superseded[0].retainedId, "newer"); }); test("enforceLongTermLimitsWithAccounting reports capacity drops", () => { const now = new Date().toISOString(); const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, i) => ({ id: `mem_${i}`, type: "reference" as const, text: `Unique low priority reference ${i}`, source: "compaction" as const, confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1, status: "active" as const, createdAt: now, updatedAt: now, })); const result = enforceLongTermLimitsWithAccounting(entries); assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries); assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 2); assert.ok(result.dropped.every(event => event.memory.source === "compaction")); }); test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () => { const now = new Date().toISOString(); const entry: LongTermMemoryEntry = { id: "key_alignment", type: "decision", text: "OpenCode uses NPM CACHE for plugin loading!!!", source: "compaction", confidence: 0.75, status: "active", createdAt: now, updatedAt: now, }; assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading"); }); test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => { const root = "/repo"; const now = new Date().toISOString(); const store: WorkspaceMemoryStore = { version: 1, workspace: { root, key: "abc" }, limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, migrations: ["2026-04-26-p0-cleanup"], entries: [{ id: "cred", type: "reference", text: "Admin PIN 是 456123", rationale: "password: sushi", source: "explicit", confidence: 1, status: "active", createdAt: now, updatedAt: now, }], updatedAt: now, }; const result = await normalizeWorkspaceMemoryWithAccounting(root, store); assert.equal(result.kept.length, 1); assert.equal(result.kept[0].text, "Admin PIN 是 [REDACTED]"); assert.equal(result.kept[0].rationale, "password: [REDACTED]"); assert.equal(result.store.entries[0].text, "Admin PIN 是 [REDACTED]"); }); test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => { const root = "/repo"; const now = new Date().toISOString(); const store: WorkspaceMemoryStore = { version: 1, workspace: { root, key: "abc" }, limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, migrations: ["2026-04-26-p0-cleanup"], entries: Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({ id: `overflow_${i}`, type: "reference" as const, text: `Overflow reference ${i}`, source: "compaction" as const, confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1, status: "active" as const, createdAt: now, updatedAt: now, })), updatedAt: now, }; const result = await normalizeWorkspaceMemoryWithAccounting(root, store); assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries); assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries); assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 1); }); test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async () => { const root = "/repo"; const now = new Date().toISOString(); const stale = agedEntry( "stale_normalize", "Old compaction decision should be removed by normalization accounting", "decision", { daysAgo: 90, staleAfterDays: 1 }, ); const store: WorkspaceMemoryStore = { version: 1, workspace: { root, key: "abc" }, limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, migrations: ["2026-04-26-p0-cleanup"], entries: [stale], updatedAt: now, }; const result = await normalizeWorkspaceMemoryWithAccounting(root, store); assert.equal(result.kept.length, 0); assert.equal(result.store.entries.length, 0); assert.deepEqual(result.dropped.map(event => event.reason), ["rejected_stale"]); assert.equal(result.dropped[0].memory.id, "stale_normalize"); }); test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted updates", async () => { const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-update-")); const dataHome = join(sandbox, "xdg-data-home"); const root = join(sandbox, "workspace"); const previousXdgDataHome = process.env.XDG_DATA_HOME; process.env.XDG_DATA_HOME = dataHome; try { const now = new Date().toISOString(); const result = await updateWorkspaceMemoryWithAccounting(root, store => { store.entries.push(...Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({ id: `persisted_${i}`, type: "reference" as const, text: `Persisted accounting reference ${i}`, source: "compaction" as const, confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1, status: "active" as const, createdAt: now, updatedAt: now, }))); return store; }); assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries); assert.equal(result.events.filter(event => event.reason === "rejected_capacity").length, 1); const persisted = await loadWorkspaceMemory(root); assert.equal(persisted.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries); } finally { if (previousXdgDataHome === undefined) { delete process.env.XDG_DATA_HOME; } else { process.env.XDG_DATA_HOME = previousXdgDataHome; } await rm(sandbox, { recursive: true, force: true }); } }); // ============================================ // P0d: identity-key dedup, supersession, staleness // ============================================ test("enforceLongTermLimits project: bilingual variants collapse to one", () => { // All three mention opencode-agenthub plugin system - should merge const entries = [ agedEntry("p1", "此 repo 在開發時使用 opencode-agenthub 插件系統,目錄位於 /Users/sd_wo/work/opencode-working-memory/.opencode-agenthub/", "project", { daysAgo: 2 }), agedEntry("p2", "此 repo 在開發時使用 opencode-agenthub 插件系統", "project", { daysAgo: 1 }), agedEntry("p3", "This repo uses opencode-agenthub plugin system at /Users/sd_wo/work/opencode-working-memory/", "project", { daysAgo: 0 }), ]; const kept = enforceLongTermLimits(entries); const projectEntries = kept.filter(e => e.type === "project"); assert.equal(projectEntries.length, 1, "All three project variants should merge to one"); }); test("enforceLongTermLimits reference: same config path variants collapse to one", () => { const entries = [ agedEntry("r1", "OpenCode plugin config location: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 1 }), agedEntry("r2", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 0 }), ]; const kept = enforceLongTermLimits(entries); const refEntries = kept.filter(e => e.type === "reference"); assert.equal(refEntries.length, 1, "Both reference variants should merge to one"); }); test("enforceLongTermLimits decision: newer supersedes older on same topic", () => { // "4 formats" supersedes "3 formats" on the same parser topic const entries = [ agedEntry("d1", "Parser supports 3 formats: HTML comment, Markdown section, legacy XML", "decision", { daysAgo: 2 }), agedEntry("d2", "Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML", "decision", { daysAgo: 0 }), ]; const kept = enforceLongTermLimits(entries); const decisionEntries = kept.filter(e => e.text.includes("formats")); assert.equal(decisionEntries.length, 1, "Newer 4-formats should supersede older 3-formats"); assert.ok(decisionEntries[0].text.includes("4 formats"), "Kept entry should be the 4-formats one"); }); test("enforceLongTermLimits feedback: newer supersedes older on same issue", () => { const entries = [ agedEntry("f1", "Purple/italic text issue resolved by using plain text labels instead of any special markup syntax", "feedback", { daysAgo: 2 }), agedEntry("f2", "Purple/italic text issue resolved by replacing default compaction template with ---free version using only Markdown headings", "feedback", { daysAgo: 0 }), ]; const kept = enforceLongTermLimits(entries); const feedbackEntries = kept.filter(e => e.type === "feedback"); assert.equal(feedbackEntries.length, 1, "Newer purple/italic fix should supersede older"); assert.ok(feedbackEntries[0].text.includes("replacing default compaction template"), "Kept entry should be the newer fix"); }); test("enforceLongTermLimits stale: compaction entry older than staleAfterDays+grace is pruned", () => { // decision with staleAfterDays=45, 76 days old (> 45+30 grace=75) const entries = [ agedEntry("stale", "Compaction output contract changed from XML to HTML comments to avoid Markdown rendering issues", "decision", { daysAgo: 76, staleAfterDays: 45 }), ]; const kept = enforceLongTermLimits(entries); assert.equal(kept.length, 0, "Stale compaction entry should be pruned"); }); test("enforceLongTermLimits stale: explicit entry is retained even if old", () => { // explicit entry - never auto-pruned regardless of age const entries = [ agedEntry("old_explicit", "User explicitly set Admin PIN 456123 for the system", "reference", { daysAgo: 500, source: "explicit", staleAfterDays: 90 }), ]; const kept = enforceLongTermLimits(entries); assert.equal(kept.length, 1, "Explicit entry should never be age-pruned"); }); test("enforceLongTermLimits stale: feedback entry is retained regardless of age", () => { // feedback - never age-pruned (only superseded) const entries = [ agedEntry("old_feedback", "Users prefer darker themes over light themes", "feedback", { daysAgo: 300, staleAfterDays: 30 }), ]; const kept = enforceLongTermLimits(entries); assert.equal(kept.length, 1, "Feedback entry should never be age-pruned"); }); test("enforceLongTermLimits stale: compaction entry within grace period is retained", () => { // decision staleAfterDays=45, 60 days old (< 45+30=75 grace) - should keep const entries = [ agedEntry("within_grace", "Some compaction decision made two months ago", "decision", { daysAgo: 60, staleAfterDays: 45 }), ]; const kept = enforceLongTermLimits(entries); assert.equal(kept.length, 1, "Entry within grace period should be retained"); }); test("enforceLongTermLimits dedup before trim: cleanup runs before maxEntries slice", () => { // 30 entries that should dedupe to < 28, confirming trim doesn't run before dedupe const entries = [ ...Array.from({ length: 15 }, (_, i) => agedEntry(`a${i}`, "opencode uses npm cache for plugin loading", "decision", { daysAgo: 0 }) ), ...Array.from({ length: 15 }, (_, i) => agedEntry(`b${i}`, "opencode uses npm cache for plugin loading", "decision", { daysAgo: 0 }) ), ]; const kept = enforceLongTermLimits(entries); assert.equal(kept.length, 1, "All duplicates should merge to 1 entry, far below maxEntries"); }); test("enforceLongTermLimits priority: freshness used as tie-breaker among same priority entries", () => { // Same type, same source, same confidence — newer should win const older = agedEntry("older", "Some durable configuration fact about the workspace", "reference", { daysAgo: 30, source: "compaction", staleAfterDays: 90 }); const newer = agedEntry("newer", "Some durable configuration fact about the workspace", "reference", { daysAgo: 5, source: "compaction", staleAfterDays: 90 }); const kept = enforceLongTermLimits([older, newer]); assert.equal(kept.length, 1); assert.equal(kept[0].id, "newer", "Newer entry should win as tie-breaker"); }); test("enforceLongTermLimits feedback: 500 error and port issue are NOT collapsed", () => { // Distinct feedback entries should remain separate const entries = [ agedEntry("f1", "瀏覽器登入出現 500 internal_error,代碼邏輯正確但原因不明", "feedback", { daysAgo: 0 }), agedEntry("f2", "端口 9473 可能被舊進程佔用,需殺掉後重啟", "feedback", { daysAgo: 0 }), ]; const kept = enforceLongTermLimits(entries); const feedbackEntries = kept.filter(e => e.type === "feedback"); assert.equal(feedbackEntries.length, 2, "Distinct feedback items should not collapse"); }); test("enforceLongTermLimits config: unrelated plugin configs are NOT collapsed", () => { const entries = [ agedEntry("c1", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 0 }), agedEntry("c2", "Vite plugin config location: vite.config.ts at project root", "reference", { daysAgo: 0 }), ]; const kept = enforceLongTermLimits(entries); const refEntries = kept.filter(e => e.type === "reference"); assert.equal(refEntries.length, 2, "Unrelated plugin configs should remain separate"); }); test("enforceLongTermLimits supersession: newer shorter decision beats older longer one", () => { // Same topic, same source, same confidence — newer wins even if shorter const older = agedEntry("d1", "Parser supports 3 formats: HTML comment, Markdown section, legacy XML with backward compatibility", "decision", { daysAgo: 5 }); const newer = agedEntry("d2", "Parser supports 4 formats", "decision", { daysAgo: 0 }); const kept = enforceLongTermLimits([older, newer]); const decisions = kept.filter(e => e.type === "decision" && /parser.*format/i.test(e.text)); assert.equal(decisions.length, 1, "Newer shorter decision should supersede older longer one"); assert.ok(decisions[0].text.includes("4 formats"), "Kept entry should be the newer 4-formats"); }); test("enforceLongTermLimits feedback: English port issue does NOT collapse with server error", () => { const entries = [ agedEntry("e1", "Browser login 500 internal_error, code correct but cause unknown", "feedback", { daysAgo: 0 }), agedEntry("e2", "Port 9473 occupied by old process, may need to kill and restart", "feedback", { daysAgo: 0 }), ]; const kept = enforceLongTermLimits(entries); const feedbackEntries = kept.filter(e => e.type === "feedback"); assert.equal(feedbackEntries.length, 2, "English port issue and server error should remain separate"); }); test("enforceLongTermLimits config: unrelated generic plugin configs do NOT collapse", () => { const entries = [ agedEntry("c1", "Vite plugin config location: vite.config.ts at project root", "reference", { daysAgo: 0 }), agedEntry("c2", "ESLint plugin config location: eslint.config.js at project root", "reference", { daysAgo: 0 }), ]; const kept = enforceLongTermLimits(entries); const refEntries = kept.filter(e => e.type === "reference"); assert.equal(refEntries.length, 2, "Unrelated plugin configs without entity key should remain separate"); }); test("enforceLongTermLimits feedback: supersession prefers newer shorter over older longer", () => { // Same purple/italic issue, newer shorter fix supersedes older verbose fix const older = agedEntry("f1", "Purple/italic text issue resolved by using plain text labels instead of any special markup syntax in the prompt", "feedback", { daysAgo: 5 }); const newer = agedEntry("f2", "Purple/italic text fixed via template replacement", "feedback", { daysAgo: 0 }); const kept = enforceLongTermLimits([older, newer]); const feedbackEntries = kept.filter(e => e.type === "feedback"); assert.equal(feedbackEntries.length, 1, "Newer shorter feedback should supersede older longer"); assert.ok(feedbackEntries[0].text.includes("template replacement"), "Kept entry should be the newer fix"); }); // ============================================ // Workspace cleanup migration tests // ============================================ test("redactCredentials preserves PIN delimiter variants", () => { assert.equal(redactCredentials("Admin PIN 是 456123"), "Admin PIN 是 [REDACTED]"); assert.equal(redactCredentials("Admin PIN = 456123"), "Admin PIN = [REDACTED]"); assert.equal(redactCredentials("Admin PIN 456123"), "Admin PIN [REDACTED]"); }); test("redactCredentials handles multilingual passwords", () => { assert.equal(redactCredentials("パスワード:secret"), "パスワード:[REDACTED]"); assert.equal(redactCredentials("비밀번호: secret"), "비밀번호: [REDACTED]"); assert.equal(redactCredentials("contraseña: secret"), "contraseña: [REDACTED]"); }); test("redactCredentials handles username+password pair and punctuation boundary", () => { assert.equal( redactCredentials("測試用戶名:shihlab,密碼:sushi"), "測試用戶名:[REDACTED],密碼:[REDACTED]", ); assert.equal( redactCredentials("密碼:sushi,用於測試"), "密碼:[REDACTED],用於測試", ); }); test("redactCredentials handles generic API keys and tokens", () => { assert.equal(redactCredentials("API_KEY: sk-123456789"), "API_KEY: [REDACTED]"); assert.equal(redactCredentials("Bearer Token: eyJhbGciOiJIUzI1..."), "Bearer Token: [REDACTED]"); assert.equal(redactCredentials("GitHub Secret: ghp_abc123"), "GitHub Secret: [REDACTED]"); assert.equal(redactCredentials("auth: abc123def"), "auth: [REDACTED]"); }); test("redactCredentials does not redact benign security-related wording", () => { assert.equal(redactCredentials("token budget is 5200 characters"), "token budget is 5200 characters"); assert.equal(redactCredentials("auth config uses OAuth"), "auth config uses OAuth"); assert.equal(redactCredentials("secret manager is not supported"), "secret manager is not supported"); assert.equal(redactCredentials("private key handling is out of scope"), "private key handling is out of scope"); }); test("redactCredentials redacts common sensitive key delimiters", () => { assert.equal(redactCredentials("token=ghp_abc123"), "token=[REDACTED]"); assert.equal(redactCredentials("private_key: -----BEGIN"), "private_key: [REDACTED]"); assert.equal(redactCredentials("credential:abc123"), "credential:[REDACTED]"); assert.equal(redactCredentials("api-key: sk-live-123"), "api-key: [REDACTED]"); }); test("redactCredentials is idempotent and also redacts rationale text", () => { assert.equal(redactCredentials("password: [REDACTED]"), "password: [REDACTED]"); const now = new Date().toISOString(); const store: WorkspaceMemoryStore = { version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: 5200, maxEntries: 28 }, migrations: [], entries: [ { id: "cred", type: "reference", text: "Admin PIN 是 456123", rationale: "password: sushi", source: "explicit", confidence: 1, status: "active", createdAt: now, updatedAt: now, }, ], updatedAt: now, }; const migrated = runMigrationP0Cleanup( { ...store, entries: store.entries.map(entry => ({ ...entry, text: redactCredentials(entry.text), rationale: entry.rationale ? redactCredentials(entry.rationale) : undefined, })), }, now, ); assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]"); 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); assert.equal(isProjectSnapshotViolation("Upload limit is 10 files"), false); assert.equal(isProjectSnapshotViolation("Project supports 5 test suites"), false); }); test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs once", () => { const now = new Date().toISOString(); const later = new Date(Date.now() + 1000).toISOString(); const store: WorkspaceMemoryStore = { version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: 5200, maxEntries: 28 }, migrations: [], entries: [ { id: "project-snapshot", type: "project", text: "Phase 1-4 已完成", source: "compaction", confidence: 0.75, status: "active", createdAt: now, updatedAt: now, }, { id: "project-explicit", type: "project", text: "Waves 1-5 已完成", source: "explicit", confidence: 1, status: "active", createdAt: now, updatedAt: now, }, { id: "feedback-snapshot-like", type: "feedback", text: "1237 tests pass", source: "compaction", confidence: 0.75, status: "active", createdAt: now, updatedAt: now, }, ], updatedAt: now, }; const once = runMigrationP0Cleanup(store, now); assert.deepEqual(once.migrations, ["2026-04-26-p0-cleanup"]); assert.equal(once.entries.find(e => e.id === "project-snapshot")?.status, "superseded"); assert.equal(once.entries.find(e => e.id === "project-explicit")?.status, "active"); assert.equal(once.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active"); const twice = runMigrationP0Cleanup(once, later); assert.deepEqual(twice.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate"); assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt); }); test("renderWorkspaceMemory excludes superseded entries", () => { const now = new Date().toISOString(); const store: WorkspaceMemoryStore = { version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: 5200, maxEntries: 28 }, migrations: ["2026-04-26-p0-cleanup"], entries: [ { id: "active-1", type: "decision", text: "Use pnpm for this workspace", source: "compaction", confidence: 0.75, status: "active", createdAt: now, updatedAt: now, }, { id: "sup-1", type: "project", text: "Waves 1-5 已完成", source: "compaction", confidence: 0.75, status: "superseded", createdAt: now, updatedAt: now, }, ], updatedAt: now, }; const rendered = renderWorkspaceMemory(store); assert.match(rendered, /Use pnpm/); assert.doesNotMatch(rendered, /Waves 1-5 已完成/); }); test("loadWorkspaceMemory normalizes and persists credentials from legacy unredacted store", async () => { const sandbox = await mkdtemp(join(tmpdir(), "wm-redact-")); const dataHome = join(sandbox, "xdg-data-home"); const root = join(sandbox, "workspace"); const previousXdgDataHome = process.env.XDG_DATA_HOME; process.env.XDG_DATA_HOME = dataHome; try { const now = new Date().toISOString(); // Write UNREDACTED JSON directly to disk (simulating legacy store) const unredactedStore: WorkspaceMemoryStore = { version: 1, workspace: { root, key: "test" }, limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries, }, entries: [ { id: "cred-1", text: "Admin PIN 是 456123", type: "project", source: "compaction", confidence: 0.75, status: "active", createdAt: now, updatedAt: now, }, ], migrations: [], updatedAt: now, }; // Write directly to disk WITHOUT using saveWorkspaceMemory (which would redact) const { mkdir, writeFile } = await import("node:fs/promises"); const storePath = await workspaceMemoryPath(root); await mkdir(dirname(storePath), { recursive: true }); await writeFile(storePath, JSON.stringify(unredactedStore, null, 2), "utf-8"); // Load should normalize and redact const loaded = await loadWorkspaceMemory(root); assert.equal(loaded.entries[0].text, "Admin PIN 是 [REDACTED]"); // Verify persisted to disk (not just in-memory) const { readFile } = await import("node:fs/promises"); const persistedRaw = await readFile(storePath, "utf-8"); const persisted = JSON.parse(persistedRaw); assert.equal(persisted.entries[0].text, "Admin PIN 是 [REDACTED]"); } finally { if (previousXdgDataHome === undefined) { delete process.env.XDG_DATA_HOME; } else { process.env.XDG_DATA_HOME = previousXdgDataHome; } await rm(sandbox, { recursive: true, force: true }); } });