diff --git a/scripts/memory-diag.ts b/scripts/memory-diag.ts index e6e349e..c8c021b 100644 --- a/scripts/memory-diag.ts +++ b/scripts/memory-diag.ts @@ -12,7 +12,7 @@ import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, w import { assessMemoryQuality, HARD_QUALITY_REASONS } from "../src/memory-quality.ts"; import { redactCredentials } from "../src/redaction.ts"; import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts"; -import { renderWorkspaceMemory } from "../src/workspace-memory.ts"; +import { accountWorkspaceMemoryRender, renderWorkspaceMemory } from "../src/workspace-memory.ts"; import { DORMANT_DECAY_MULTIPLIER, RETENTION_TYPE_MAX, @@ -21,18 +21,70 @@ import { } from "../src/retention.ts"; import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts"; import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts"; +import { + queryEvidenceEvents, + summarizeMemoryEvidence, + traceMemoryLifecycle, + type EvidenceEventType, + type EvidenceEventV1, + type EvidenceOutcome, +} from "../src/evidence-log.ts"; -type Command = "health" | "rejections" | "audit"; +export type MemoryRenderStatus = + | "rendered" + | "omitted_superseded" + | "omitted_type_cap" + | "omitted_global_cap" + | "omitted_char_budget" + | "omitted_absorbed_duplicate" + | "pending_retry" + | "pending_rejected_capacity" + | "quarantined_corrupt_store"; + +export type MemoryDiagJSON = { + version: 1; + workspace: { rootHash: string; key: string }; + generatedAt: string; + summary: { + storedActive: number; + rendered: number; + pending: number; + rejectedLast7Days: number; + corruptStoresQuarantinedLast30Days: number; + }; + memories: Array<{ + id: string; + type: "feedback" | "project" | "decision" | "reference"; + source: "explicit" | "compaction" | "manual"; + status: MemoryRenderStatus; + strength?: number; + reasonCodes: string[]; + textPreview?: string; + evidenceEventIds: string[]; + }>; + recentEvents: Array<{ + eventId: string; + type: EvidenceEventType; + outcome: EvidenceOutcome; + createdAt: string; + memoryId?: string; + reasonCodes: string[]; + }>; +}; + +type Command = "health" | "rejections" | "audit" | "explain" | "trace"; type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown"; type CliOptions = { raw: boolean; + json?: boolean; workspace?: string; all?: boolean; softOnly?: boolean; triggerOnly?: boolean; since?: string; migration?: string; + memory?: string; }; type RejectionLogRecord = { @@ -89,7 +141,9 @@ const ALLOWED_ORIGINS = new Set([ function usage(): string { return `Usage: - bun scripts/memory-diag.ts health [--workspace ] [--all] [--raw] + bun scripts/memory-diag.ts health [--workspace ] [--all] [--raw] [--json] + bun scripts/memory-diag.ts explain [--workspace ] [--raw] + bun scripts/memory-diag.ts trace --memory [--workspace ] [--raw] bun scripts/memory-diag.ts rejections [--soft-only] [--trigger-only] [--since 14d] [--raw] bun scripts/memory-diag.ts audit [--migration ] [--raw] `; @@ -107,7 +161,7 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } { console.log(usage()); process.exit(0); } - if (command !== "health" && command !== "rejections" && command !== "audit") { + if (command !== "health" && command !== "rejections" && command !== "audit" && command !== "explain" && command !== "trace") { die(`Unknown subcommand: ${command}`); } @@ -115,6 +169,7 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } { for (let i = 0; i < rest.length; i += 1) { const arg = rest[i]; if (arg === "--raw") options.raw = true; + else if (arg === "--json") options.json = true; else if (arg === "--all") options.all = true; else if (arg === "--soft-only") options.softOnly = true; else if (arg === "--trigger-only") options.triggerOnly = true; @@ -130,6 +185,10 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } { const value = rest[++i]; if (!value) die("--migration requires an id"); options.migration = value; + } else if (arg === "--memory") { + const value = rest[++i]; + if (!value) die("--memory requires an id"); + options.memory = value; } else { die(`Unknown option: ${arg}`); } @@ -137,15 +196,25 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } { if (command === "health") { if (options.all && options.workspace) die("Use either --all or --workspace, not both"); + if (options.json && options.all) die("health --json does not support --all"); + } else if (command === "explain" || command === "trace") { + if (options.all) die(`${command} does not accept --all`); } else { if (options.all || options.workspace) die(`${command} does not accept --all or --workspace`); } + if (command !== "health" && options.json) die(`${command} does not accept --json`); if (command !== "rejections" && (options.softOnly || options.triggerOnly || options.since)) { die(`${command} does not accept rejection filters`); } if (command !== "audit" && options.migration) { die(`${command} does not accept --migration`); } + if (command !== "trace" && options.memory) { + die(`${command} does not accept --memory`); + } + if (command === "trace" && !options.memory) { + die("--memory requires an id"); + } return { command, options }; } @@ -335,7 +404,186 @@ function normalizedJournal(journal: PendingMemoryJournalStore | null): PendingMe }; } +type WorkspaceDiagSnapshot = { + store: WorkspaceMemoryStore; + journal: PendingMemoryJournalStore; + retention: ReturnType; + memories: MemoryDiagJSON["memories"]; + recentEvents: MemoryDiagJSON["recentEvents"]; + allEvents: EvidenceEventV1[]; + summary: MemoryDiagJSON["summary"]; +}; + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +function eventMemoryId(event: EvidenceEventV1): string | undefined { + return event.memory?.memoryId + ?? event.relations?.map(relation => relation.memory?.memoryId).find((id): id is string => Boolean(id)); +} + +function isWithinDays(iso: string, days: number): boolean { + const ms = new Date(iso).getTime(); + return Number.isFinite(ms) && ms >= Date.now() - days * 86_400_000; +} + +function renderStatusReason(status: MemoryRenderStatus, fallback?: string): string[] { + switch (status) { + case "rendered": return ["within_caps", "within_char_budget"]; + case "omitted_superseded": return ["superseded"]; + case "omitted_type_cap": return ["type_cap"]; + case "omitted_global_cap": return ["global_cap"]; + case "omitted_char_budget": return [fallback === "empty_render_budget" ? "empty_render_budget" : "char_budget"]; + case "omitted_absorbed_duplicate": return ["absorbed_duplicate"]; + case "pending_retry": return ["retryable_capacity_rejection"]; + case "pending_rejected_capacity": return ["capacity_rejected", "max_attempts_reached"]; + case "quarantined_corrupt_store": return ["invalid_json"]; + } +} + +function statusFromOmissionReason(reason: string | undefined): MemoryRenderStatus { + if (reason === "superseded") return "omitted_superseded"; + if (reason === "type_cap") return "omitted_type_cap"; + if (reason === "global_cap") return "omitted_global_cap"; + return "omitted_char_budget"; +} + +function pendingStatus(entry: LongTermMemoryEntry): MemoryRenderStatus { + const attempts = entry.promotionAttempts ?? 0; + return attempts >= promotionLimit(entry.source) ? "pending_rejected_capacity" : "pending_retry"; +} + +function safeTextPreview(text: string): string { + return truncate(cleanText(text, false), 120); +} + +async function buildWorkspaceDiagSnapshot(input: { + root: string; + key: string; + memoryPath: string; + pendingPath: string; +}): Promise { + const rawStore = await readJSONFile(input.memoryPath); + const storeRoot = rawStore?.workspace?.root ?? input.root; + const storeKey = rawStore?.workspace?.key ?? input.key; + const store = normalizedStore(rawStore, storeRoot, storeKey); + const journal = normalizedJournal(await readJSONFile(input.pendingPath)); + const retention = retentionCandidatesForDiag(store); + const renderAccounting = accountWorkspaceMemoryRender(store); + const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id)); + const omittedById = new Map(renderAccounting.omitted.map(item => [item.memory.id, item.reason])); + const allEvents = await queryEvidenceEvents(input.root); + const recentEvidence = await queryEvidenceEvents(input.root, { newestFirst: true, limit: 50 }); + const memoryRows: MemoryDiagJSON["memories"] = []; + const seenIds = new Set(); + + for (const entry of store.entries) { + const omissionReason = omittedById.get(entry.id); + const status: MemoryRenderStatus = renderedIds.has(entry.id) + ? "rendered" + : statusFromOmissionReason(omissionReason ?? (entry.status === "superseded" ? "superseded" : undefined)); + const summary = await summarizeMemoryEvidence(input.root, { memoryId: entry.id }); + memoryRows.push({ + id: entry.id, + type: entry.type, + source: entry.source, + status, + strength: calculateRetentionStrength(entry, Date.now(), store.lastActivityAt), + reasonCodes: uniqueStrings([...renderStatusReason(status, omissionReason), ...summary.reasonCodes]), + textPreview: safeTextPreview(entry.text), + evidenceEventIds: summary.eventIds, + }); + seenIds.add(entry.id); + } + + for (const entry of journal.entries) { + const status = pendingStatus(entry); + const summary = await summarizeMemoryEvidence(input.root, { memoryId: entry.id }); + memoryRows.push({ + id: entry.id, + type: entry.type, + source: entry.source, + status, + strength: calculateRetentionStrength(entry, Date.now(), store.lastActivityAt), + reasonCodes: uniqueStrings([...renderStatusReason(status), entry.lastPromotionFailureReason ?? "", ...summary.reasonCodes]), + textPreview: safeTextPreview(entry.text), + evidenceEventIds: summary.eventIds, + }); + seenIds.add(entry.id); + } + + for (const event of allEvents) { + if (event.outcome !== "absorbed") continue; + const memory = event.memory; + if (!memory?.memoryId || !memory.type || !memory.source || seenIds.has(memory.memoryId)) continue; + const summary = await summarizeMemoryEvidence(input.root, { memoryId: memory.memoryId }); + memoryRows.push({ + id: memory.memoryId, + type: memory.type, + source: memory.source, + status: "omitted_absorbed_duplicate", + reasonCodes: uniqueStrings([...renderStatusReason("omitted_absorbed_duplicate"), ...summary.reasonCodes]), + evidenceEventIds: summary.eventIds.length > 0 ? summary.eventIds : [event.eventId], + }); + seenIds.add(memory.memoryId); + } + + const recentEvents = recentEvidence.map(event => ({ + eventId: event.eventId, + type: event.type, + outcome: event.outcome, + createdAt: event.createdAt, + memoryId: eventMemoryId(event), + reasonCodes: uniqueStrings([ + ...event.reasonCodes, + ...(event.type === "storage_corrupt_json_quarantined" ? ["quarantined_corrupt_store"] : []), + ]), + })); + + return { + store, + journal, + retention, + memories: memoryRows, + recentEvents, + allEvents, + summary: { + storedActive: store.entries.filter(entry => entry.status !== "superseded").length, + rendered: retention.rendered.length, + pending: journal.entries.length, + rejectedLast7Days: allEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7)).length, + corruptStoresQuarantinedLast30Days: allEvents.filter(event => event.type === "storage_corrupt_json_quarantined" && isWithinDays(event.createdAt, 30)).length, + }, + }; +} + +async function buildMemoryDiagJSON(root: string): Promise { + const key = await workspaceKey(root); + const snapshot = await buildWorkspaceDiagSnapshot({ + root, + key, + memoryPath: await workspaceMemoryPath(root), + pendingPath: await workspacePendingJournalPath(root), + }); + + return { + version: 1, + workspace: { rootHash: workspaceRootHash(snapshot.store.workspace.root || root), key: snapshot.store.workspace.key || key }, + generatedAt: new Date().toISOString(), + summary: snapshot.summary, + memories: snapshot.memories, + recentEvents: snapshot.recentEvents, + }; +} + async function runHealth(options: CliOptions): Promise { + if (options.json) { + const root = options.workspace ?? process.cwd(); + console.log(JSON.stringify(await buildMemoryDiagJSON(root), null, 2)); + return; + } + if (options.all) { const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 }); console.log("Workspace memory health"); @@ -707,8 +955,125 @@ async function runAudit(options: CliOptions): Promise { } } +async function snapshotForOptions(options: CliOptions): Promise { + const root = options.workspace ?? process.cwd(); + const key = await workspaceKey(root); + return buildWorkspaceDiagSnapshot({ + root, + key, + memoryPath: await workspaceMemoryPath(root), + pendingPath: await workspacePendingJournalPath(root), + }); +} + +function formatEvidenceRefs(eventIds: string[], allEvents: EvidenceEventV1[]): string { + if (eventIds.length === 0) return "(none)"; + const byId = new Map(allEvents.map(event => [event.eventId, event])); + return eventIds + .map(id => { + const event = byId.get(id); + return event ? `${event.eventId} ${event.type}` : id; + }) + .join(", "); +} + +async function runExplain(options: CliOptions): Promise { + const snapshot = await snapshotForOptions(options); + console.log("Workspace memory explainability"); + console.log(""); + + if (snapshot.memories.length === 0) { + console.log("No memories found."); + } + + for (const memory of snapshot.memories) { + console.log(`Memory ${memory.id}: ${memory.status}`); + const strength = typeof memory.strength === "number" ? formatStrength(memory.strength) : "n/a"; + console.log(`- strength=${strength}, type=${memory.type}, source=${memory.source}`); + console.log(`- reasons: ${memory.reasonCodes.length > 0 ? memory.reasonCodes.join(", ") : "(none)"}`); + console.log(`- evidence: ${formatEvidenceRefs(memory.evidenceEventIds, snapshot.allEvents)}`); + console.log(""); + } + + const quarantines = snapshot.recentEvents.filter(event => event.type === "storage_corrupt_json_quarantined"); + if (quarantines.length > 0) { + console.log("Quarantined stores:"); + for (const event of quarantines) { + console.log(`- quarantined_corrupt_store: ${event.eventId} ${event.type}; reasons=${event.reasonCodes.join(",")}`); + } + } +} + +function statusFromTraceEvent(event: EvidenceEventV1 | undefined): string { + if (!event) return "unknown"; + if (event.type === "render_selected") return "rendered"; + if (event.type === "render_omitted") return statusFromOmissionReason(event.reasonCodes[0]); + if (event.type === "promotion_absorbed_exact" || event.type === "promotion_absorbed_identity") return "omitted_absorbed_duplicate"; + if (event.type === "promotion_retry_scheduled") return "pending_retry"; + if (event.type === "promotion_rejected_capacity" || event.type === "promotion_retry_exhausted") return "pending_rejected_capacity"; + if (event.type === "storage_corrupt_json_quarantined") return "quarantined_corrupt_store"; + if (event.outcome === "superseded") return "omitted_superseded"; + return event.outcome; +} + +function formatTraceEvent(event: EvidenceEventV1): string { + const reasons = event.reasonCodes.length > 0 ? event.reasonCodes.join(",") : "none"; + const relations = (event.relations ?? []) + .map(relation => relation.memory?.memoryId ? `${relation.role}=${relation.memory.memoryId}` : undefined) + .filter((value): value is string => Boolean(value)); + const relationText = relations.length > 0 ? `; ${relations.join(", ")}` : ""; + return `- ${event.eventId} ${event.type}: ${event.outcome}; reasons=${reasons}${relationText}`; +} + +function relationMemoryIds(events: EvidenceEventV1[], role: string): string[] { + return uniqueStrings(events.flatMap(event => (event.relations ?? []) + .filter(relation => relation.role === role) + .map(relation => relation.memory?.memoryId ?? ""))); +} + +async function runTrace(options: CliOptions): Promise { + const root = options.workspace ?? process.cwd(); + const memoryId = options.memory; + if (!memoryId) die("--memory requires an id"); + + const [snapshot, trace] = await Promise.all([ + snapshotForOptions(options), + traceMemoryLifecycle(root, { memoryId }), + ]); + const memoryRow = snapshot.memories.find(memory => memory.id === memoryId); + const status = memoryRow?.status ?? statusFromTraceEvent(trace.events.at(-1)); + + console.log(`Memory ${memoryId}: ${status}`); + console.log(""); + console.log("Lifecycle:"); + if (trace.events.length === 0) { + console.log("(none)"); + return; + } + + for (const event of trace.events) { + console.log(formatTraceEvent(event)); + } + + const supersededBy = relationMemoryIds(trace.events, "superseded_by"); + if (supersededBy.length > 0) { + console.log(""); + console.log("Superseded by:"); + for (const id of supersededBy) console.log(`- ${id}`); + } + + const reinforcedBy = relationMemoryIds(trace.events, "reinforced_by"); + if (reinforcedBy.length > 0) { + console.log(""); + console.log("Reinforced by:"); + for (const id of reinforcedBy) console.log(`- ${id}`); + } +} + const { command, options } = parseArgs(process.argv.slice(2)); if (command === "health") await runHealth(options); else if (command === "rejections") await runRejections(options); -else await runAudit(options); +else if (command === "audit") await runAudit(options); +else if (command === "explain") await runExplain(options); +else await runTrace(options); diff --git a/tests/memory-diag.test.ts b/tests/memory-diag.test.ts index 5b576e7..a594ed1 100644 --- a/tests/memory-diag.test.ts +++ b/tests/memory-diag.test.ts @@ -1,14 +1,15 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; -import { LONG_TERM_LIMITS } from "../src/types.ts"; -import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts"; +import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } from "../src/types.ts"; +import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; const execFileAsync = promisify(execFile); const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -46,17 +47,43 @@ async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[], } async function runMemoryDiagHealth(root: string): Promise { + return runMemoryDiag(["health", "--workspace", root]); +} + +async function runMemoryDiag(args: string[]): Promise { const { stdout } = await execFileAsync(process.execPath, [ "--experimental-strip-types", "scripts/memory-diag.ts", - "health", - "--workspace", - root, + ...args, ], { cwd: repoRoot }); return stdout; } +async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise { + const key = await workspaceKey(root); + const path = await workspacePendingJournalPath(root); + const store: PendingMemoryJournalStore = { + version: 1, + workspace: { root, key }, + entries, + updatedAt: new Date().toISOString(), + }; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(store, null, 2), "utf8"); +} + +function evidence(overrides: Partial): EvidenceEventInput { + return { + type: "promotion_promoted", + phase: "promotion", + outcome: "promoted", + memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, + reasonCodes: ["new_workspace_entry"], + ...overrides, + }; +} + test("memory health reports stored vs rendered retention counts", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-")); try { @@ -141,3 +168,145 @@ test("memory health reports missing dormancy and non-alert monitoring defaults", await rm(root, { recursive: true, force: true }); } }); + +test("memory health --json prints parseable privacy-safe diagnostics matching human counts", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-json-")); + try { + const rendered = { ...entry("mem-rendered", "Prefer small focused changes", "feedback"), source: "explicit" as const }; + const secret = { ...entry("mem-secret", "Use password: sushi only in test fixtures", "decision"), source: "manual" as const }; + const superseded = { ...entry("mem-old", "Old decision that was superseded", "decision"), status: "superseded" as const }; + const pending = { ...entry("mem-pending", "Retry this pending memory later", "project"), promotionAttempts: 1 }; + await writeWorkspaceStore(root, [rendered, secret, superseded]); + await writePendingJournal(root, [pending]); + await appendEvidenceEvents(root, [ + evidence({ type: "extraction_candidate_rejected", phase: "extraction", outcome: "rejected", memory: { memoryId: "mem-rejected", type: "feedback", source: "explicit" }, reasonCodes: ["raw_secret"], textPreview: "password: sushi should not leak" }), + evidence({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined", memory: undefined, reasonCodes: ["invalid_json"] }), + evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["new_workspace_entry"] }), + evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["within_caps", "within_char_budget"] }), + ]); + + const human = await runMemoryDiagHealth(root); + const jsonText = await runMemoryDiag(["health", "--workspace", root, "--json"]); + const parsed = JSON.parse(jsonText) as { + version: 1; + summary: { storedActive: number; rendered: number; pending: number; rejectedLast7Days: number; corruptStoresQuarantinedLast30Days: number }; + memories: Array<{ id: string; status: string; reasonCodes: string[]; evidenceEventIds: string[]; textPreview?: string }>; + recentEvents: Array<{ eventId: string; type: string; outcome: string; createdAt: string; reasonCodes: string[] }>; + }; + + assert.equal(parsed.version, 1); + assert.equal(parsed.summary.storedActive, Number(human.match(/Stored active memories: (\d+)/)?.[1])); + assert.equal(parsed.summary.rendered, Number(human.match(/Rendered candidates: (\d+)/)?.[1])); + assert.equal(parsed.summary.pending, Number(human.match(/Pending journal:\n\s+total: (\d+)/)?.[1])); + assert.equal(parsed.summary.rejectedLast7Days, 1); + assert.equal(parsed.summary.corruptStoresQuarantinedLast30Days, 1); + assert.ok(parsed.recentEvents.some(event => event.eventId && event.type === "render_selected" && event.outcome === "rendered" && event.createdAt && event.reasonCodes.includes("within_caps"))); + assert.ok(parsed.memories.find(memory => memory.id === "mem-rendered")?.evidenceEventIds.length); + assert.ok(!jsonText.includes("sushi")); + assert.ok(jsonText.trim().startsWith("{")); + assert.ok(jsonText.trim().endsWith("}")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag explain shows rendered, omitted, pending, and evidence reason status", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-explain-")); + try { + const rendered = { ...entry("mem-rendered", "Rendered feedback wins the render set", "feedback"), source: "explicit" as const }; + const superseded = { ...entry("mem-superseded", "Superseded memory is not rendered", "decision"), status: "superseded" as const }; + const typeCapped = Array.from({ length: 11 }, (_, i) => entry(`mem-type-${i}`, `Type cap feedback memory ${i}`, "feedback")); + const globalCapped = [ + ...Array.from({ length: 10 }, (_, i) => entry(`mem-g-feedback-${i}`, `Global cap feedback memory ${i}`, "feedback")), + ...Array.from({ length: 10 }, (_, i) => entry(`mem-g-decision-${i}`, `Global cap decision memory ${i}`, "decision")), + ...Array.from({ length: 8 }, (_, i) => entry(`mem-g-project-${i}`, `Global cap project memory ${i}`, "project")), + ...Array.from({ length: 7 }, (_, i) => entry(`mem-g-reference-${i}`, `Global cap reference memory ${i}`, "reference")), + ]; + const charBudget = { ...entry("mem-char-budget", "This active memory cannot fit the tiny character budget", "project") }; + await writeWorkspaceStore(root, [rendered, superseded, ...typeCapped, ...globalCapped, charBudget]); + const key = await workspaceKey(root); + const path = await workspaceMemoryPath(root); + const raw = JSON.parse(await readFile(path, "utf8")) as WorkspaceMemoryStore; + raw.limits.maxRenderedChars = 100; + raw.workspace = { root, key }; + await writeFile(path, JSON.stringify(raw, null, 2), "utf8"); + + const retry = { ...entry("mem-pending-retry", "Pending retry memory", "project"), promotionAttempts: 1, lastPromotionFailureReason: "capacity_rejected" }; + const exhausted = { ...entry("mem-pending-capacity", "Pending capacity rejected memory", "project"), promotionAttempts: PROMOTION_RETRY_LIMITS.maxExplicitAttempts, lastPromotionFailureReason: "capacity_rejected" }; + await writePendingJournal(root, [retry, exhausted]); + await appendEvidenceEvents(root, [ + evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "explicit" }, reasonCodes: ["same_exact_key"] }), + evidence({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined", memory: undefined, reasonCodes: ["invalid_json"] }), + ]); + + const stdout = await runMemoryDiag(["explain", "--workspace", root]); + + assert.match(stdout, /Memory mem-rendered: rendered/); + assert.match(stdout, /Memory mem-superseded: omitted_superseded/); + assert.match(stdout, /omitted_type_cap/); + assert.match(stdout, /omitted_global_cap/); + assert.match(stdout, /omitted_char_budget/); + assert.match(stdout, /Memory mem-pending-retry: pending_retry/); + assert.match(stdout, /Memory mem-pending-capacity: pending_rejected_capacity/); + assert.match(stdout, /Memory mem-absorbed: omitted_absorbed_duplicate/); + assert.match(stdout, /quarantined_corrupt_store/); + assert.match(stdout, /- strength=\d+\.\d{3}, type=feedback, source=explicit/); + assert.match(stdout, /- evidence: .*promotion_absorbed_exact/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag trace prints lifecycle relations and redacts secrets", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-")); + try { + await writeWorkspaceStore(root, [ + { ...entry("mem-life", "Old token password: sushi should be redacted", "decision"), status: "superseded" as const }, + entry("mem-new", "Replacement memory", "decision"), + ]); + await appendEvidenceEvents(root, [ + evidence({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, reasonCodes: ["quality_gate_passed"], textPreview: "password: sushi" }), + evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "pending", memory: { memoryId: "mem-life" } }], reasonCodes: ["pending_journal_append"] }), + evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), + evidence({ type: "memory_reinforced", phase: "reinforcement", outcome: "reinforced", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "reinforced_by", memory: { memoryId: "mem-duplicate" } }], reasonCodes: ["duplicate_exact"] }), + evidence({ type: "promotion_superseded", phase: "promotion", outcome: "superseded", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "superseded_by", memory: { memoryId: "mem-new" } }], reasonCodes: ["superseded_existing"] }), + evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "omitted", memory: { memoryId: "mem-life" } }], reasonCodes: ["superseded"] }), + ]); + + const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "mem-life"]); + + assert.match(stdout, /Memory mem-life: omitted_superseded/); + assert.match(stdout, /Lifecycle:/); + assert.match(stdout, /extraction_candidate_accepted: accepted; reasons=quality_gate_passed/); + assert.match(stdout, /pending_memory_appended: accepted; reasons=pending_journal_append/); + assert.match(stdout, /promotion_superseded: superseded; reasons=superseded_existing; .*superseded_by=mem-new/); + assert.match(stdout, /memory_reinforced: reinforced; reasons=duplicate_exact; .*reinforced_by=mem-duplicate/); + assert.match(stdout, /Superseded by:\n- mem-new/); + assert.match(stdout, /Reinforced by:\n- mem-duplicate/); + assert.ok(!stdout.includes("sushi")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag trace requires --memory and reports unknown IDs", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-")); + try { + await assert.rejects( + execFileAsync(process.execPath, ["--experimental-strip-types", "scripts/memory-diag.ts", "trace", "--workspace", root], { cwd: repoRoot }), + (error: unknown) => { + const err = error as { code?: number; stderr?: string }; + assert.notEqual(err.code, 0); + assert.match(err.stderr ?? "", /--memory requires an id/); + assert.match(err.stderr ?? "", /Usage:/); + return true; + }, + ); + + const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "missing-memory"]); + assert.match(stdout, /Memory missing-memory: unknown/); + assert.match(stdout, /Lifecycle:\n\(none\)/); + } finally { + await rm(root, { recursive: true, force: true }); + } +});