feat(reinforcement): compaction prompt wording reuse, migration evidence, and validation baseline

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).
This commit is contained in:
Ralph Chang
2026-05-02 15:03:34 +08:00
parent ff5c568cb7
commit aaa4016ae8
7 changed files with 745 additions and 23 deletions
+368
View File
@@ -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 <path>]
node --experimental-strip-types scripts/dev/validate-identity-keys.ts --data-path <workspaces-dir> [--json <path>]
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<LongTermMemoryEntry>;
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<string, ScannedMemory[]>, 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<unknown | undefined> {
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<ValidationReport> {
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 : "<unknown>",
};
workspaces += 1;
const exactGroups = new Map<string, ScannedMemory[]>();
const identityGroups = new Map<string, ScannedMemory[]>();
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<string, CollisionGroup[]>();
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<void> {
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}`);
}
+1
View File
@@ -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"
+3
View File
@@ -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.",
+90 -15
View File
@@ -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: <workspace_memory>\n...\n</workspace_memory>
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<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
return {
@@ -114,6 +126,14 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
// writes for ordering/capacity/timestamp-only normalization.
if (hasSecurityOrMigrationChange(store, normalized.store)) {
await atomicWriteJSON(path, normalized.store);
// loadWorkspaceMemory has a narrow migration side effect: when a first-load
// migration actually supersedes entries, append evidence for those storage
// changes. Migration IDs keep this idempotent on repeated loads.
const migrationEvidence = normalized.evidence.filter(event => 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),
+35
View File
@@ -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 {
+63
View File
@@ -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<string, Function>)["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<string, Function>)["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<string, Function>)["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-"));
+185 -8
View File
@@ -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");