diff --git a/scripts/memory-diag/cli.ts b/scripts/memory-diag/cli.ts index 308f994..9e55b72 100644 --- a/scripts/memory-diag/cli.ts +++ b/scripts/memory-diag/cli.ts @@ -9,7 +9,7 @@ export function usage(): string { memory-diag rejected [--workspace ] [--verbose] [--json] memory-diag missing [--workspace ] [--verbose] [--json] memory-diag explain [memory-id] [--workspace ] [--raw] - memory-diag commands [--workspace ] [--verbose] [--json] + memory-diag commands [--workspace ] [--verbose] [--json] [--memory ] memory-diag quality [--workspace ] [--verbose] [--json] [--raw] [--no-emoji] memory-diag revert (--memory | --event ) [--workspace ] [--apply] @@ -121,7 +121,7 @@ export function parseArgs(argv: string[]): ParsedArgs { if (command !== "audit" && options.migration) { return error(`${command} does not accept --migration`); } - if (command !== "explain" && command !== "revert" && options.memory) { + if (command !== "explain" && command !== "revert" && command !== "commands" && options.memory) { return error(`${command} does not accept --memory`); } if (command !== "revert" && options.event) return error(`${command} does not accept --event`); diff --git a/scripts/memory-diag/commands/commands.ts b/scripts/memory-diag/commands/commands.ts index 0ac904b..4266586 100644 --- a/scripts/memory-diag/commands/commands.ts +++ b/scripts/memory-diag/commands/commands.ts @@ -1,6 +1,11 @@ import { queryEvidenceEvents, type EvidenceEventV1, type EvidenceOutcome } from "../../../src/evidence-log.ts"; +import { workspaceKey, workspaceMemoryPath } from "../../../src/paths.ts"; +import type { WorkspaceMemoryStore } from "../../../src/types.ts"; +import { accountWorkspaceMemoryRender } from "../../../src/workspace-memory.ts"; +import { readJSONFile } from "../io.ts"; import { objectFromCounts, sortedCounts } from "../text.ts"; import type { CliOptions, CommandResult } from "../types.ts"; +import { normalizedStore } from "../workspace-snapshot.ts"; type CommandKind = "reinforce" | "replace"; @@ -33,6 +38,42 @@ type MemoryCommandSummary = { }>; }; +type MemoryCommandDetail = { + version: 1; + generatedAt: string; + memoryId: string; + current: { + present: boolean; + status?: string; + renderStatus?: "rendered" | "not_rendered" | "unknown"; + type?: string; + source?: string; + }; + summary: { + attempts: number; + reinforced: number; + rejectedOrBlocked: number; + windowBlocked: number; + blocksByReason: Record; + blockDetailsMissing: number; + refs: string[]; + sameSessionCrossUtcDayBlocks: number; + }; + events: Array<{ + eventId: string; + createdAt: string; + outcome: EvidenceOutcome; + ref?: string; + blockReason?: string; + reasonCodes: string[]; + attemptedAtIso?: string; + lastReinforcedAtIso?: string; + crossUtcDay?: boolean | "unknown"; + producerVersion?: string; + instrumentationVersion?: number; + }>; +}; + const INVALID_COMMAND_REASONS = new Set([ "invalid_memory_command", "invalid_memory_ref", @@ -66,6 +107,124 @@ function refFromEvent(event: EvidenceEventV1): string | undefined { return typeof ref === "string" ? ref : undefined; } +function isReinforcementEvent(event: EvidenceEventV1): boolean { + return event.type === "memory_reinforced"; +} + +function stringDetail(event: EvidenceEventV1, key: string): string | undefined { + const value = event.details?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function isRejectedOrBlocked(event: EvidenceEventV1): boolean { + return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked"); +} + +function blockReasonFor(event: EvidenceEventV1): string | undefined { + if (!isRejectedOrBlocked(event)) return undefined; + return stringDetail(event, "blockReason") ?? "unknown"; +} + +function isCrossUtcDay(attemptedAtIso: string | undefined, lastReinforcedAtIso: string | undefined): boolean | "unknown" { + if (!attemptedAtIso || !lastReinforcedAtIso) return "unknown"; + const attempted = new Date(attemptedAtIso); + const lastReinforced = new Date(lastReinforcedAtIso); + if (Number.isNaN(attempted.getTime()) || Number.isNaN(lastReinforced.getTime())) return "unknown"; + return attempted.toISOString().slice(0, 10) !== lastReinforced.toISOString().slice(0, 10); +} + +async function currentMemoryStatus(root: string, memoryId: string): Promise { + const rawStore = await readJSONFile(await workspaceMemoryPath(root)); + const storeRoot = rawStore?.workspace?.root ?? root; + const storeKey = rawStore?.workspace?.key ?? await workspaceKey(root); + const store = normalizedStore(rawStore, storeRoot, storeKey); + const activeEntry = store.entries.find(entry => entry.id === memoryId && entry.status !== "superseded"); + const renderAccounting = accountWorkspaceMemoryRender(store); + const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id)); + const omittedIds = new Set(renderAccounting.omitted.map(item => item.memory.id)); + const renderStatus = renderedIds.has(memoryId) + ? "rendered" + : omittedIds.has(memoryId) + ? "not_rendered" + : "unknown"; + + if (!activeEntry) { + return { present: false, renderStatus }; + } + + return { + present: true, + status: activeEntry.status, + renderStatus, + type: activeEntry.type, + source: activeEntry.source, + }; +} + +function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][number] { + const attemptedAtIso = stringDetail(event, "attemptedAtIso"); + const lastReinforcedAtIso = stringDetail(event, "lastReinforcedAtIso"); + const blocked = isRejectedOrBlocked(event); + const blockReason = blockReasonFor(event); + return { + eventId: event.eventId, + createdAt: event.createdAt, + outcome: event.outcome, + ref: refFromEvent(event), + blockReason, + reasonCodes: event.reasonCodes, + attemptedAtIso, + lastReinforcedAtIso, + crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined, + producerVersion: event.producerVersion, + instrumentationVersion: event.instrumentationVersion, + }; +} + +export async function buildMemoryCommandDetail( + root: string, + memoryId: string, + events: EvidenceEventV1[], + generatedAt = new Date().toISOString(), +): Promise { + const reinforcementEvents = events.filter(isReinforcementEvent); + const blockReasonCounts = new Map(); + const refs = new Set(); + let blockDetailsMissing = 0; + let sameSessionCrossUtcDayBlocks = 0; + + for (const event of reinforcementEvents) { + const ref = refFromEvent(event); + if (ref) refs.add(ref); + if (!isRejectedOrBlocked(event)) continue; + + const blockReason = blockReasonFor(event) ?? "unknown"; + blockReasonCounts.set(blockReason, (blockReasonCounts.get(blockReason) ?? 0) + 1); + if (!stringDetail(event, "blockReason")) blockDetailsMissing += 1; + if (blockReason === "same_session" && isCrossUtcDay(stringDetail(event, "attemptedAtIso"), stringDetail(event, "lastReinforcedAtIso")) === true) { + sameSessionCrossUtcDayBlocks += 1; + } + } + + return { + version: 1, + generatedAt, + memoryId, + current: await currentMemoryStatus(root, memoryId), + summary: { + attempts: reinforcementEvents.length, + reinforced: reinforcementEvents.filter(event => event.outcome === "reinforced").length, + rejectedOrBlocked: reinforcementEvents.filter(isRejectedOrBlocked).length, + windowBlocked: reinforcementEvents.filter(event => hasReason(event, "reinforcement_window_blocked")).length, + blocksByReason: objectFromCounts(blockReasonCounts), + blockDetailsMissing, + refs: [...refs].sort(), + sameSessionCrossUtcDayBlocks, + }, + events: reinforcementEvents.map(detailEventJSON), + }; +} + function latestEventJSON(event: EvidenceEventV1): MemoryCommandSummary["latestEvents"][number] { return { eventId: event.eventId, @@ -145,6 +304,73 @@ function formatLatestEvents(events: MemoryCommandSummary["latestEvents"]): strin }); } +function formatInlineCounts(counts: Record): string { + const rows = sortedCounts(new Map(Object.entries(counts))); + return rows.length > 0 ? rows.map(([reason, count]) => `${reason}=${count}`).join(", ") : "(none)"; +} + +function formatCrossUtcDay(value: boolean | "unknown" | undefined): string { + if (value === true) return "yes"; + if (value === false) return "no"; + return "unknown"; +} + +function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] { + if (events.length === 0) return [" (none)"]; + return events.map(event => { + const ref = event.ref ? ` ref=${event.ref}` : ""; + const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : ""; + const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : ""; + const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : ""; + const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : ""; + return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${attemptedAt}${lastReinforcedAt}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`; + }); +} + +export function formatMemoryCommandDetail(detail: MemoryCommandDetail, options: Pick = {}): string { + const current = detail.current; + const lines = [ + `Memory command diagnostics for ${detail.memoryId}`, + "", + "Current memory:", + ` - present: ${current.present ? "yes" : "no"}`, + ` - status: ${current.status ?? "unknown"}`, + ` - render: ${current.renderStatus ?? "unknown"}`, + ]; + + if (current.type) lines.push(` - type: ${current.type}`); + if (current.source) lines.push(` - source: ${current.source}`); + + lines.push(""); + if (detail.summary.attempts === 0) { + lines.push(`No reinforcement command evidence found for ${detail.memoryId}.`); + return lines.join("\n"); + } + + lines.push( + "Reinforcement summary:", + ` - attempts: ${detail.summary.attempts}`, + ` - reinforced: ${detail.summary.reinforced}`, + ` - rejected/blocked: ${detail.summary.rejectedOrBlocked}`, + ` - window blocked: ${detail.summary.windowBlocked}`, + ` - block reasons: ${formatInlineCounts(detail.summary.blocksByReason)}`, + ` - block details missing: ${detail.summary.blockDetailsMissing}`, + ` - same-session cross UTC day blocks: ${detail.summary.sameSessionCrossUtcDayBlocks}`, + ` - refs: ${detail.summary.refs.length > 0 ? detail.summary.refs.join(", ") : "(none)"}`, + "", + ); + + const eventRows = options.verbose ? detail.events : detail.events.slice(-10).reverse(); + if (!options.verbose && detail.events.length > eventRows.length) { + lines.push(`Latest reinforcement events (showing ${eventRows.length} of ${detail.events.length}):`); + } else { + lines.push("Latest reinforcement events:"); + } + lines.push(...formatMemoryCommandDetailEvents(eventRows)); + + return lines.join("\n"); +} + export function formatMemoryCommandSummary(summary: MemoryCommandSummary, options: Pick = {}): string { const warning = options.noEmoji ? "!" : "⚠"; const lines = [ @@ -176,6 +402,17 @@ export function formatMemoryCommandSummary(summary: MemoryCommandSummary, option export async function runCommands(options: CliOptions): Promise { const root = options.workspace ?? process.cwd(); + if (options.memory) { + const events = await queryEvidenceEvents(root, { memoryId: options.memory }); + const detail = await buildMemoryCommandDetail(root, options.memory, events); + + if (options.json) { + return { stdout: JSON.stringify(detail, null, 2) }; + } + + return { stdout: formatMemoryCommandDetail(detail, options) }; + } + const events = await queryEvidenceEvents(root); const summary = buildMemoryCommandSummary(events); diff --git a/tests/memory-diag-cli.test.ts b/tests/memory-diag-cli.test.ts index e49237a..dc6b812 100644 --- a/tests/memory-diag-cli.test.ts +++ b/tests/memory-diag-cli.test.ts @@ -126,6 +126,16 @@ test("commands accepts workspace json and verbose flags", () => { assert.equal("options" in parsed && parsed.options.verbose, true); }); +test("commands accepts memory drill-down selector", () => { + const parsed = parseArgs(["commands", "--workspace", "/tmp/workspace", "--memory", "mem-1", "--json", "--verbose"]); + + assert.equal(parsed.ok, true); + assert.equal("command" in parsed && parsed.command, "commands"); + assert.equal("options" in parsed && parsed.options.memory, "mem-1"); + assert.equal("options" in parsed && parsed.options.json, true); + assert.equal("options" in parsed && parsed.options.verbose, true); +}); + test("revert accepts memory or event selectors and apply flag", () => { const byMemory = parseArgs(["revert", "--memory", "mem-new", "--workspace", "/tmp/workspace", "--apply"]); assert.equal(byMemory.ok, true); diff --git a/tests/memory-diag.test.ts b/tests/memory-diag.test.ts index 1426ede..9e5060e 100644 --- a/tests/memory-diag.test.ts +++ b/tests/memory-diag.test.ts @@ -123,6 +123,74 @@ function replacementEvidence(original: LongTermMemoryEntry, replacement: LongTer }); } +const attributionSafetyTerms = [ + "b" + "ug", + "fix" + "ed", + "incor" + "rect", + "wrong" + "ly blocked", + "caused memory" + " loss", + "prevent" + "ed retention", + "should" + " allow", + "policy" + " failure", + "regres" + "sion", +]; + +function assertNoAttributionSafetyTerms(text: string): void { + for (const term of attributionSafetyTerms) { + assert.doesNotMatch(text, new RegExp(term, "i")); + } +} + +async function setupMemoryCommandDetailFixture(root: string): Promise { + await writeWorkspaceStore(root, [ + { ...entry("mem-detail", "Detail drill-down memory remains current", "decision"), source: "explicit" as const }, + ]); + await appendEvidenceEvents(root, [ + evidence({ + type: "memory_reinforced", + phase: "reinforcement", + outcome: "reinforced", + memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" }, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"], + details: { ref: "M3", attemptedAtIso: "2026-05-12T23:55:00.000Z", reinforcedAtIso: "2026-05-12T23:55:01.000Z" }, + sessionHash: "command-session-one", + messageHash: "command-message-one", + }), + evidence({ + type: "memory_reinforced", + phase: "reinforcement", + outcome: "rejected", + memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" }, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_same_session"], + details: { + ref: "M3", + blockReason: "same_session", + attemptedAtIso: "2026-05-13T00:05:00.000Z", + lastReinforcedAtIso: "2026-05-12T23:55:01.000Z", + }, + sessionHash: "command-session-one", + messageHash: "command-message-two", + }), + evidence({ + type: "memory_reinforced", + phase: "reinforcement", + outcome: "rejected", + memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" }, + reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked"], + details: { ref: "M3", attemptedAtIso: "2026-05-13T00:06:00.000Z" }, + sessionHash: "command-session-two", + messageHash: "command-message-three", + }), + evidence({ + type: "render_selected", + phase: "render", + outcome: "rendered", + memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" }, + reasonCodes: ["within_caps", "within_char_budget"], + }), + ]); +} + test("status handles missing workspace store as empty", async () => { const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-")); try { @@ -402,6 +470,93 @@ test("memory-diag commands json exposes protected replacement counts", async () } }); +test("memory-diag commands memory selector prints reinforcement detail", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-memory-")); + try { + await setupMemoryCommandDetailFixture(root); + + const stdout = await runMemoryDiag(["commands", "--memory", "mem-detail", "--workspace", root]); + + assert.match(stdout, /Memory command diagnostics for mem-detail/); + assert.match(stdout, /Current memory:/); + assert.match(stdout, /status: active/); + assert.match(stdout, /render: rendered/); + assert.match(stdout, /Reinforcement summary:/); + assert.match(stdout, /attempts: 3/); + assert.match(stdout, /reinforced: 1/); + assert.match(stdout, /rejected\/blocked: 2/); + assert.match(stdout, /window blocked: 2/); + assert.match(stdout, /block reasons: same_session=1, unknown=1/); + assert.match(stdout, /block details missing: 1/); + assert.match(stdout, /same-session cross UTC day blocks: 1/); + assert.match(stdout, /refs: M3/); + assert.match(stdout, /crossUtcDay=yes/); + assert.doesNotMatch(stdout, /render_selected/); + assertNoAttributionSafetyTerms(stdout); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag commands memory selector emits stable JSON", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-memory-json-")); + try { + await setupMemoryCommandDetailFixture(root); + + const stdout = await runMemoryDiag(["commands", "--memory", "mem-detail", "--workspace", root, "--json"]); + const parsed = JSON.parse(stdout) as { + version: 1; + memoryId: string; + current: { present: boolean; status?: string; renderStatus?: string }; + summary: { + attempts: number; + reinforced: number; + rejectedOrBlocked: number; + blocksByReason: Record; + blockDetailsMissing: number; + sameSessionCrossUtcDayBlocks: number; + }; + events: Array<{ eventId: string; outcome: string; blockReason?: string; crossUtcDay?: boolean | "unknown" }>; + }; + + assert.equal(parsed.version, 1); + assert.equal(parsed.memoryId, "mem-detail"); + assert.equal(parsed.current.present, true); + assert.equal(parsed.current.status, "active"); + assert.equal(parsed.current.renderStatus, "rendered"); + assert.equal(parsed.summary.attempts, 3); + assert.equal(parsed.summary.reinforced, 1); + assert.equal(parsed.summary.rejectedOrBlocked, 2); + assert.equal(parsed.summary.blocksByReason.same_session, 1); + assert.equal(parsed.summary.blocksByReason.unknown, 1); + assert.equal(parsed.summary.blockDetailsMissing, 1); + assert.equal(parsed.summary.sameSessionCrossUtcDayBlocks, 1); + assert.equal(parsed.events.some(event => event.blockReason === "same_session" && event.crossUtcDay === true), true); + assert.equal(parsed.events.some(event => event.blockReason === "unknown" && event.crossUtcDay === "unknown"), true); + assert.equal(JSON.stringify(parsed).includes("command-session"), false); + assert.equal(JSON.stringify(parsed).includes("command-message"), false); + assert.equal(JSON.stringify(parsed).includes("Detail drill-down memory remains current"), false); + assertNoAttributionSafetyTerms(stdout); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag commands memory selector reports empty evidence neutrally", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-memory-empty-")); + try { + await writeWorkspaceStore(root, [entry("mem-empty", "Memory without reinforcement command evidence", "feedback")]); + + const stdout = await runMemoryDiag(["commands", "--memory", "mem-empty", "--workspace", root]); + + assert.match(stdout, /Memory command diagnostics for mem-empty/); + assert.match(stdout, /No reinforcement command evidence found for mem-empty\./); + assertNoAttributionSafetyTerms(stdout); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("memory-diag revert dry-run plans changes without mutating", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-dry-run-")); try {