mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
feat(memory-diag): publish diagnostics CLI
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
|
||||
import type { LongTermMemoryEntry } from "../src/types.ts";
|
||||
import type { MemoryInspectionReadModel } from "../scripts/memory-diag/types.ts";
|
||||
import { CliInputError, normalizeRejection, rejectionQualitySummary, sinceCutoff } from "../scripts/memory-diag/rejections-model.ts";
|
||||
import { coverageRows, disappearanceRows } from "../scripts/memory-diag/inspection-model.ts";
|
||||
import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts";
|
||||
import { statusFromTraceEvent } from "../scripts/memory-diag/trace-model.ts";
|
||||
|
||||
function entry(id: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
|
||||
const now = new Date("2026-01-01T00:00:00.000Z").toISOString();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
text: `${id} text`,
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 {
|
||||
return {
|
||||
version: 1,
|
||||
eventId: `evt-${overrides.type}-${Math.random()}`,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||
workspaceKey: "workspace-key",
|
||||
workspaceRootHash: "workspace-root-hash",
|
||||
reasonCodes: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function model(entries: LongTermMemoryEntry[], events: EvidenceEventV1[]): MemoryInspectionReadModel {
|
||||
return {
|
||||
store: {
|
||||
version: 1,
|
||||
workspace: { root: "/tmp/workspace", key: "workspace-key" },
|
||||
limits: { maxRenderedChars: 24_000, maxEntries: 28 },
|
||||
entries,
|
||||
migrations: [],
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||
},
|
||||
pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() },
|
||||
evidenceEvents: events,
|
||||
rejectionRecords: [],
|
||||
currentById: new Map(entries.map(memory => [memory.id, memory])),
|
||||
evidenceByMemoryId: groupEvidenceByMemoryId(events),
|
||||
};
|
||||
}
|
||||
|
||||
test("normalizeRejection infers origins from source", () => {
|
||||
assert.equal(normalizeRejection({ source: "compaction", text: "a", reasons: ["bad_decision"] })?.origin, "compaction_candidate");
|
||||
assert.equal(normalizeRejection({ source: "explicit", text: "a", reasons: ["bad_feedback"] })?.origin, "explicit_trigger");
|
||||
assert.equal(normalizeRejection({ source: "manual", text: "a", reasons: ["bad_feedback"] })?.origin, "manual");
|
||||
assert.equal(normalizeRejection({ source: "unknown-source", text: "a", reasons: ["bad_feedback"] })?.origin, "unknown");
|
||||
});
|
||||
|
||||
test("sinceCutoff accepts relative durations and ISO timestamps", () => {
|
||||
const now = new Date("2026-01-15T12:00:00.000Z").getTime();
|
||||
|
||||
assert.equal(sinceCutoff("14d", now), now - 14 * 86_400_000);
|
||||
assert.equal(sinceCutoff("3h", now), now - 3 * 3_600_000);
|
||||
assert.equal(sinceCutoff("30m", now), now - 30 * 60_000);
|
||||
assert.equal(sinceCutoff("2026-01-01T00:00:00.000Z", now), new Date("2026-01-01T00:00:00.000Z").getTime());
|
||||
assert.throws(() => sinceCutoff("forever", now), (error: unknown) => {
|
||||
assert.ok(error instanceof CliInputError);
|
||||
assert.equal((error as Error).message, "Invalid --since value: forever");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("rejectionQualitySummary keeps architecture-like false-positive grouping", () => {
|
||||
const records = [
|
||||
normalizeRejection({ type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] }),
|
||||
normalizeRejection({ type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] }),
|
||||
normalizeRejection({ type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] }),
|
||||
].filter(record => record !== null);
|
||||
|
||||
const summary = rejectionQualitySummary(records);
|
||||
|
||||
assert.equal(summary.totalRecords, 3);
|
||||
assert.equal(summary.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
|
||||
assert.equal(summary.possibleFalsePositiveGroups.clearly_garbage.count, 1);
|
||||
assert.equal(summary.possibleFalsePositiveGroups.ambiguous.count, 1);
|
||||
});
|
||||
|
||||
test("coverageRows classifies current and historical memory evidence", () => {
|
||||
const entries = [entry("mem-full", "feedback"), entry("mem-render-only", "decision"), entry("mem-no-evidence", "project")];
|
||||
const events = [
|
||||
event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }),
|
||||
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }),
|
||||
event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-render-only", type: "decision", source: "compaction" } }),
|
||||
event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "historical-cap", type: "project", source: "compaction" }, reasonCodes: ["global_cap"] }),
|
||||
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "reference", source: "compaction" } }),
|
||||
];
|
||||
|
||||
const rows = coverageRows(model(entries, events), true);
|
||||
const byId = new Map(rows.map(row => [row.id, row.class]));
|
||||
|
||||
assert.equal(byId.get("mem-full"), "full_lifecycle");
|
||||
assert.equal(byId.get("mem-render-only"), "render_only");
|
||||
assert.equal(byId.get("mem-no-evidence"), "no_evidence");
|
||||
assert.equal(byId.get("historical-cap"), "historical_absent_with_reason");
|
||||
assert.equal(byId.get("historical-unknown"), "historical_absent_unknown_reason");
|
||||
});
|
||||
|
||||
test("disappearanceRows surfaces terminal capacity evidence", () => {
|
||||
const events = [
|
||||
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" } }),
|
||||
event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" }, reasonCodes: ["type_cap"] }),
|
||||
];
|
||||
|
||||
const rows = disappearanceRows(model([], events));
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].id, "capacity-loser");
|
||||
assert.equal(rows[0].classification, "historical_absent_with_reason");
|
||||
assert.equal(rows[0].terminalType, "memory_removed_capacity");
|
||||
assert.deepEqual(rows[0].reasonCodes, ["type_cap"]);
|
||||
});
|
||||
|
||||
test("statusFromTraceEvent maps lifecycle events", () => {
|
||||
assert.equal(statusFromTraceEvent(undefined), "unknown");
|
||||
assert.equal(statusFromTraceEvent(event({ type: "render_selected", phase: "render", outcome: "rendered" })), "rendered");
|
||||
assert.equal(statusFromTraceEvent(event({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["type_cap"] })), "omitted_type_cap");
|
||||
assert.equal(statusFromTraceEvent(event({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed" })), "omitted_absorbed_duplicate");
|
||||
assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_scheduled", phase: "promotion", outcome: "retried" })), "pending_retry");
|
||||
assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_exhausted", phase: "promotion", outcome: "exhausted" })), "pending_rejected_capacity");
|
||||
assert.equal(statusFromTraceEvent(event({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined" })), "quarantined_corrupt_store");
|
||||
assert.equal(statusFromTraceEvent(event({ type: "promotion_superseded", phase: "promotion", outcome: "superseded" })), "omitted_superseded");
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseArgs } from "../scripts/memory-diag/cli.ts";
|
||||
|
||||
test("help returns usage without exiting", () => {
|
||||
const parsed = parseArgs(["--help"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("help" in parsed && parsed.help, true);
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
assert.match(parsed.usage, /memory-diag \[status\]/);
|
||||
assert.doesNotMatch(parsed.usage, /health/);
|
||||
});
|
||||
|
||||
test("status defaults when no subcommand", () => {
|
||||
const parsed = parseArgs([]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "status");
|
||||
assert.deepEqual("options" in parsed && parsed.options, { raw: false, positional: [] });
|
||||
});
|
||||
|
||||
test("unknown command returns usage error", () => {
|
||||
const parsed = parseArgs(["unknown"]);
|
||||
|
||||
assert.equal(parsed.ok, false);
|
||||
if (parsed.ok) return;
|
||||
assert.equal(parsed.message, "Unknown subcommand: unknown");
|
||||
assert.equal(parsed.exitCode, 1);
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
});
|
||||
|
||||
test("current command flag validation messages are preserved", () => {
|
||||
const cases: Array<{ args: string[]; message: string }> = [
|
||||
{ args: ["health", "--json", "--all"], message: "health --json does not support --all" },
|
||||
{ args: ["quality", "--all"], message: "quality does not accept --all" },
|
||||
{ args: ["coverage", "--all"], message: "coverage does not accept --all" },
|
||||
{ args: ["disappearances", "--all"], message: "disappearances does not accept --all" },
|
||||
{ args: ["rejections", "--all"], message: "rejections does not accept --all" },
|
||||
{ args: ["audit", "--workspace", "/tmp/workspace"], message: "audit does not accept --all or --workspace" },
|
||||
{ args: ["explain", "--all"], message: "explain does not accept --all" },
|
||||
{ args: ["trace", "--all", "--memory", "mem-1"], message: "trace does not accept --all" },
|
||||
{ args: ["quality", "--since", "forever"], message: "quality does not accept rejection filters" },
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
const parsed = parseArgs(item.args);
|
||||
assert.equal(parsed.ok, false, item.args.join(" "));
|
||||
if (parsed.ok) continue;
|
||||
assert.equal(parsed.message, item.message);
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
}
|
||||
});
|
||||
|
||||
test("trace without memory returns current required id error", () => {
|
||||
const parsed = parseArgs(["trace"]);
|
||||
|
||||
assert.equal(parsed.ok, false);
|
||||
if (parsed.ok) return;
|
||||
assert.equal(parsed.message, "--memory requires an id");
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
});
|
||||
|
||||
test("health with all and workspace returns current conflict error", () => {
|
||||
const parsed = parseArgs(["health", "--all", "--workspace", "/tmp/workspace"]);
|
||||
|
||||
assert.equal(parsed.ok, false);
|
||||
if (parsed.ok) return;
|
||||
assert.equal(parsed.message, "Use either --all or --workspace, not both");
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
});
|
||||
|
||||
test("rejections invalid since value returns current error", () => {
|
||||
const parsed = parseArgs(["rejections", "--since", "forever"]);
|
||||
|
||||
assert.equal(parsed.ok, false);
|
||||
if (parsed.ok) return;
|
||||
assert.equal(parsed.message, "Invalid --since value: forever");
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
});
|
||||
|
||||
test("legacy health alias emits deprecation notice and maps to status", () => {
|
||||
const parsed = parseArgs(["health"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "status");
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "health");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'health' is now 'status'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("legacy quality alias sets verbose and emits deprecation notice", () => {
|
||||
const parsed = parseArgs(["quality"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "status");
|
||||
assert.equal("options" in parsed && parsed.options.verbose, true);
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "quality");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("legacy rejections alias emits deprecation notice", () => {
|
||||
const parsed = parseArgs(["rejections"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "rejected");
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "rejections");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("legacy disappearances alias emits deprecation notice", () => {
|
||||
const parsed = parseArgs(["disappearances"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "missing");
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "disappearances");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("legacy trace alias emits deprecation notice", () => {
|
||||
const parsed = parseArgs(["trace", "--memory", "mem-1"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "explain");
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "trace");
|
||||
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'trace --memory <id>' is now 'explain <memory-id>'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("explain accepts positional memory id", () => {
|
||||
const parsed = parseArgs(["explain", "mem-1", "--workspace", "/tmp/workspace"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "explain");
|
||||
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
|
||||
assert.deepEqual("options" in parsed && parsed.options.positional, ["mem-1"]);
|
||||
});
|
||||
|
||||
test("explain with both positional and memory flag errors", () => {
|
||||
const parsed = parseArgs(["explain", "mem-1", "--memory", "mem-2"]);
|
||||
|
||||
assert.equal(parsed.ok, false);
|
||||
if (parsed.ok) return;
|
||||
assert.equal(parsed.message, "Use either explain <memory-id> or --memory, not both");
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
|
||||
import type { MemoryInspectionReadModel, WorkspaceDiagSnapshot } from "../scripts/memory-diag/types.ts";
|
||||
import { formatWorkspaceHealth } from "../scripts/memory-diag/formatters/health.ts";
|
||||
import { formatQuality } from "../scripts/memory-diag/formatters/quality.ts";
|
||||
import { formatCoverage } from "../scripts/memory-diag/formatters/coverage.ts";
|
||||
import { formatDisappearances } from "../scripts/memory-diag/formatters/disappearances.ts";
|
||||
import { formatRejectionQuality } from "../scripts/memory-diag/formatters/rejections.ts";
|
||||
import { formatMigrationAudit } from "../scripts/memory-diag/formatters/audit.ts";
|
||||
import { formatExplain } from "../scripts/memory-diag/formatters/explain.ts";
|
||||
import { formatTrace } from "../scripts/memory-diag/formatters/trace.ts";
|
||||
import { rejectionQualitySummary } from "../scripts/memory-diag/rejections-model.ts";
|
||||
|
||||
function emptyInspectionModel(): MemoryInspectionReadModel {
|
||||
return {
|
||||
store: {
|
||||
version: 1,
|
||||
workspace: { root: "/tmp/workspace", key: "workspace-key" },
|
||||
limits: { maxRenderedChars: 24_000, maxEntries: 28 },
|
||||
entries: [],
|
||||
migrations: [],
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||
},
|
||||
pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() },
|
||||
evidenceEvents: [],
|
||||
rejectionRecords: [],
|
||||
currentById: new Map(),
|
||||
evidenceByMemoryId: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function emptySnapshot(): WorkspaceDiagSnapshot {
|
||||
return {
|
||||
store: emptyInspectionModel().store,
|
||||
journal: emptyInspectionModel().pending,
|
||||
retention: { sorted: [], rendered: [], typeCapped: [], globalCapped: [] },
|
||||
memories: [],
|
||||
recentEvents: [],
|
||||
allEvents: [],
|
||||
summary: {
|
||||
storedActive: 0,
|
||||
rendered: 0,
|
||||
pending: 0,
|
||||
rejectedLast7Days: 0,
|
||||
corruptStoresQuarantinedLast30Days: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 {
|
||||
return {
|
||||
version: 1,
|
||||
eventId: `evt-${overrides.type}`,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||
workspaceKey: "workspace-key",
|
||||
workspaceRootHash: "workspace-root-hash",
|
||||
reasonCodes: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("health formatter includes existing retention cap label", () => {
|
||||
const output = formatWorkspaceHealth({
|
||||
root: "/tmp/workspace",
|
||||
key: "workspace-key",
|
||||
memoryPath: "/tmp/workspace-memory.json",
|
||||
pendingPath: "/tmp/workspace-pending-journal.json",
|
||||
raw: false,
|
||||
now: new Date("2026-01-01T00:00:00.000Z").getTime(),
|
||||
includeTitle: true,
|
||||
}, { rawStore: null, rawJournal: null, pendingExists: false });
|
||||
|
||||
assert.match(output, /Workspace memory health/);
|
||||
assert.match(output, /Retention caps:/);
|
||||
});
|
||||
|
||||
test("quality formatter includes caps and retention clock sections", () => {
|
||||
const output = formatQuality(emptyInspectionModel(), new Date("2026-01-01T00:00:00.000Z").getTime());
|
||||
|
||||
assert.match(output, /Caps:/);
|
||||
assert.match(output, /Retention clocks:/);
|
||||
});
|
||||
|
||||
test("rejection quality formatter includes reason distribution sections", () => {
|
||||
const summary = rejectionQualitySummary([]);
|
||||
const output = formatRejectionQuality({ path: "/tmp/rejections.jsonl", invalidLines: 0, summary, raw: false });
|
||||
|
||||
assert.match(output, /Reason distribution \(raw records\):/);
|
||||
assert.match(output, /Reason distribution \(unique text\):/);
|
||||
});
|
||||
|
||||
test("coverage formatter includes class counts section", () => {
|
||||
const output = formatCoverage([]);
|
||||
|
||||
assert.match(output, /Class counts:/);
|
||||
assert.match(output, /Per-memory rows:\n \(none\)/);
|
||||
});
|
||||
|
||||
test("disappearances formatter preserves empty-state label", () => {
|
||||
const output = formatDisappearances([]);
|
||||
|
||||
assert.match(output, /No evidence-only memories found\./);
|
||||
});
|
||||
|
||||
test("trace formatter includes lifecycle section", () => {
|
||||
const output = formatTrace("mem-1", emptySnapshot(), {
|
||||
events: [event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-1", type: "feedback", source: "explicit" } })],
|
||||
});
|
||||
|
||||
assert.match(output, /Lifecycle:/);
|
||||
assert.match(output, /evt-render_selected render_selected/);
|
||||
});
|
||||
|
||||
test("audit formatter preserves no-log output", () => {
|
||||
const output = formatMigrationAudit([], { raw: false });
|
||||
|
||||
assert.match(output, /Migration audit report/);
|
||||
assert.match(output, /No migration logs found\./);
|
||||
});
|
||||
|
||||
test("explain formatter preserves no-memory output", () => {
|
||||
const output = formatExplain(emptySnapshot());
|
||||
|
||||
assert.match(output, /Workspace memory explainability/);
|
||||
assert.match(output, /No memories found\./);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { canonicalMemoryText, cleanText, countBy, sortedCounts, truncate } from "../scripts/memory-diag/text.ts";
|
||||
import { readJSONLFile } from "../scripts/memory-diag/io.ts";
|
||||
|
||||
test("cleanText redacts credentials and absolute paths unless raw", () => {
|
||||
const text = "Use password: sushi and api_key=abc123 in /Users/alice/project/config.json";
|
||||
|
||||
const cleaned = cleanText(text, false);
|
||||
|
||||
assert.match(cleaned, /password: \[REDACTED\]/);
|
||||
assert.match(cleaned, /api_key=\[REDACTED\]/);
|
||||
assert.match(cleaned, /<path>/);
|
||||
assert.doesNotMatch(cleaned, /sushi/);
|
||||
assert.doesNotMatch(cleaned, /\/Users\/alice/);
|
||||
assert.equal(cleanText(text, true), text);
|
||||
});
|
||||
|
||||
test("truncate collapses whitespace and applies ellipsis at max length", () => {
|
||||
assert.equal(truncate(" hello\n\tworld "), "hello world");
|
||||
assert.equal(truncate("abcdef", 5), "abcd…");
|
||||
});
|
||||
|
||||
test("canonicalMemoryText normalizes punctuation and case", () => {
|
||||
assert.equal(canonicalMemoryText("Hello, WORLD!!! Path?"), "hello world path");
|
||||
});
|
||||
|
||||
test("sortedCounts sorts by count descending then key ascending", () => {
|
||||
const counts = new Map<string, number>([["b", 2], ["a", 2], ["c", 3]]);
|
||||
|
||||
assert.deepEqual(sortedCounts(counts), [["c", 3], ["a", 2], ["b", 2]]);
|
||||
});
|
||||
|
||||
test("countBy counts string items", () => {
|
||||
assert.deepEqual([...countBy(["beta", "alpha", "beta"]).entries()], [["beta", 2], ["alpha", 1]]);
|
||||
});
|
||||
|
||||
test("readJSONLFile returns valid records and invalid line count", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-utils-"));
|
||||
try {
|
||||
const path = join(root, "records.jsonl");
|
||||
await writeFile(path, '{"id":"one"}\nnot-json\n\n{"id":"two"}\n', "utf8");
|
||||
|
||||
const result = await readJSONLFile<{ id: string }>(path);
|
||||
|
||||
assert.deepEqual(result.records, [{ id: "one" }, { id: "two" }]);
|
||||
assert.equal(result.invalidLines, 1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { appendEvidenceEvents, queryEvidenceEvents, summarizeMemoryEvidence, type EvidenceEventInput, type EvidenceEventV1 } from "../src/evidence-log.ts";
|
||||
import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts";
|
||||
import { retentionCandidatesForDiag } from "../scripts/memory-diag/retention-model.ts";
|
||||
import { buildMemoryDiagJSON, memoryDiagJSONFromSnapshot, normalizedJournal, normalizedStore, snapshotForOptions } from "../scripts/memory-diag/workspace-snapshot.ts";
|
||||
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
import { LONG_TERM_LIMITS, type LongTermMemoryEntry, type PendingMemoryJournalStore, type WorkspaceMemoryStore } from "../src/types.ts";
|
||||
|
||||
function entry(id: string, text: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
|
||||
const now = new Date("2026-01-01T00:00:00.000Z").toISOString();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
text,
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
retentionClock: new Date(now).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
|
||||
return {
|
||||
type: "promotion_promoted",
|
||||
phase: "promotion",
|
||||
outcome: "promoted",
|
||||
memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" },
|
||||
reasonCodes: ["new_workspace_entry"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function groupedEvidenceSummary(grouped: Map<string, EvidenceEventV1[]>, memoryId: string): { eventIds: string[]; reasonCodes: string[] } {
|
||||
const events = grouped.get(memoryId) ?? [];
|
||||
const reasonCodes = new Set<string>();
|
||||
for (const event of events) {
|
||||
for (const reason of event.reasonCodes) reasonCodes.add(reason);
|
||||
}
|
||||
return {
|
||||
eventIds: events.map(event => event.eventId),
|
||||
reasonCodes: [...reasonCodes],
|
||||
};
|
||||
}
|
||||
|
||||
async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
|
||||
const key = await workspaceKey(root);
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries,
|
||||
migrations: [],
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||
};
|
||||
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
|
||||
const key = await workspaceKey(root);
|
||||
const path = await workspacePendingJournalPath(root);
|
||||
const store: PendingMemoryJournalStore = {
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
entries,
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||
};
|
||||
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
|
||||
}
|
||||
|
||||
test("normalizedStore returns an empty store with limits and empty arrays", () => {
|
||||
const store = normalizedStore(null, "/tmp/example-workspace", "workspace-key");
|
||||
|
||||
assert.equal(store.version, 1);
|
||||
assert.deepEqual(store.workspace, { root: "/tmp/example-workspace", key: "workspace-key" });
|
||||
assert.deepEqual(store.limits, { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries });
|
||||
assert.deepEqual(store.entries, []);
|
||||
assert.deepEqual(store.migrations, []);
|
||||
});
|
||||
|
||||
test("normalizedJournal returns an empty journal", () => {
|
||||
const journal = normalizedJournal(null);
|
||||
|
||||
assert.equal(journal.version, 1);
|
||||
assert.deepEqual(journal.workspace, { root: "", key: "" });
|
||||
assert.deepEqual(journal.entries, []);
|
||||
assert.equal(journal.updatedAt, new Date(0).toISOString());
|
||||
});
|
||||
|
||||
test("retentionCandidatesForDiag separates rendered, type-capped, and global-capped entries", () => {
|
||||
const entries: LongTermMemoryEntry[] = [
|
||||
...Array.from({ length: 11 }, (_, i) => entry(`feedback-${String(i).padStart(2, "0")}`, `Feedback memory ${i}`, "feedback")),
|
||||
...Array.from({ length: 10 }, (_, i) => entry(`decision-${String(i).padStart(2, "0")}`, `Decision memory ${i}`, "decision")),
|
||||
...Array.from({ length: 8 }, (_, i) => entry(`project-${String(i).padStart(2, "0")}`, `Project memory ${i}`, "project")),
|
||||
...Array.from({ length: 6 }, (_, i) => entry(`reference-${String(i).padStart(2, "0")}`, `Reference memory ${i}`, "reference")),
|
||||
];
|
||||
const store = normalizedStore({ entries, workspace: { root: "/tmp/root", key: "key" } } as WorkspaceMemoryStore, "/tmp/root", "key");
|
||||
|
||||
const candidates = retentionCandidatesForDiag(store, new Date("2026-01-02T00:00:00.000Z").getTime());
|
||||
|
||||
assert.equal(candidates.rendered.length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(candidates.typeCapped.length, 1);
|
||||
assert.equal(candidates.globalCapped.length, 6);
|
||||
assert.equal(candidates.typeCapped[0].entry.type, "feedback");
|
||||
});
|
||||
|
||||
test("buildMemoryDiagJSON redacts previews, includes pending entries, and preserves summary fields", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-workspace-model-"));
|
||||
try {
|
||||
const active = entry("mem-active", "Remember password: sushi and file /Users/alice/private.txt", "decision");
|
||||
const pending = { ...entry("mem-pending", "Pending api_key=secret-value", "project"), promotionAttempts: 1 };
|
||||
await writeWorkspaceStore(root, [active]);
|
||||
await writePendingJournal(root, [pending]);
|
||||
|
||||
const diag = await buildMemoryDiagJSON(root);
|
||||
|
||||
assert.equal(diag.version, 1);
|
||||
assert.equal(diag.summary.storedActive, 1);
|
||||
assert.equal(diag.summary.rendered, 1);
|
||||
assert.equal(diag.summary.pending, 1);
|
||||
assert.equal(diag.summary.rejectedLast7Days, 0);
|
||||
assert.equal(diag.summary.corruptStoresQuarantinedLast30Days, 0);
|
||||
assert.equal(diag.memories.length, 2);
|
||||
assert.equal(diag.memories.find(memory => memory.id === "mem-pending")?.status, "pending_retry");
|
||||
assert.ok(diag.memories.some(memory => memory.textPreview?.includes("[REDACTED]")));
|
||||
assert.ok(diag.memories.some(memory => memory.textPreview?.includes("<path>")));
|
||||
assert.ok(!JSON.stringify(diag).includes("sushi"));
|
||||
assert.ok(!JSON.stringify(diag).includes("secret-value"));
|
||||
assert.equal(diag.workspace.key, await workspaceKey(root));
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memoryDiagJSONFromSnapshot serializes an existing snapshot with fixed generatedAt", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-snapshot-json-"));
|
||||
try {
|
||||
const active = entry("mem-active", "Stable decision memory", "decision");
|
||||
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
|
||||
await writeWorkspaceStore(root, [active]);
|
||||
await writePendingJournal(root, [pending]);
|
||||
|
||||
const snapshot = await snapshotForOptions({ raw: false, workspace: root });
|
||||
const generatedAt = "2026-05-02T00:00:00.000Z";
|
||||
const diag = memoryDiagJSONFromSnapshot(root, snapshot, generatedAt);
|
||||
|
||||
assert.equal(diag.version, 1);
|
||||
assert.equal(diag.generatedAt, generatedAt);
|
||||
assert.equal(diag.memories, snapshot.memories);
|
||||
assert.equal(diag.recentEvents, snapshot.recentEvents);
|
||||
assert.equal(diag.summary, snapshot.summary);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("grouped evidence summaries match per-memory summaries for stored pending and absorbed memories", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-equivalence-"));
|
||||
try {
|
||||
const active = entry("mem-active", "Stable decision memory", "decision");
|
||||
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
|
||||
await writeWorkspaceStore(root, [active]);
|
||||
await writePendingJournal(root, [pending]);
|
||||
await appendEvidenceEvents(root, [
|
||||
evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }),
|
||||
evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }),
|
||||
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }),
|
||||
]);
|
||||
|
||||
const grouped = groupEvidenceByMemoryId(await queryEvidenceEvents(root));
|
||||
for (const id of ["mem-active", "mem-pending", "mem-absorbed"]) {
|
||||
const oldSummary = await summarizeMemoryEvidence(root, { memoryId: id });
|
||||
const groupedSummary = groupedEvidenceSummary(grouped, id);
|
||||
|
||||
assert.deepEqual(groupedSummary.eventIds, oldSummary.eventIds);
|
||||
assert.deepEqual(groupedSummary.reasonCodes, oldSummary.reasonCodes);
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("buildMemoryDiagJSON preserves evidence ids and reason codes for stored pending and absorbed memories", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-rows-"));
|
||||
try {
|
||||
const active = entry("mem-active", "Stable decision memory", "decision");
|
||||
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
|
||||
await writeWorkspaceStore(root, [active]);
|
||||
await writePendingJournal(root, [pending]);
|
||||
const events = await appendEvidenceEvents(root, [
|
||||
evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }),
|
||||
evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }),
|
||||
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }),
|
||||
]);
|
||||
|
||||
const diag = await buildMemoryDiagJSON(root);
|
||||
const activeRow = diag.memories.find(memory => memory.id === "mem-active");
|
||||
const pendingRow = diag.memories.find(memory => memory.id === "mem-pending");
|
||||
const absorbedRow = diag.memories.find(memory => memory.id === "mem-absorbed");
|
||||
|
||||
assert.ok(activeRow);
|
||||
assert.ok(pendingRow);
|
||||
assert.ok(absorbedRow);
|
||||
assert.deepEqual(activeRow.evidenceEventIds, [events[0].eventId]);
|
||||
assert.ok(activeRow.reasonCodes.includes("stored_reason"));
|
||||
assert.deepEqual(pendingRow.evidenceEventIds, [events[1].eventId]);
|
||||
assert.ok(pendingRow.reasonCodes.includes("pending_reason"));
|
||||
assert.deepEqual(absorbedRow.evidenceEventIds, [events[2].eventId]);
|
||||
assert.ok(absorbedRow.reasonCodes.includes("same_exact_key"));
|
||||
assert.ok(absorbedRow.reasonCodes.includes("absorbed_duplicate"));
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+363
-9
@@ -1,6 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -9,7 +10,7 @@ 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, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } from "../src/types.ts";
|
||||
import { extractionRejectionLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
import { extractionRejectionLogPath, workspaceEvidenceLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -51,13 +52,18 @@ async function runMemoryDiagHealth(root: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function runMemoryDiag(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync(process.execPath, [
|
||||
const { stdout } = await runMemoryDiagResult(args);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function runMemoryDiagResult(args: string[], options: { cwd?: string } = {}): Promise<{ stdout: string; stderr: string }> {
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [
|
||||
"--experimental-strip-types",
|
||||
"scripts/memory-diag.ts",
|
||||
...args,
|
||||
], { cwd: repoRoot });
|
||||
], { cwd: options.cwd ?? repoRoot });
|
||||
|
||||
return stdout;
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
||||
}
|
||||
|
||||
async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
|
||||
@@ -90,6 +96,120 @@ function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
|
||||
};
|
||||
}
|
||||
|
||||
test("health handles missing workspace store as empty", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-"));
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["health", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /memory store: missing or unreadable \(treated as empty\)/);
|
||||
assert.match(stdout, /pending journal: missing \(treated as empty\)/);
|
||||
assert.match(stdout, /Stored active memories: 0/);
|
||||
assert.match(stdout, /Pending journal:\n\s+total: 0/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quality handles missing workspace store as empty", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-quality-"));
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["quality", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /Memory quality inspection/);
|
||||
assert.match(stdout, /0 active memories/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("coverage and disappearances handle missing workspace store as empty", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-inspection-"));
|
||||
try {
|
||||
const coverageStdout = await runMemoryDiag(["coverage", "--workspace", root]);
|
||||
assert.match(coverageStdout, /no_evidence: 0/);
|
||||
assert.match(coverageStdout, /Per-memory rows:\n\s+\(none\)/);
|
||||
|
||||
const missingStdout = await runMemoryDiag(["missing", "--workspace", root]);
|
||||
assert.match(missingStdout, /No missing memories found\./);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("health with conflicting flags shows usage error", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-conflicting-flags-"));
|
||||
try {
|
||||
await assert.rejects(
|
||||
runMemoryDiagResult(["health", "--all", "--workspace", root]),
|
||||
(error: unknown) => {
|
||||
const err = error as { code?: number; stderr?: string };
|
||||
assert.notEqual(err.code, 0);
|
||||
assert.match(err.stderr ?? "", /Use either --all or --workspace, not both/);
|
||||
assert.match(err.stderr ?? "", /Usage:/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag reports unexpected command errors without stack traces", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-error-boundary-"));
|
||||
try {
|
||||
await writeWorkspaceStore(root, []);
|
||||
// Invalid rejection filter values are parse-caught before dispatch, so this
|
||||
// fixture exercises the same top-level boundary with a command-thrown Error.
|
||||
await mkdir(await workspaceEvidenceLogPath(root), { recursive: true });
|
||||
|
||||
await assert.rejects(
|
||||
runMemoryDiagResult(["status", "--workspace", root]),
|
||||
(error: unknown) => {
|
||||
const err = error as { code?: number; stderr?: string };
|
||||
assert.notEqual(err.code, 0);
|
||||
assert.match(err.stderr ?? "", /memory-diag failed:/);
|
||||
assert.doesNotMatch(err.stderr ?? "", /\n\s+at\s|Error:/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag defaults to status when no subcommand is supplied", async () => {
|
||||
const result = await runMemoryDiagResult([]);
|
||||
|
||||
assert.match(result.stdout, /Memory status/);
|
||||
assert.match(result.stdout, /Key metrics:/);
|
||||
assert.equal(result.stderr, "");
|
||||
});
|
||||
|
||||
test("legacy health alias emits deprecation notice and still runs", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-"));
|
||||
try {
|
||||
const result = await runMemoryDiagResult(["health", "--workspace", root]);
|
||||
|
||||
assert.match(result.stdout, /Workspace memory health/);
|
||||
assert.match(result.stderr, /Note: 'health' is now 'status'\. This alias will be removed in v2\.0\./);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy trace alias emits deprecation notice and still traces memory", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-trace-"));
|
||||
try {
|
||||
const result = await runMemoryDiagResult(["trace", "--memory", "test-id", "--workspace", root]);
|
||||
|
||||
assert.match(result.stdout, /Memory test-id: unknown/);
|
||||
assert.match(result.stdout, /Lifecycle:/);
|
||||
assert.match(result.stderr, /Note: 'trace --memory <id>' is now 'explain <memory-id>'\. This alias will be removed in v2\.0\./);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory health reports stored vs rendered retention counts", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
|
||||
try {
|
||||
@@ -295,6 +415,30 @@ test("memory-diag trace prints lifecycle relations and redacts secrets", async (
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag explain positional memory id prints lifecycle", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-explain-positional-"));
|
||||
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: "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"] }),
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["explain", "mem-life", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /Memory mem-life: omitted_superseded/);
|
||||
assert.match(stdout, /Lifecycle:/);
|
||||
assert.match(stdout, /promotion_superseded: superseded; reasons=superseded_existing; .*superseded_by=mem-new/);
|
||||
assert.match(stdout, /Superseded by:\n- mem-new/);
|
||||
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 {
|
||||
@@ -380,6 +524,94 @@ test("quality --json includes summaryText, caps, retention, evidence, and reject
|
||||
}
|
||||
});
|
||||
|
||||
test("status default output is concise and actionable", async () => {
|
||||
const root = await setupQualityFixture();
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["status", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /OK Memory status|WARNING Memory status|DEGRADED Memory status/);
|
||||
assert.match(stdout, /Key metrics:/);
|
||||
assert.match(stdout, /active memories:/);
|
||||
assert.match(stdout, /rendered:/);
|
||||
assert.match(stdout, /pending:/);
|
||||
assert.match(stdout, /rejected 7d:/);
|
||||
assert.match(stdout, /evidence coverage:/);
|
||||
assert.match(stdout, /Needs attention:/);
|
||||
assert.match(stdout, /Suggested next steps:/);
|
||||
assert.doesNotMatch(stdout, /Caps:|Retention clocks:|Rejection scoping:|Dormancy:/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status --verbose includes detailed diagnostics", async () => {
|
||||
const root = await setupQualityFixture();
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
|
||||
|
||||
assert.match(stdout, /Memory status inspection/);
|
||||
assert.match(stdout, /Caps:/);
|
||||
assert.match(stdout, /Retention clocks:/);
|
||||
assert.match(stdout, /Evidence:/);
|
||||
assert.match(stdout, /Rejection scoping:/);
|
||||
assert.match(stdout, /Dormancy:/);
|
||||
assert.match(stdout, /Top rendered candidates:/);
|
||||
assert.match(stdout, /Weakest active memories:/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status --json includes additive summary fields", async () => {
|
||||
const root = await setupQualityFixture();
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]);
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
version: 1;
|
||||
summary: {
|
||||
storedActive: number;
|
||||
rendered: number;
|
||||
pending: number;
|
||||
rejectedLast7Days: number;
|
||||
corruptStoresQuarantinedLast30Days: number;
|
||||
status?: string;
|
||||
evidenceCoveragePercent?: number;
|
||||
needsAttention?: string[];
|
||||
suggestedNextSteps?: string[];
|
||||
};
|
||||
memories: unknown[];
|
||||
recentEvents: unknown[];
|
||||
};
|
||||
|
||||
assert.equal(parsed.version, 1);
|
||||
assert.equal(typeof parsed.summary.storedActive, "number");
|
||||
assert.equal(typeof parsed.summary.rendered, "number");
|
||||
assert.equal(typeof parsed.summary.pending, "number");
|
||||
assert.equal(typeof parsed.summary.rejectedLast7Days, "number");
|
||||
assert.equal(typeof parsed.summary.corruptStoresQuarantinedLast30Days, "number");
|
||||
assert.match(parsed.summary.status ?? "", /ok|warning|degraded/);
|
||||
assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number");
|
||||
assert.ok(Array.isArray(parsed.summary.needsAttention));
|
||||
assert.ok(Array.isArray(parsed.summary.suggestedNextSteps));
|
||||
assert.ok(Array.isArray(parsed.memories));
|
||||
assert.ok(Array.isArray(parsed.recentEvents));
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status non-tty and --no-emoji use text labels", async () => {
|
||||
const root = await setupQualityFixture();
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["status", "--workspace", root, "--no-emoji"]);
|
||||
|
||||
assert.match(stdout, /^DEGRADED Memory status/);
|
||||
assert.doesNotMatch(stdout, /🧠|⚠️|✖️/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("coverage human output includes class counts and per-memory rows", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-coverage-"));
|
||||
try {
|
||||
@@ -428,7 +660,7 @@ test("coverage --json includes event counts", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("disappearances labels historical evidence-only memory unknown without terminal event", async () => {
|
||||
test("missing default output summarizes unknown disappearances", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-"));
|
||||
try {
|
||||
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
||||
@@ -436,15 +668,21 @@ test("disappearances labels historical evidence-only memory unknown without term
|
||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["disappearances", "--workspace", root]);
|
||||
const stdout = await runMemoryDiag(["missing", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/);
|
||||
assert.match(stdout, /Missing memory summary/);
|
||||
assert.match(stdout, /Total missing: 1/);
|
||||
assert.match(stdout, /Explained: 0/);
|
||||
assert.match(stdout, /Needs review: 1/);
|
||||
assert.match(stdout, /Unknown disappearance samples:/);
|
||||
assert.match(stdout, /- historical-unknown terminal=unknown reasons=none/);
|
||||
assert.doesNotMatch(stdout, /Memory historical-unknown:/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("disappearances --explain shows capacity details and render omitted type-cap observations", async () => {
|
||||
test("missing --verbose shows capacity details and render omitted type-cap observations", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-explain-"));
|
||||
try {
|
||||
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
||||
@@ -461,9 +699,11 @@ test("disappearances --explain shows capacity details and render omitted type-ca
|
||||
evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "render-loser", type: "feedback", source: "compaction" }, reasonCodes: ["type_cap"] }),
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["disappearances", "--workspace", root, "--explain"]);
|
||||
const stdout = await runMemoryDiag(["missing", "--workspace", root, "--verbose"]);
|
||||
|
||||
assert.match(stdout, /Missing memory summary/);
|
||||
assert.match(stdout, /capacity-loser: historical_absent_with_reason terminal=memory_removed_capacity reasons=type_cap/);
|
||||
assert.match(stdout, /events:/);
|
||||
assert.match(stdout, /memory_removed_capacity details: .*globalCap=28 .*typeCap=10/);
|
||||
assert.match(stdout, /render-loser: historical_absent_with_reason terminal=render_omitted reasons=type_cap/);
|
||||
assert.match(stdout, /render_omitted type-cap observation: reasons=type_cap/);
|
||||
@@ -472,6 +712,117 @@ test("disappearances --explain shows capacity details and render omitted type-ca
|
||||
}
|
||||
});
|
||||
|
||||
test("missing --json includes disappearances and additive summary", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-missing-json-"));
|
||||
try {
|
||||
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
||||
await appendEvidenceEvents(root, [
|
||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["missing", "--workspace", root, "--json"]);
|
||||
const parsed = JSON.parse(stdout) as { disappearances: Array<{ id: string }>; summary: { total: number; explained: number; needsReview: number } };
|
||||
|
||||
assert.equal(parsed.summary.total, 1);
|
||||
assert.equal(parsed.summary.explained, 0);
|
||||
assert.equal(parsed.summary.needsReview, 1);
|
||||
assert.equal(parsed.disappearances[0]?.id, "historical-unknown");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy disappearances --explain emits deprecation notice and detailed output", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-legacy-"));
|
||||
try {
|
||||
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
||||
await appendEvidenceEvents(root, [
|
||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
||||
]);
|
||||
|
||||
const result = await runMemoryDiagResult(["disappearances", "--workspace", root, "--explain"]);
|
||||
|
||||
assert.match(result.stderr, /Note: 'disappearances' is now 'missing'\. This alias will be removed in v2\.0\./);
|
||||
assert.match(result.stdout, /^Memory disappearances/);
|
||||
assert.match(result.stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/);
|
||||
assert.match(result.stdout, /events:/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejected default output is concise with top reasons and samples", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-"));
|
||||
try {
|
||||
const key = await workspaceKey(root);
|
||||
const now = new Date().toISOString();
|
||||
await writeRejectionRecords([
|
||||
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Keep memory system boundary stable", reasons: ["bad_decision"] },
|
||||
{ timestamp: now, workspaceKey: key, type: "feedback", source: "explicit", text: "Remember password: sushi from rejected sample", reasons: ["raw_secret", "bad_feedback"] },
|
||||
{ timestamp: "2020-01-01T00:00:00.000Z", workspaceKey: key, type: "project", source: "manual", text: "Old rejected sample", reasons: ["bad_project"] },
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["rejected", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /Rejected memory summary/);
|
||||
assert.match(stdout, /Total rejected: 3/);
|
||||
assert.match(stdout, /Unique texts: 3/);
|
||||
assert.match(stdout, /Top reasons:/);
|
||||
assert.match(stdout, /bad_decision\s+1/);
|
||||
assert.match(stdout, /False-positive risk: low/);
|
||||
assert.match(stdout, /Recent samples:/);
|
||||
assert.match(stdout, /\[decision\] Keep memory system boundary stable/);
|
||||
assert.ok(!stdout.includes("sushi"));
|
||||
assert.doesNotMatch(stdout, /By origin:|Reason distribution \(raw records\):|Possible false-positive groups/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejected --verbose includes detailed distributions", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-verbose-"));
|
||||
try {
|
||||
const key = await workspaceKey(root);
|
||||
const now = new Date().toISOString();
|
||||
await writeRejectionRecords([
|
||||
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
|
||||
{ timestamp: now, workspaceKey: key, type: "feedback", source: "explicit", text: "Status update completed", reasons: ["bad_feedback"] },
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["rejected", "--workspace", root, "--verbose"]);
|
||||
|
||||
assert.match(stdout, /Rejected memory summary/);
|
||||
assert.match(stdout, /Reason distribution \(raw records\):/);
|
||||
assert.match(stdout, /Reason distribution \(unique text\):/);
|
||||
assert.match(stdout, /By origin:/);
|
||||
assert.match(stdout, /Possible false-positive groups \(heuristic, not deterministic\):/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejected --json includes quality summary and false-positive risk", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-json-"));
|
||||
try {
|
||||
const key = await workspaceKey(root);
|
||||
const now = new Date().toISOString();
|
||||
await writeRejectionRecords([
|
||||
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
|
||||
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["rejected", "--workspace", root, "--json"]);
|
||||
const parsed = JSON.parse(stdout) as { totalRecords: number; uniqueTexts: number; falsePositiveRisk: string; possibleFalsePositiveGroups: Record<string, { count: number }> };
|
||||
|
||||
assert.equal(parsed.totalRecords, 2);
|
||||
assert.equal(parsed.uniqueTexts, 1);
|
||||
assert.equal(parsed.falsePositiveRisk, "high");
|
||||
assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejections --quality --reason bad_decision --unique groups architecture-like samples heuristically", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-quality-"));
|
||||
try {
|
||||
@@ -485,6 +836,7 @@ test("rejections --quality --reason bad_decision --unique groups architecture-li
|
||||
|
||||
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--reason", "bad_decision", "--unique"]);
|
||||
|
||||
assert.match(stdout, /Extraction rejection quality inspection/);
|
||||
assert.match(stdout, /Possible false-positive grouping is heuristic, not deterministic truth/);
|
||||
assert.match(stdout, /architecture_like_possible_false_positive: 1/);
|
||||
assert.match(stdout, /clearly_garbage: 1/);
|
||||
@@ -509,12 +861,14 @@ test("rejections --quality --json includes scoping, unique reasons, and possible
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
workspaceScopedCount: number;
|
||||
legacyUnscopedCount: number;
|
||||
falsePositiveRisk: string;
|
||||
uniqueReasonDistribution: Record<string, number>;
|
||||
possibleFalsePositiveGroups: Record<string, { count: number }>;
|
||||
};
|
||||
|
||||
assert.equal(parsed.workspaceScopedCount, 1);
|
||||
assert.equal(parsed.legacyUnscopedCount, 1);
|
||||
assert.equal(parsed.falsePositiveRisk, "high");
|
||||
assert.equal(parsed.uniqueReasonDistribution.bad_decision, 1);
|
||||
assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user