From e0357c572afe69033f00b00eec0406ee59a5dc70 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Sat, 2 May 2026 15:03:34 +0800 Subject: [PATCH] feat(reinforcement): compaction prompt wording reuse, migration evidence, and validation baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1 — Compaction prompt improvement: - Add three wording-reuse bullets to buildCompactionPrompt() under CRITICAL MEMORY RULES: do not create rephrased duplicates, reuse existing wording exactly when re-emitting, only emit new memories when the fact is new, materially corrected, or more specific. - This attacks the root cause of zero reinforcement: compaction generating variant text for the same durable fact. Wave 2 — Bug fixes: - Bug #2: Add placeholder comment to superseded_existing branch in decision dedupe (unreachable until v1.5.4 numbered refs). Preserve as const type assertions. - Bug #3: Add memory_migration_superseded evidence event type. Both P0 and quality cleanup migrations now produce evidence events for superseded entries. loadWorkspaceMemory appends migration evidence on first-load migrations only (idempotent via migration IDs). No historical backfill. - Bug #4: Add documentation comment explaining that feedback identity key returns exact key (absorbed_identity currently impossible for feedback). Add test verifying this behavior. Wave 3 — Validation baseline script: - Add scripts/dev/validate-identity-keys.ts: read-only script that scans workspace memory stores, computes exact/identity key collisions, and reports reinforcement statistics. Baseline matches audit: 0 exact collisions, 0 identity collisions, 0 reinforcement events across 123 active memories. Identity extension is gated on measurement: if the prompt change produces measurable reinforcement (reinforcementCount > 0), identity extension may be unnecessary. Decision dedupe stays exact-only (Wave 4 deferred). --- scripts/dev/validate-identity-keys.ts | 368 ++++++++++++++++++++++++++ src/evidence-log.ts | 1 + src/plugin.ts | 3 + src/workspace-memory.ts | 105 ++++++-- tests/evidence-log.test.ts | 35 +++ tests/plugin.test.ts | 63 +++++ tests/workspace-memory.test.ts | 193 +++++++++++++- 7 files changed, 745 insertions(+), 23 deletions(-) create mode 100644 scripts/dev/validate-identity-keys.ts diff --git a/scripts/dev/validate-identity-keys.ts b/scripts/dev/validate-identity-keys.ts new file mode 100644 index 0000000..b2b8388 --- /dev/null +++ b/scripts/dev/validate-identity-keys.ts @@ -0,0 +1,368 @@ +#!/usr/bin/env node +/** + * Read-only baseline report for workspace-memory exact/identity key collisions. + * + * This script reads workspace-memory.json files from the local workspace memory + * data directory. It never writes to workspace memory stores; the only optional + * write is the explicit --json report path. + */ + +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../../src/workspace-memory.ts"; +import type { LongTermMemoryEntry, LongTermSource, LongTermType } from "../../src/types.ts"; + +type CliOptions = { + workspacesDir: string; + jsonPath?: string; +}; + +type ReinforcementCountStatistics = { + min: number; + max: number; + mean: number; + gtZero: number; +}; + +type MemorySummary = { + id: string; + type: LongTermType; + source: LongTermSource; + text: string; + reinforcementCount: number; +}; + +type WorkspaceSummary = { + dirName: string; + dirPath: string; + key: string; + root: string; +}; + +type CollisionGroup = { + kind: "exact" | "identity"; + workspace: WorkspaceSummary; + key: string; + classification: "unclassified"; + memories: MemorySummary[]; +}; + +type ValidationReport = { + summary: { + workspaces: number; + activeMemories: number; + exactCollisionGroups: number; + identityCollisionGroups: number; + reinforcementCountStatistics: ReinforcementCountStatistics; + }; + groups: CollisionGroup[]; +}; + +type ScannedMemory = { + entry: LongTermMemoryEntry; + exactKey: string; + identityKey: string; + reinforcementCount: number; +}; + +const DEFAULT_WORKSPACES_DIR = join(homedir(), ".local", "share", "opencode-working-memory", "workspaces"); +const WORKSPACES_DIR_ENV_KEYS = [ + "OPENCODE_WORKING_MEMORY_WORKSPACES_DIR", + "WORKSPACE_MEMORY_WORKSPACES_DIR", +]; + +function usage(): string { + return `Usage: + node --experimental-strip-types scripts/dev/validate-identity-keys.ts [workspaces-dir] [--json ] + node --experimental-strip-types scripts/dev/validate-identity-keys.ts --data-path [--json ] + +Defaults: + workspaces-dir defaults to ${DEFAULT_WORKSPACES_DIR} + env override: ${WORKSPACES_DIR_ENV_KEYS.join(" or ")} +`; +} + +function die(message: string): never { + console.error(message); + console.error(usage()); + process.exit(1); +} + +function envWorkspacesDir(): string | undefined { + for (const key of WORKSPACES_DIR_ENV_KEYS) { + const value = process.env[key]; + if (value) return value; + } + return undefined; +} + +function expandHome(path: string): string { + if (path === "~") return homedir(); + if (path.startsWith("~/")) return join(homedir(), path.slice(2)); + return path; +} + +function parseArgs(argv: string[]): CliOptions { + let workspacesDir: string | undefined; + let jsonPath: string | undefined; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--help" || arg === "-h") { + console.log(usage()); + process.exit(0); + } + if (arg === "--json") { + const value = argv[++i]; + if (!value) die("--json requires a path"); + jsonPath = expandHome(value); + continue; + } + if (arg === "--data-path" || arg === "--workspaces-dir") { + const value = argv[++i]; + if (!value) die(`${arg} requires a path`); + if (workspacesDir) die("Only one workspaces directory may be provided"); + workspacesDir = expandHome(value); + continue; + } + if (arg.startsWith("--")) die(`Unknown option: ${arg}`); + if (workspacesDir) die("Only one workspaces directory may be provided"); + workspacesDir = expandHome(arg); + } + + return { + workspacesDir: resolve(workspacesDir ?? expandHome(envWorkspacesDir() ?? DEFAULT_WORKSPACES_DIR)), + jsonPath: jsonPath ? resolve(jsonPath) : undefined, + }; +} + +function isLongTermType(value: unknown): value is LongTermType { + return value === "feedback" || value === "project" || value === "decision" || value === "reference"; +} + +function isLongTermSource(value: unknown): value is LongTermSource { + return value === "explicit" || value === "compaction" || value === "manual"; +} + +function isMemoryEntry(value: unknown): value is LongTermMemoryEntry { + if (!value || typeof value !== "object") return false; + const entry = value as Partial; + return typeof entry.id === "string" + && isLongTermType(entry.type) + && typeof entry.text === "string" + && isLongTermSource(entry.source); +} + +function reinforcementCount(entry: LongTermMemoryEntry): number { + const count = entry.reinforcementCount ?? 0; + return Number.isFinite(count) && count > 0 ? count : 0; +} + +function summarizeMemory(memory: ScannedMemory): MemorySummary { + return { + id: memory.entry.id, + type: memory.entry.type, + source: memory.entry.source, + text: memory.entry.text, + reinforcementCount: memory.reinforcementCount, + }; +} + +function addToGroupMap(map: Map, key: string, memory: ScannedMemory): void { + const group = map.get(key); + if (group) { + group.push(memory); + } else { + map.set(key, [memory]); + } +} + +function computeReinforcementStatistics(counts: number[]): ReinforcementCountStatistics { + if (counts.length === 0) { + return { min: 0, max: 0, mean: 0, gtZero: 0 }; + } + + const sum = counts.reduce((total, count) => total + count, 0); + return { + min: Math.min(...counts), + max: Math.max(...counts), + mean: sum / counts.length, + gtZero: counts.filter(count => count > 0).length, + }; +} + +async function readWorkspaceMemory(workspaceDir: string): Promise { + const path = join(workspaceDir, "workspace-memory.json"); + if (!existsSync(path)) return undefined; + try { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT" || code === "EISDIR") return undefined; + throw error; + } +} + +async function scanWorkspaces(workspacesDir: string): Promise { + const dirents = await readdir(workspacesDir, { withFileTypes: true }); + const workspaceDirs = dirents + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + .sort((a, b) => a.localeCompare(b)); + + const collisionGroups: CollisionGroup[] = []; + const reinforcementCounts: number[] = []; + let workspaces = 0; + let activeMemories = 0; + + for (const dirName of workspaceDirs) { + const dirPath = join(workspacesDir, dirName); + const store = await readWorkspaceMemory(dirPath); + if (!store || typeof store !== "object") continue; + + const storeObject = store as { + workspace?: { key?: unknown; root?: unknown }; + entries?: unknown; + }; + const rawEntries = Array.isArray(storeObject.entries) ? storeObject.entries : []; + const workspace: WorkspaceSummary = { + dirName, + dirPath, + key: typeof storeObject.workspace?.key === "string" ? storeObject.workspace.key : dirName, + root: typeof storeObject.workspace?.root === "string" ? storeObject.workspace.root : "", + }; + + workspaces += 1; + + const exactGroups = new Map(); + const identityGroups = new Map(); + + for (const rawEntry of rawEntries) { + if (!isMemoryEntry(rawEntry)) continue; + if (rawEntry.status === "superseded") continue; + + const scanned: ScannedMemory = { + entry: rawEntry, + exactKey: workspaceMemoryExactKey(rawEntry), + identityKey: workspaceMemoryIdentityKey(rawEntry), + reinforcementCount: reinforcementCount(rawEntry), + }; + activeMemories += 1; + reinforcementCounts.push(scanned.reinforcementCount); + addToGroupMap(exactGroups, scanned.exactKey, scanned); + addToGroupMap(identityGroups, scanned.identityKey, scanned); + } + + for (const [key, memories] of exactGroups.entries()) { + if (memories.length < 2) continue; + collisionGroups.push({ + kind: "exact", + workspace, + key, + classification: "unclassified", + memories: memories.map(summarizeMemory), + }); + } + + for (const [key, memories] of identityGroups.entries()) { + if (memories.length < 2) continue; + collisionGroups.push({ + kind: "identity", + workspace, + key, + classification: "unclassified", + memories: memories.map(summarizeMemory), + }); + } + } + + const exactCollisionGroups = collisionGroups.filter(group => group.kind === "exact").length; + const identityCollisionGroups = collisionGroups.filter(group => group.kind === "identity").length; + + return { + summary: { + workspaces, + activeMemories, + exactCollisionGroups, + identityCollisionGroups, + reinforcementCountStatistics: computeReinforcementStatistics(reinforcementCounts), + }, + groups: collisionGroups, + }; +} + +function oneLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function renderHumanReport(report: ValidationReport, workspacesDir: string): string { + const stats = report.summary.reinforcementCountStatistics; + const lines: string[] = []; + lines.push("Workspace memory identity key validation baseline"); + lines.push(`Data path: ${workspacesDir}`); + lines.push(""); + lines.push("Summary:"); + lines.push(`- Workspaces scanned: ${report.summary.workspaces}`); + lines.push(`- Active memories: ${report.summary.activeMemories}`); + lines.push(`- Exact collision groups: ${report.summary.exactCollisionGroups}`); + lines.push(`- Identity collision groups: ${report.summary.identityCollisionGroups}`); + lines.push(`- Reinforcement counts: min=${stats.min} max=${stats.max} mean=${stats.mean.toFixed(2)} gtZero=${stats.gtZero}`); + + if (report.groups.length === 0) { + lines.push(""); + lines.push("Collision breakdown: none"); + return lines.join("\n"); + } + + const groupsByWorkspace = new Map(); + for (const group of report.groups) { + const key = group.workspace.key; + const existing = groupsByWorkspace.get(key); + if (existing) existing.push(group); + else groupsByWorkspace.set(key, [group]); + } + + lines.push(""); + lines.push("Collision breakdown:"); + for (const [workspaceKey, groups] of [...groupsByWorkspace.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + const workspace = groups[0].workspace; + const exactCount = groups.filter(group => group.kind === "exact").length; + const identityCount = groups.filter(group => group.kind === "identity").length; + lines.push(`- Workspace ${workspaceKey}`); + lines.push(` root: ${workspace.root}`); + lines.push(` dir: ${workspace.dirPath}`); + lines.push(` exactCollisionGroups=${exactCount} identityCollisionGroups=${identityCount}`); + + for (const group of groups.sort((a, b) => a.kind.localeCompare(b.kind) || a.key.localeCompare(b.key))) { + lines.push(` - ${group.kind} key: ${group.key}`); + lines.push(` classification: ${group.classification}`); + for (const memory of group.memories) { + lines.push(` - id=${memory.id} type=${memory.type} source=${memory.source} reinforcementCount=${memory.reinforcementCount}`); + lines.push(` text: ${oneLine(memory.text)}`); + } + } + } + + return lines.join("\n"); +} + +async function writeJsonReport(path: string, report: ValidationReport): Promise { + if (basename(path) === "workspace-memory.json") { + die("Refusing to write JSON output to a workspace-memory.json file"); + } + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, "utf8"); +} + +const options = parseArgs(process.argv.slice(2)); +const report = await scanWorkspaces(options.workspacesDir); +console.log(renderHumanReport(report, options.workspacesDir)); + +if (options.jsonPath) { + await writeJsonReport(options.jsonPath, report); + console.log(`\nJSON report written to: ${options.jsonPath}`); +} diff --git a/src/evidence-log.ts b/src/evidence-log.ts index e10583f..78fb4c3 100644 --- a/src/evidence-log.ts +++ b/src/evidence-log.ts @@ -20,6 +20,7 @@ export type EvidenceEventType = | "promotion_retry_scheduled" | "promotion_retry_exhausted" | "memory_reinforced" + | "memory_migration_superseded" | "render_selected" | "render_omitted" | "memory_removed_capacity" diff --git a/src/plugin.ts b/src/plugin.ts index 1557796..412b3fa 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -116,6 +116,9 @@ function buildCompactionPrompt(privateContext: string): string { "", "CRITICAL MEMORY RULES:", "- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.", + "- Existing workspace memory may already contain durable facts. If a fact is already present and still accurate, do not create a rephrased duplicate.", + "- If the same durable fact truly needs to be emitted again, reuse the existing memory wording exactly whenever possible.", + "- Only emit a new memory when the fact is new, materially corrected, or materially more specific than the existing memory.", "- 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.", diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 0566fcd..c5c18e4 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -12,6 +12,7 @@ import { reinforceMemory, } from "./retention.ts"; import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts"; +import { appendEvidenceEvents } from "./evidence-log.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; @@ -73,6 +74,17 @@ export type QualityCleanupMigrationLogEntry = { afterStatus: "superseded"; }; +export type P0CleanupMigrationResult = { + store: WorkspaceMemoryStore; + events: EvidenceEventInput[]; +}; + +export type QualityCleanupMigrationResult = { + store: WorkspaceMemoryStore; + events: QualityCleanupMigrationLogEntry[]; + evidence: EvidenceEventInput[]; +}; + export async function emptyWorkspaceMemory(root: string): Promise { const nowIso = new Date().toISOString(); return { @@ -114,6 +126,14 @@ export async function loadWorkspaceMemory(root: string): Promise event.type === "memory_migration_superseded"); + if (migrationEvidence.length > 0) { + await appendEvidenceEvents(root, migrationEvidence); + } } return normalized.store; @@ -159,8 +179,15 @@ export async function updateWorkspaceMemoryWithAccounting( const fallback = await emptyWorkspaceMemory(root); let finalResult: WorkspaceMemoryNormalizationResult | undefined; const store = await updateJSON(path, () => fallback, async current => { - const normalized = await normalizeWorkspaceMemory(root, current); - finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized)); + const currentNormalization = await normalizeWorkspaceMemoryWithAccounting(root, current); + const currentMigrationEvidence = currentNormalization.evidence.filter(event => event.type === "memory_migration_superseded"); + finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(currentNormalization.store)); + if (currentMigrationEvidence.length > 0) { + finalResult = { + ...finalResult, + evidence: [...currentMigrationEvidence, ...finalResult.evidence], + }; + } return finalResult.store; }); @@ -224,10 +251,12 @@ export async function normalizeWorkspaceMemoryWithAccounting( const beforeQualityCleanup = result; const qualityCleanup = runMigrationQualityCleanup(result, nowIso); result = qualityCleanup.store; + let migrationEvidence: EvidenceEventInput[] = []; let skipRemainingMigrations = false; if (qualityCleanup.events.length > 0) { try { await appendQualityCleanupMigrationLog(qualityCleanup.events); + migrationEvidence = [...migrationEvidence, ...qualityCleanup.evidence]; } catch (error) { console.error("[memory] failed to write quality cleanup migration log:", error); console.error("[memory] aborting migration to maintain audit trail integrity"); @@ -236,7 +265,9 @@ export async function normalizeWorkspaceMemoryWithAccounting( } } if (!skipRemainingMigrations) { - result = runMigrationP0Cleanup(result, nowIso); + const p0Cleanup = runMigrationP0Cleanup(result, nowIso); + result = p0Cleanup.store; + migrationEvidence = [...migrationEvidence, ...p0Cleanup.events]; } result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs)); @@ -269,7 +300,7 @@ export async function normalizeWorkspaceMemoryWithAccounting( dropped: accounting.dropped, absorbed: accounting.absorbed, superseded: accounting.superseded, - evidence: accounting.evidence, + evidence: [...migrationEvidence, ...accounting.evidence], events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded], }; } @@ -295,31 +326,38 @@ function backfillRetentionClock(entry: LongTermMemoryEntry, nowMs: number): Long export function runMigrationP0Cleanup( store: WorkspaceMemoryStore, nowIso: string, -): WorkspaceMemoryStore { +): P0CleanupMigrationResult { if (store.migrations?.includes(MIGRATION_ID)) { - return store; + return { store, events: [] }; } + const events: EvidenceEventInput[] = []; const entries = store.entries.map(entry => { if (entry.source !== "compaction") return entry; if (entry.type !== "project") return entry; + if (entry.status === "superseded") return entry; if (isProgressSnapshotViolation(entry.text)) { - return { + const superseded = { ...entry, status: "superseded" as const, updatedAt: nowIso, }; + events.push(migrationSupersededEvidence(superseded, ["migration:p0_cleanup"], MIGRATION_ID)); + return superseded; } return entry; }); return { - ...store, - entries, - migrations: [...(store.migrations || []), MIGRATION_ID], - updatedAt: nowIso, + store: { + ...store, + entries, + migrations: [...(store.migrations || []), MIGRATION_ID], + updatedAt: nowIso, + }, + events, }; } @@ -333,12 +371,13 @@ async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationL export function runMigrationQualityCleanup( store: WorkspaceMemoryStore, nowIso: string, -): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } { +): QualityCleanupMigrationResult { if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) { - return { store, events: [] }; + return { store, events: [], evidence: [] }; } const events: QualityCleanupMigrationLogEntry[] = []; + const evidence: EvidenceEventInput[] = []; let changed = false; const entries = store.entries.map(entry => { if (entry.source !== "compaction") return entry; @@ -372,12 +411,19 @@ export function runMigrationQualityCleanup( ...hardReasons.map(reason => `quality:${reason}`), ]); - return { + const superseded = { ...entry, status: "superseded" as const, updatedAt: nowIso, tags: [...tags], }; + evidence.push(migrationSupersededEvidence( + superseded, + ["migration:quality_cleanup", ...hardReasons.map(reason => `quality:${reason}`)], + QUALITY_CLEANUP_MIGRATION_ID, + { hardReasons }, + )); + return superseded; }); return { @@ -388,6 +434,7 @@ export function runMigrationQualityCleanup( updatedAt: changed ? nowIso : store.updatedAt, }, events, + evidence, }; } @@ -525,6 +572,29 @@ function capacityRemovalEvidence( }; } +function migrationSupersededEvidence( + memory: LongTermMemoryEntry, + reasonCodes: string[], + migrationId: string, + details: EvidenceEventInput["details"] = {}, +): EvidenceEventInput { + return { + type: "memory_migration_superseded", + phase: "storage", + outcome: "superseded", + memory: memoryEvidenceRef(memory), + relations: [{ role: "superseded", memory: memoryEvidenceRef(memory) }], + reasonCodes, + details: { + migrationId, + type: memory.type, + source: memory.source, + ...details, + }, + textPreview: memory.text, + }; +} + /** Choose better memory when identity/topic keys conflict */ function chooseBetterMemory( a: LongTermMemoryEntry, @@ -631,6 +701,11 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry const evidence: EvidenceEventInput[] = []; // For project/reference/feedback: dedupe by concrete identity or exact canonical text. + // Feedback is grouped with project/reference for entity dedupe, but + // workspaceMemoryIdentityKey() returns exact key for feedback (no concrete + // identity extraction). This means feedback absorbed_identity is currently + // impossible. When identity key extraction is extended to all types, feedback + // with matching concrete identifiers will correctly produce absorbed_identity. const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback"); // Build identity key dedup for project/reference/feedback. @@ -674,7 +749,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry const dropped = retained === entry ? existing : entry; const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing) ? "absorbed_exact" as const - : "superseded_existing" as const; + : "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs const reinforced = reinforceMemory( retained, reinforcementSessionId(retained, dropped), diff --git a/tests/evidence-log.test.ts b/tests/evidence-log.test.ts index d83bf00..f2edeb5 100644 --- a/tests/evidence-log.test.ts +++ b/tests/evidence-log.test.ts @@ -174,6 +174,41 @@ test("memory_removed_capacity event round-trips through append and query", async } }); +test("memory_migration_superseded event round-trips through append and query", async () => { + const root = await tempRoot(); + try { + await appendEvidenceEvent(root, eventInput({ + type: "memory_migration_superseded", + phase: "storage", + outcome: "superseded", + reasonCodes: ["migration:quality_cleanup", "quality:progress_snapshot"], + memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" }, + relations: [{ role: "superseded", memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" } }], + details: { + migrationId: "2026-04-28-quality-cleanup", + type: "project", + source: "compaction", + }, + })); + + const result = await queryEvidenceEvents(root, { + types: ["memory_migration_superseded"], + phases: ["storage"], + outcomes: ["superseded"], + memoryId: "migrated-memory", + }); + + assert.equal(result.length, 1); + assert.equal(result[0].type, "memory_migration_superseded"); + assert.equal(result[0].phase, "storage"); + assert.equal(result[0].outcome, "superseded"); + assert.ok(result[0].reasonCodes.includes("migration:quality_cleanup")); + assert.equal(result[0].memory?.status, "superseded"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("queryEvidenceEvents supports newestFirst and limit", async () => { const root = await tempRoot(); try { diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index bad4fa9..ad9a1f1 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -339,6 +339,69 @@ test("compaction prompt forbids progress and session-internal memory candidates" } }); +test("compaction prompt includes existing-memory wording reuse guidance", 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-wording-reuse-session", model: {} }, + output, + ); + + assert.match(output.prompt, /Existing workspace memory may already contain durable facts/); + assert.match(output.prompt, /do not create a rephrased duplicate/); + assert.match(output.prompt, /reuse the existing memory wording exactly whenever possible/); + assert.match(output.prompt, /new, materially corrected, or materially more specific/); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("compaction prompt does not introduce CRUD memory directives", 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-no-crud-session", model: {} }, + output, + ); + + assert.doesNotMatch(output.prompt, /\bREPLACE\b/); + assert.doesNotMatch(output.prompt, /\bDROP\b/); + assert.doesNotMatch(output.prompt, /\bREINFORCE\s+\[M/); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("compaction prompt preserves Memory candidates output format", 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-format-session", model: {} }, + output, + ); + + assert.match( + output.prompt, + /Format when there ARE durable memories:\nMemory candidates:\n- \[feedback\|decision\|project\|reference\] future-facing durable fact/, + ); + assert.match( + output.prompt, + /Format when there are NO durable memories:\nMemory candidates:\n\(none\)/, + ); + } 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-")); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index fc9c179..d34030c 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts"; import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts"; +import { queryEvidenceEvents } from "../src/evidence-log.ts"; import { renderWorkspaceMemory, accountWorkspaceMemoryRender, @@ -562,6 +563,23 @@ test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", )); }); +test("dedupeLongTermEntriesWithAccounting decision same-identity variants absorb exact only", () => { + const retained = entry("decision-a", "Use pnpm for package management!!!", "decision"); + const duplicate = entry("decision-b", "use pnpm for package management", "decision"); + + assert.notEqual(retained.text, duplicate.text); + assert.equal(workspaceMemoryIdentityKey(retained), workspaceMemoryIdentityKey(duplicate)); + assert.equal(workspaceMemoryExactKey(retained), workspaceMemoryExactKey(duplicate)); + + const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]); + + assert.equal(result.kept.length, 1); + assert.equal(result.absorbed.length, 1); + assert.equal(result.absorbed[0].reason, "absorbed_exact"); + assert.equal(result.superseded.length, 0); + assert.equal(result.absorbed.some(event => event.reason === "superseded_existing"), false); +}); + test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => { const now = Date.now(); const retained: LongTermMemoryEntry = { @@ -999,6 +1017,16 @@ test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () = assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading"); }); +test("workspaceMemoryIdentityKey returns exact key for feedback entries", () => { + const feedback = entry( + "feedback-exact-identity", + "User prefers references to mention `.opencode/opencode.json` explicitly.", + "feedback", + ); + + assert.equal(workspaceMemoryIdentityKey(feedback), workspaceMemoryExactKey(feedback)); +}); + test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => { const root = "/repo"; const now = new Date().toISOString(); @@ -1252,6 +1280,50 @@ test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted } }); +test("updateWorkspaceMemoryWithAccounting includes migration evidence from pre-update normalization", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-migration-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 { + await mkdir(root, { recursive: true }); + const now = "2026-04-26T00: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: "update_p0_progress", + type: "project", + text: "Phase 1-4 completed successfully", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + }], + migrations: ["2026-04-28-quality-cleanup"], + updatedAt: now, + }, null, 2), "utf8"); + + const result = await updateWorkspaceMemoryWithAccounting(root, store => store); + const migrationEvidence = result.evidence.filter(event => event.type === "memory_migration_superseded"); + + assert.equal(result.store.entries.find(memory => memory.id === "update_p0_progress")?.status, "superseded"); + assert.equal(migrationEvidence.length, 1); + assert.deepEqual(migrationEvidence[0].reasonCodes, ["migration:p0_cleanup"]); + assert.equal(migrationEvidence[0].memory?.memoryId, "update_p0_progress"); + } 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 // ============================================ @@ -1604,7 +1676,7 @@ test("redactCredentials is idempotent and also redacts rationale text", () => { })), }, now, - ); + ).store; assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]"); assert.equal(migrated.entries[0].rationale, "password: [REDACTED]"); }); @@ -1663,14 +1735,69 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o }; 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"); + assert.deepEqual(once.store.migrations, ["2026-04-26-p0-cleanup"]); + assert.equal(once.store.entries.find(e => e.id === "project-snapshot")?.status, "superseded"); + assert.equal(once.store.entries.find(e => e.id === "project-explicit")?.status, "active"); + assert.equal(once.store.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active"); + assert.equal(once.events.length, 1); + assert.equal(once.events[0].type, "memory_migration_superseded"); + assert.equal(once.events[0].phase, "storage"); + assert.equal(once.events[0].outcome, "superseded"); + assert.ok(once.events[0].reasonCodes.includes("migration:p0_cleanup")); + assert.equal(once.events[0].memory?.memoryId, "project-snapshot"); - 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); + const twice = runMigrationP0Cleanup(once.store, later); + assert.deepEqual(twice.store.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate"); + assert.equal(twice.store.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.store.entries.find(e => e.id === "project-snapshot")?.updatedAt); + assert.equal(twice.events.length, 0); +}); + +test("loadWorkspaceMemory appends P0 migration evidence once", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "wm-p0-evidence-")); + 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 { + await mkdir(root, { recursive: true }); + const now = "2026-04-26T00: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: "p0_progress", + type: "project", + text: "Phase 1-4 completed successfully", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + }], + migrations: ["2026-04-28-quality-cleanup"], + updatedAt: now, + }, null, 2), "utf8"); + + const firstLoad = await loadWorkspaceMemory(root); + await loadWorkspaceMemory(root); + const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] }); + + assert.equal(firstLoad.entries.find(memory => memory.id === "p0_progress")?.status, "superseded"); + assert.equal(events.length, 1); + assert.equal(events[0].type, "memory_migration_superseded"); + assert.equal(events[0].phase, "storage"); + assert.equal(events[0].outcome, "superseded"); + assert.deepEqual(events[0].reasonCodes, ["migration:p0_cleanup"]); + assert.equal(events[0].memory?.memoryId, "p0_progress"); + } finally { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + await rm(sandbox, { recursive: true, force: true }); + } }); test("quality cleanup migration preserves soft-only feedback and decision violations", async () => { @@ -1797,6 +1924,56 @@ test("quality cleanup migration writes audit log for hard supersedes", async () } }); +test("quality cleanup migration appends superseded evidence with hard reasons", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-evidence-")); + 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 { + 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: "quality_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 loaded = await loadWorkspaceMemory(root); + const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] }); + + assert.equal(loaded.entries.find(memory => memory.id === "quality_progress")?.status, "superseded"); + assert.equal(events.length, 1); + assert.equal(events[0].type, "memory_migration_superseded"); + assert.equal(events[0].phase, "storage"); + assert.equal(events[0].outcome, "superseded"); + assert.ok(events[0].reasonCodes.includes("migration:quality_cleanup")); + assert.ok(events[0].reasonCodes.includes("quality:progress_snapshot")); + assert.equal(events[0].memory?.memoryId, "quality_progress"); + assert.equal(events[0].memory?.status, "superseded"); + } finally { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + await rm(sandbox, { recursive: true, force: true }); + } +}); + 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");