feat(memory-diag): add memory command detail

This commit is contained in:
Ralph Chang
2026-05-14 09:29:55 +08:00
parent 3c4282b241
commit 93550b2e41
4 changed files with 404 additions and 2 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ export function usage(): string {
memory-diag rejected [--workspace <path>] [--verbose] [--json]
memory-diag missing [--workspace <path>] [--verbose] [--json]
memory-diag explain [memory-id] [--workspace <path>] [--raw]
memory-diag commands [--workspace <path>] [--verbose] [--json]
memory-diag commands [--workspace <path>] [--verbose] [--json] [--memory <id>]
memory-diag quality [--workspace <path>] [--verbose] [--json] [--raw] [--no-emoji]
memory-diag revert (--memory <replacement-id> | --event <event-id>) [--workspace <path>] [--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`);
+237
View File
@@ -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<string, number>;
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<MemoryCommandDetail["current"]> {
const rawStore = await readJSONFile<WorkspaceMemoryStore>(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<MemoryCommandDetail> {
const reinforcementEvents = events.filter(isReinforcementEvent);
const blockReasonCounts = new Map<string, number>();
const refs = new Set<string>();
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, number>): 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<CliOptions, "verbose"> = {}): 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<CliOptions, "verbose" | "noEmoji"> = {}): string {
const warning = options.noEmoji ? "!" : "⚠";
const lines = [
@@ -176,6 +402,17 @@ export function formatMemoryCommandSummary(summary: MemoryCommandSummary, option
export async function runCommands(options: CliOptions): Promise<CommandResult> {
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);
+10
View File
@@ -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);
+155
View File
@@ -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<void> {
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<string, number>;
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 {