refactor(memory-diag): remove legacy aliases, centralize command metadata, prepare v1.5.4

- Remove legacy CLI aliases (health, quality, rejections, disappearances, trace)
- Centralize command metadata in command-metadata.ts
- Move trace lifecycle into explain command
- Move disappearance helpers into missing formatter
- Remove cleanup:workspaces from package scripts (dev tool preserved)
- Bump version to 1.5.4
This commit is contained in:
Ralph Chang
2026-05-02 21:57:13 +08:00
parent 84aa020774
commit 2918645d8a
22 changed files with 240 additions and 937 deletions
+13
View File
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.4] - 2026-05-02
### Changed
- Centralized the supported `memory-diag` command metadata for the official public commands: `status`, `rejected`, `missing`, and `explain`.
- Removed pre-public legacy aliases so unsupported spellings now use the standard unknown-subcommand path instead of carrying v2.0 compatibility debt.
- Kept `coverage` and `audit` as hidden maintainer diagnostics with neutral maintainer-only notices.
- Removed public npm script exposure for the internal workspace cleanup tool while keeping the development tool in the repository.
### Fixed
- Moved missing-memory disappearance detail formatting into the official `missing` formatter before deleting legacy disappearance formatter code.
## [1.5.3] - 2026-05-02
### Added
+21
View File
@@ -1,5 +1,26 @@
# Release Notes
## 1.5.4 (2026-05-02)
### Memory Diagnostics Surface Cleanup
This cleanup release keeps the newly published `memory-diag` CLI small before legacy spellings become compatibility debt.
### What Changed
- **Official commands only**: the public CLI surface is `status`, `rejected`, `missing`, and `explain`.
- **Pre-public aliases removed**: old spellings such as `health`, `quality`, `rejections`, `disappearances`, and `trace` are no longer recognized.
- **Maintainer diagnostics clarified**: hidden `coverage` and `audit` commands remain available as maintainer-only diagnostics and stay out of public usage output.
- **Cleaner internals**: current command metadata now has one source of truth, and legacy command/formatter wrappers were removed.
- **Internal cleanup script hidden**: the workspace cleanup helper is no longer exposed as an npm script; the underlying development tool remains in `scripts/dev/`.
### Validation
- `npm run typecheck``TYPECHECK_PASS`
- `npm test` — 347 tests passing, `TEST_PASS`
---
## 1.5.3 (2026-05-02)
### Published Memory Diagnostics CLI
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-working-memory",
"version": "1.5.3",
"version": "1.5.4",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
@@ -24,7 +24,6 @@
"diag": "node --experimental-strip-types scripts/memory-diag.ts",
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
},
"keywords": [
+15 -66
View File
@@ -1,4 +1,5 @@
import type { CliOptions, Command, LegacyCommand, ParsedArgs } from "./types.ts";
import { HIDDEN_COMMAND_NOTICES, isCommand } from "./command-metadata.ts";
import type { CliOptions, Command, ParsedArgs } from "./types.ts";
export type { ParsedArgs } from "./types.ts";
@@ -21,23 +22,6 @@ function error(message: string): ParsedArgs {
return { ok: false, message, usage: usage(), exitCode: 1 };
}
function isCommand(value: string | undefined): value is Command {
return value === "status"
|| value === "rejected"
|| value === "missing"
|| value === "explain"
|| value === "coverage"
|| value === "audit";
}
function isLegacyCommand(value: string | undefined): value is LegacyCommand {
return value === "health"
|| value === "quality"
|| value === "rejections"
|| value === "disappearances"
|| value === "trace";
}
function isValidSince(rawSince: string): boolean {
if (/^(\d+)([dhm])$/i.test(rawSince)) return true;
return !Number.isNaN(new Date(rawSince).getTime());
@@ -50,44 +34,19 @@ export function parseArgs(argv: string[]): ParsedArgs {
}
let command: Command = "status";
let legacyCommand: LegacyCommand | undefined;
let deprecationNotice: string | undefined;
let rest = argv;
if (first && !first.startsWith("--")) {
rest = tail;
if (isCommand(first)) {
command = first;
if (first === "coverage") {
deprecationNotice = "Note: 'coverage' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit evidence'.";
} else if (first === "audit") {
deprecationNotice = "Note: 'audit' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit migrations'.";
}
} else if (isLegacyCommand(first)) {
legacyCommand = first;
if (first === "health") {
command = "status";
deprecationNotice = "Note: 'health' is now 'status'. This alias will be removed in v2.0.";
} else if (first === "quality") {
command = "status";
deprecationNotice = "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0.";
} else if (first === "rejections") {
command = "rejected";
deprecationNotice = "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0.";
} else if (first === "disappearances") {
command = "missing";
deprecationNotice = "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0.";
} else {
command = "explain";
deprecationNotice = "Note: 'trace --memory <id>' is now 'explain <memory-id>'. This alias will be removed in v2.0.";
}
deprecationNotice = HIDDEN_COMMAND_NOTICES[first];
} else {
return error(`Unknown subcommand: ${first}`);
}
}
const options: CliOptions = { raw: false, positional: [] };
if (legacyCommand) options.legacyCommand = legacyCommand;
if (legacyCommand === "quality") options.verbose = true;
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (arg === "--raw") options.raw = true;
@@ -98,8 +57,6 @@ export function parseArgs(argv: string[]): ParsedArgs {
else if (arg === "--soft-only") options.softOnly = true;
else if (arg === "--trigger-only") options.triggerOnly = true;
else if (arg === "--include-historical") options.includeHistorical = true;
else if (arg === "--quality") options.quality = true;
else if (arg === "--unique") options.unique = true;
else if (arg === "--explain") options.explain = true;
else if (arg === "--workspace") {
const value = rest[++i];
@@ -121,14 +78,13 @@ export function parseArgs(argv: string[]): ParsedArgs {
const value = rest[++i];
if (!value) return error("--memory requires an id");
options.memory = value;
} else if (!arg.startsWith("--") && command === "explain" && legacyCommand !== "trace") {
} else if (!arg.startsWith("--") && command === "explain") {
options.positional?.push(arg);
} else {
return error(`Unknown option: ${arg}`);
}
}
const displayCommand = legacyCommand ?? command;
if (command === "explain") {
const positional = options.positional ?? [];
if (positional.length > 1) return error("explain accepts at most one memory id");
@@ -138,34 +94,27 @@ export function parseArgs(argv: string[]): ParsedArgs {
return error(`Unknown option: ${options.positional?.[0]}`);
}
if (legacyCommand === "health") {
if (options.all && options.workspace) return error("Use either --all or --workspace, not both");
if (options.json && options.all) return error("health --json does not support --all");
} else if (command === "status") {
if (options.all) return error(`${displayCommand} does not accept --all`);
if (command === "status") {
if (options.all) return error(`${command} does not accept --all`);
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain") {
if (options.all) return error(`${displayCommand} does not accept --all`);
if (options.all) return error(`${command} does not accept --all`);
} else {
if (options.all || options.workspace) return error(`${displayCommand} does not accept --all or --workspace`);
if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`);
}
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage") {
return error(`${displayCommand} does not accept --json`);
return error(`${command} does not accept --json`);
}
if (legacyCommand === "rejections" && options.json && !options.quality) return error("rejections --json requires --quality");
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
return error(`${displayCommand} does not accept rejection filters`);
return error(`${command} does not accept rejection filters`);
}
if (command !== "coverage" && options.includeHistorical) return error(`${displayCommand} does not accept --include-historical`);
if (command !== "rejected" && (options.quality || options.reason || options.unique)) return error(`${displayCommand} does not accept rejection quality filters`);
if (command !== "missing" && options.explain) return error(`${displayCommand} does not accept --explain`);
if (command !== "coverage" && options.includeHistorical) return error(`${command} does not accept --include-historical`);
if (command !== "rejected" && options.reason) return error(`${command} does not accept rejection filters`);
if (command !== "missing" && options.explain) return error(`${command} does not accept --explain`);
if (command !== "audit" && options.migration) {
return error(`${displayCommand} does not accept --migration`);
}
if (legacyCommand === "trace" && !options.memory) {
return error("--memory requires an id");
return error(`${command} does not accept --migration`);
}
if (command !== "explain" && options.memory) {
return error(`${displayCommand} does not accept --memory`);
return error(`${command} does not accept --memory`);
}
if (command === "rejected" && options.since && !isValidSince(options.since)) {
return error(`Invalid --since value: ${options.since}`);
+16
View File
@@ -0,0 +1,16 @@
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain"] as const;
export const HIDDEN_COMMANDS = ["coverage", "audit"] as const;
export type VisibleCommand = typeof VISIBLE_COMMANDS[number];
export type HiddenCommand = typeof HIDDEN_COMMANDS[number];
export type Command = VisibleCommand | HiddenCommand;
export const HIDDEN_COMMAND_NOTICES: Partial<Record<Command, string>> = {
coverage: "Note: 'coverage' is a maintainer-only diagnostic and is not part of the public CLI surface.",
audit: "Note: 'audit' is a maintainer-only diagnostic and is not part of the public CLI surface.",
};
export function isCommand(value: string | undefined): value is Command {
return value !== undefined
&& ([...VISIBLE_COMMANDS, ...HIDDEN_COMMANDS] as readonly string[]).includes(value);
}
-11
View File
@@ -1,23 +1,12 @@
import { runAudit } from "./commands/audit.ts";
import { runCoverage } from "./commands/coverage.ts";
import { runDisappearances } from "./commands/disappearances.ts";
import { runExplain } from "./commands/explain.ts";
import { runHealth } from "./commands/health.ts";
import { runMissing } from "./commands/missing.ts";
import { runQuality } from "./commands/quality.ts";
import { runRejected } from "./commands/rejected.ts";
import { runRejections } from "./commands/rejections.ts";
import { runStatus } from "./commands/status.ts";
import { runTrace } from "./commands/trace.ts";
import type { CliOptions, Command, CommandResult } from "./types.ts";
export async function dispatch(command: Command, options: CliOptions): Promise<CommandResult> {
if (options.legacyCommand === "health") return runHealth(options);
if (options.legacyCommand === "quality") return runQuality(options);
if (options.legacyCommand === "rejections") return runRejections(options);
if (options.legacyCommand === "disappearances") return runDisappearances(options);
if (options.legacyCommand === "trace") return runTrace(options);
switch (command) {
case "status": return runStatus(options);
case "rejected": return runRejected(options);
@@ -1,14 +0,0 @@
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
import { buildDisappearancesJSON, formatDisappearances } from "../formatters/disappearances.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runDisappearances(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const rows = disappearanceRows(model);
if (options.json) {
return { stdout: JSON.stringify(buildDisappearancesJSON(rows, { explain: options.explain }), null, 2) };
}
return { stdout: formatDisappearances(rows, { explain: options.explain }) };
}
+10 -2
View File
@@ -1,10 +1,18 @@
import { traceMemoryLifecycle } from "../../../src/evidence-log.ts";
import { formatExplain } from "../formatters/explain.ts";
import { formatTrace } from "../formatters/trace.ts";
import { snapshotForOptions } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult } from "../types.ts";
import { runTrace } from "./trace.ts";
export async function runExplain(options: CliOptions): Promise<CommandResult> {
if (options.memory) return runTrace(options);
if (options.memory) {
const root = options.workspace ?? process.cwd();
const [snapshot, trace] = await Promise.all([
snapshotForOptions(options),
traceMemoryLifecycle(root, { memoryId: options.memory }),
]);
return { stdout: formatTrace(options.memory, snapshot, trace) };
}
const snapshot = await snapshotForOptions(options);
return { stdout: formatExplain(snapshot) };
-59
View File
@@ -1,59 +0,0 @@
import { join } from "node:path";
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../../../src/paths.ts";
import { scanWorkspaceResidues } from "../../../src/workspace-cleanup.ts";
import type { PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts";
import { formatWorkspaceHealth, type WorkspaceHealthInput } from "../formatters/health.ts";
import { pathExists, readJSONFile } from "../io.ts";
import { buildMemoryDiagJSON } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult } from "../types.ts";
type WorkspaceHealthCommandInput = Omit<WorkspaceHealthInput, "now">;
async function workspaceHealthOutput(input: WorkspaceHealthCommandInput): Promise<string> {
return formatWorkspaceHealth({ ...input, now: Date.now() }, {
rawStore: await readJSONFile<WorkspaceMemoryStore>(input.memoryPath),
rawJournal: await readJSONFile<PendingMemoryJournalStore>(input.pendingPath),
pendingExists: pathExists(input.pendingPath),
});
}
export async function runHealth(options: CliOptions): Promise<CommandResult> {
if (options.json) {
const root = options.workspace ?? process.cwd();
return { stdout: JSON.stringify(await buildMemoryDiagJSON(root), null, 2) };
}
if (options.all) {
const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 });
const lines: string[] = ["Workspace memory health", ""];
if (scan.results.length === 0) {
lines.push("No workspace stores found.");
return { stdout: lines.join("\n") };
}
for (let i = 0; i < scan.results.length; i += 1) {
const result = scan.results[i];
if (i > 0) lines.push("");
lines.push(await workspaceHealthOutput({
root: result.root,
key: result.workspaceKey,
memoryPath: join(result.workspaceDir, "workspace-memory.json"),
pendingPath: join(result.workspaceDir, "workspace-pending-journal.json"),
raw: options.raw,
}));
}
return { stdout: lines.join("\n") };
}
const root = options.workspace ?? process.cwd();
const key = await workspaceKey(root);
return {
stdout: await workspaceHealthOutput({
root,
key,
memoryPath: await workspaceMemoryPath(root),
pendingPath: await workspacePendingJournalPath(root),
raw: options.raw,
includeTitle: true,
}),
};
}
-14
View File
@@ -1,14 +0,0 @@
import { buildInspectionReadModel } from "../inspection-model.ts";
import { buildQualityJSON, formatQuality } from "../formatters/quality.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runQuality(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const now = Date.now();
if (options.json) {
return { stdout: JSON.stringify(buildQualityJSON(model, new Date(now).toISOString(), now), null, 2) };
}
return { stdout: formatQuality(model, now) };
}
@@ -1,19 +0,0 @@
import { formatRejections, formatRejectionQuality, buildRejectionQualityJSON } from "../formatters/rejections.ts";
import { loadRejectionRecords, rejectionFalsePositiveRisk, rejectionQualitySummary, uniqueByCanonicalText } from "../rejections-model.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runRejections(options: CliOptions): Promise<CommandResult> {
const { path, invalidLines, records } = await loadRejectionRecords(options);
const normalized = options.unique ? uniqueByCanonicalText(records) : records;
if (options.quality) {
const summary = rejectionQualitySummary(records);
if (options.json) {
return { stdout: JSON.stringify({ ...buildRejectionQualityJSON(summary), falsePositiveRisk: rejectionFalsePositiveRisk(summary) }, null, 2) };
}
return { stdout: formatRejectionQuality({ path, invalidLines, summary, raw: options.raw }) };
}
return { stdout: formatRejections({ path, invalidLines, records: normalized, raw: options.raw }) };
}
-3
View File
@@ -6,7 +6,6 @@ import { retentionCandidatesForDiag, retentionClockSummary, daysSinceIso } from
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import { isWithinDays, memoryDiagJSONFromSnapshot } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult, MemoryInspectionReadModel } from "../types.ts";
import { runHealth } from "./health.ts";
export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.now()): StatusReadout {
const active = model.store.entries.filter(entry => entry.status !== "superseded");
@@ -81,8 +80,6 @@ export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.
}
export async function runStatus(options: CliOptions): Promise<CommandResult> {
if (options.legacyCommand === "health" && options.all) return runHealth(options);
const now = Date.now();
const model = await buildInspectionReadModel(options);
const readout = buildStatusReadout(model, now);
-16
View File
@@ -1,16 +0,0 @@
import { traceMemoryLifecycle } from "../../../src/evidence-log.ts";
import { formatTrace } from "../formatters/trace.ts";
import { snapshotForOptions } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runTrace(options: CliOptions): Promise<CommandResult> {
const root = options.workspace ?? process.cwd();
const memoryId = options.memory;
if (!memoryId) return { stdout: "", stderr: "--memory requires an id", exitCode: 1 };
const [snapshot, trace] = await Promise.all([
snapshotForOptions(options),
traceMemoryLifecycle(root, { memoryId }),
]);
return { stdout: formatTrace(memoryId, snapshot, trace) };
}
@@ -1,45 +0,0 @@
import type { disappearanceRows } from "../inspection-model.ts";
import { eventCounts } from "../inspection-model.ts";
import { formatDetails } from "../text.ts";
export type DisappearanceRows = ReturnType<typeof disappearanceRows>;
export function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
return {
version: 1,
generatedAt: options.generatedAt ?? new Date().toISOString(),
disappearances: rows.map(row => ({
id: row.id,
classification: row.classification,
terminalType: row.terminalType,
reasonCodes: row.reasonCodes,
eventCounts: eventCounts(row.events),
details: options.explain ? row.event?.details : undefined,
})),
};
}
export function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string {
const lines: string[] = [];
lines.push("Memory disappearances");
lines.push("");
if (rows.length === 0) {
lines.push("No evidence-only memories found.");
return lines.join("\n");
}
for (const row of rows) {
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`);
if (options.explain) {
lines.push(` events: ${row.events.map(event => event.type).join(", ")}`);
if (row.event?.type === "memory_removed_capacity") {
lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`);
}
const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"));
if (renderTypeCap) {
lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`);
}
}
}
return lines.join("\n");
}
-193
View File
@@ -1,193 +0,0 @@
import { assessMemoryQuality } from "../../../src/memory-quality.ts";
import {
DORMANT_DECAY_MULTIPLIER,
RETENTION_TYPE_MAX,
WORKSPACE_DORMANT_AFTER_DAYS,
} from "../../../src/retention.ts";
import { renderWorkspaceMemory } from "../../../src/workspace-memory.ts";
import type { LongTermSource, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts";
import { LONG_TERM_LIMITS } from "../../../src/types.ts";
import { SUSPICIOUS_REASONS, TYPES } from "../constants.ts";
import {
ageDays,
daysSinceIso,
formatStrength,
isSafetyCriticalForDiag,
promotionLimit,
retentionCandidatesForDiag,
} from "../retention-model.ts";
import {
canonicalMemoryText,
cleanPath,
cleanText,
countBy,
formatPercent,
formatWorkspaceIdentity,
truncate,
} from "../text.ts";
import { normalizedJournal, normalizedStore } from "../workspace-snapshot.ts";
export type WorkspaceHealthInput = {
root?: string;
key: string;
memoryPath: string;
pendingPath: string;
raw: boolean;
now: number;
includeTitle?: boolean;
};
export type WorkspaceHealthLoadedData = {
rawStore: WorkspaceMemoryStore | null;
rawJournal: PendingMemoryJournalStore | null;
pendingExists: boolean;
};
export function formatWorkspaceHealth(input: WorkspaceHealthInput, loadedData: WorkspaceHealthLoadedData): string {
const lines: string[] = [];
if (input.includeTitle) {
lines.push("Workspace memory health");
lines.push("");
}
const rawStore = loadedData.rawStore;
const storeRoot = rawStore?.workspace?.root ?? input.root ?? "";
const storeKey = rawStore?.workspace?.key ?? input.key;
const store = normalizedStore(rawStore, storeRoot, storeKey);
const journal = normalizedJournal(loadedData.rawJournal);
const identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw);
if (identity) lines.push(identity);
lines.push(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`);
lines.push(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`);
if (!rawStore) lines.push("memory store: missing or unreadable (treated as empty)");
if (!loadedData.pendingExists) lines.push("pending journal: missing (treated as empty)");
lines.push("");
const active = store.entries.filter(entry => entry.status !== "superseded");
const superseded = store.entries.filter(entry => entry.status === "superseded");
const retention = retentionCandidatesForDiag(store, input.now);
const renderedEntries = retention.rendered.map(item => item.entry);
const renderedEstimate = renderWorkspaceMemory(store).length;
lines.push(`Stored active memories: ${active.length}`);
lines.push(`Superseded memories: ${superseded.length}`);
lines.push(`Rendered candidates: ${renderedEntries.length}`);
lines.push(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
lines.push("");
const pendingEntries = journal.entries;
const retryable = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) < promotionLimit(entry.source)).length;
const nearRetryLimit = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) >= promotionLimit(entry.source) - 1).length;
const pendingBySource = countBy(pendingEntries.map(entry => entry.source));
lines.push("Pending journal:");
lines.push(` total: ${pendingEntries.length}`);
lines.push(` retryable: ${retryable}`);
lines.push(` near retry limit: ${nearRetryLimit}`);
lines.push(" by source:");
for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) {
lines.push(` ${source}: ${pendingBySource.get(source) ?? 0}`);
}
lines.push("");
lines.push("By type:");
for (const type of TYPES) {
const storedCount = active.filter(entry => entry.type === type).length;
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
const supersededCount = superseded.filter(entry => entry.type === type).length;
lines.push(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${RETENTION_TYPE_MAX[type]} superseded=${supersededCount}`);
}
lines.push("");
lines.push("Retention caps:");
lines.push(` type-capped entries: ${retention.typeCapped.length}`);
lines.push(` global-cap overflow: ${retention.globalCapped.length}`);
lines.push("");
const olderThan30 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 30).length;
const olderThan90 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 90).length;
const staleMarked = active.filter(entry => {
const days = ageDays(entry, input.now);
return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays);
}).length;
lines.push("Age:");
lines.push(` stale-marked: ${staleMarked}`);
lines.push(` older than 30d: ${olderThan30}`);
lines.push(` older than 90d: ${olderThan90}`);
lines.push("");
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt, input.now);
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS;
const dormantDaysPastGrace = wallDaysSinceActivity === null
? 0
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
lines.push("Dormancy:");
lines.push(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
lines.push(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
lines.push(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
lines.push(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
lines.push(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`);
lines.push("");
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
const highImportanceAlert = highImportanceRatio > 0.3;
const safetyCriticalWarning = safetyCriticalCount > 0;
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
lines.push("Retention monitoring:");
lines.push(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
lines.push(` safety_critical_count: ${safetyCriticalCount} (deprecated field)${safetyCriticalWarning ? " WARNING" : ""}`);
lines.push(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
lines.push("");
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
lines.push("Quality warnings:");
lines.push(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`);
lines.push(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`);
lines.push(` duplicate-ish exact canonical text: ${duplicateExtras}`);
lines.push(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`);
lines.push("");
lines.push("Suspicious active memories:");
for (const reason of SUSPICIOUS_REASONS) {
lines.push(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`);
}
const failingQuality = qualityByEntry.filter(item => !item.quality.accepted);
if (failingQuality.length > 0) {
lines.push("");
lines.push("Active memories failing offline quality checks:");
for (const item of failingQuality.slice(0, 8)) {
lines.push(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`);
}
}
lines.push("");
lines.push("Top rendered candidates:");
const top = retention.rendered.slice(0, 5);
if (top.length === 0) {
lines.push(" (none)");
} else {
for (const item of top) {
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
lines.push("");
lines.push("Weakest active memories:");
const weakest = retention.sorted.slice(-5).reverse();
if (weakest.length === 0) {
lines.push(" (none)");
} else {
for (const item of weakest) {
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
return lines.join("\n");
}
+45 -1
View File
@@ -1,4 +1,8 @@
import { buildDisappearancesJSON, formatDisappearances, type DisappearanceRows } from "./disappearances.ts";
import type { disappearanceRows } from "../inspection-model.ts";
import { eventCounts } from "../inspection-model.ts";
import { formatDetails } from "../text.ts";
export type DisappearanceRows = ReturnType<typeof disappearanceRows>;
export type MissingSummary = {
total: number;
@@ -21,6 +25,46 @@ export function buildMissingJSON(rows: DisappearanceRows, options: { explain?: b
};
}
function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
return {
version: 1,
generatedAt: options.generatedAt ?? new Date().toISOString(),
disappearances: rows.map(row => ({
id: row.id,
classification: row.classification,
terminalType: row.terminalType,
reasonCodes: row.reasonCodes,
eventCounts: eventCounts(row.events),
details: options.explain ? row.event?.details : undefined,
})),
};
}
function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string {
const lines: string[] = [];
lines.push("Memory disappearances");
lines.push("");
if (rows.length === 0) {
lines.push("No evidence-only memories found.");
return lines.join("\n");
}
for (const row of rows) {
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`);
if (options.explain) {
lines.push(` events: ${row.events.map(event => event.type).join(", ")}`);
if (row.event?.type === "memory_removed_capacity") {
lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`);
}
const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"));
if (renderTypeCap) {
lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`);
}
}
}
return lines.join("\n");
}
export function formatMissing(rows: DisappearanceRows, options: { verbose?: boolean; explain?: boolean } = {}): string {
const summary = missingSummary(rows);
const lines: string[] = [];
-119
View File
@@ -1,119 +0,0 @@
import { RETENTION_TYPE_MAX } from "../../../src/retention.ts";
import { TYPES } from "../constants.ts";
import { disappearanceRows } from "../inspection-model.ts";
import { retentionCandidatesForDiag, retentionClockSummary } from "../retention-model.ts";
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import type { MemoryInspectionReadModel } from "../types.ts";
export function buildQualityJSON(model: MemoryInspectionReadModel, generatedAt = new Date().toISOString(), now = new Date(generatedAt).getTime()): Record<string, unknown> {
const active = model.store.entries.filter(entry => entry.status !== "superseded");
const retention = retentionCandidatesForDiag(model.store, now);
const clocks = retentionClockSummary(active);
const disappearances = disappearanceRows(model);
const evidenceCovered = active.filter(entry => (model.evidenceByMemoryId.get(entry.id) ?? []).length > 0).length;
const rejectionSummary = rejectionQualitySummary(model.rejectionRecords);
const falsePositiveRisk = rejectionFalsePositiveRisk(rejectionSummary);
const typeCounts = Object.fromEntries(TYPES.map(type => [type, active.filter(entry => entry.type === type).length]));
const capsFull = active.length >= model.store.limits.maxEntries || TYPES.some(type => (typeCounts[type] ?? 0) >= RETENTION_TYPE_MAX[type]);
const unknownDisappearances = disappearances.filter(row => row.classification === "historical_absent_unknown_reason").length;
const status = unknownDisappearances > 0 || clocks.invalid > 0
? "degraded"
: capsFull || rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high"
? "warning"
: "ok";
const summaryText = `Summary: Workspace memory quality is ${status}: ${active.length} active memories, ${evidenceCovered}/${active.length} with evidence, ${disappearances.length} evidence-only disappearances (${unknownDisappearances} unknown), ${clocks.invalid} invalid retention clocks, and ${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records.`;
const caps = {
active: active.length,
maxEntries: model.store.limits.maxEntries,
rendered: retention.rendered.length,
typeCapped: retention.typeCapped.length,
globalCapped: retention.globalCapped.length,
typeCounts,
capsFull,
};
const evidence = {
currentWithEvidence: evidenceCovered,
currentWithoutEvidence: active.length - evidenceCovered,
evidenceMemoryIds: model.evidenceByMemoryId.size,
disappearances: disappearances.length,
unknownDisappearances,
withTerminalReason: disappearances.length - unknownDisappearances,
};
return {
version: 1,
generatedAt,
status,
summaryText,
store: { active: active.length, pending: model.pending.entries.length, superseded: model.store.entries.length - active.length },
caps,
retention: clocks,
evidence,
rejections: { ...rejectionSummary, falsePositiveRisk },
};
}
export function formatQuality(model: MemoryInspectionReadModel, now: number): string {
const data = buildQualityJSON(model, new Date(now).toISOString(), now) as {
summaryText: string;
caps: {
active: number;
maxEntries: number;
rendered: number;
typeCapped: number;
globalCapped: number;
typeCounts: Record<string, number>;
capsFull: boolean;
};
retention: { present: number; missing: number; invalid: number };
evidence: {
currentWithEvidence: number;
currentWithoutEvidence: number;
evidenceMemoryIds: number;
disappearances: number;
unknownDisappearances: number;
};
rejections: {
totalRecords: number;
workspaceScopedCount: number;
legacyUnscopedCount: number;
falsePositiveRisk: string;
};
};
const lines: string[] = [];
lines.push("Memory quality inspection");
lines.push("");
lines.push(data.summaryText);
lines.push("");
lines.push("Caps:");
lines.push(` active: ${data.caps.active} / ${data.caps.maxEntries}`);
for (const type of TYPES) {
const count = data.caps.typeCounts[type] ?? 0;
const limit = RETENTION_TYPE_MAX[type];
const marker = count >= limit ? " FULL" : "";
lines.push(` ${type}: ${count} / ${limit}${marker}`);
}
lines.push(` rendered: ${data.caps.rendered}`);
lines.push(` type-capped entries: ${data.caps.typeCapped}`);
lines.push(` global-cap overflow: ${data.caps.globalCapped}`);
lines.push(` caps full: ${data.caps.capsFull ? "yes" : "no"}`);
lines.push("");
lines.push("Retention clocks:");
lines.push(` present: ${data.retention.present}`);
lines.push(` missing: ${data.retention.missing}`);
lines.push(` invalid: ${data.retention.invalid}`);
lines.push("");
lines.push("Evidence:");
lines.push(` current with evidence: ${data.evidence.currentWithEvidence}`);
lines.push(` current without evidence: ${data.evidence.currentWithoutEvidence}`);
lines.push(` evidence memory ids: ${data.evidence.evidenceMemoryIds}`);
lines.push(` disappearances: ${data.evidence.disappearances}`);
lines.push(` unknown disappearances: ${data.evidence.unknownDisappearances}`);
lines.push("");
lines.push("Rejection scoping:");
lines.push(` total records: ${data.rejections.totalRecords}`);
lines.push(` workspace scoped: ${data.rejections.workspaceScopedCount}`);
lines.push(` legacy unscoped: ${data.rejections.legacyUnscopedCount}`);
lines.push(` false-positive risk: ${data.rejections.falsePositiveRisk}`);
return lines.join("\n");
}
@@ -1,98 +0,0 @@
import { hasSoftReason, rejectionQualitySummary } from "../rejections-model.ts";
import { cleanPath, cleanText, countBy, formatWorkspaceIdentity, sortedCounts, truncate } from "../text.ts";
import type { NormalizedRejection } from "../types.ts";
export type RejectionQualitySummary = ReturnType<typeof rejectionQualitySummary>;
export function buildRejectionQualityJSON(summary: RejectionQualitySummary, generatedAt = new Date().toISOString()): Record<string, unknown> {
return {
version: 1,
generatedAt,
...summary,
};
}
export function formatRejections(input: {
path: string;
invalidLines: number;
records: NormalizedRejection[];
raw: boolean;
}): string {
const lines: string[] = [];
lines.push("Extraction rejection summary");
lines.push("");
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
lines.push("");
lines.push(`Total rejected: ${input.records.length}`);
lines.push("");
lines.push("By reason:");
const byReason = sortedCounts(countBy(input.records.flatMap(record => record.reasons)));
if (byReason.length === 0) lines.push(" (none)");
else for (const [reason, count] of byReason) lines.push(` ${reason.padEnd(24)} ${count}`);
lines.push("");
lines.push("By origin:");
const byOrigin = sortedCounts(countBy(input.records.map(record => record.origin)));
if (byOrigin.length === 0) lines.push(" (none)");
else for (const [origin, count] of byOrigin) lines.push(` ${origin.padEnd(24)} ${count}`);
lines.push("");
lines.push("Trigger-origin rejections (high priority for v1.5):");
const triggerReasons = sortedCounts(countBy(input.records.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons)));
if (triggerReasons.length === 0) lines.push(" (none)");
else for (const [reason, count] of triggerReasons) lines.push(` ${reason.padEnd(24)} ${count}`);
lines.push("");
lines.push("Recent suspicious soft rejects:");
const suspicious = input.records
.filter(hasSoftReason)
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
.slice(0, 8);
if (suspicious.length === 0) {
lines.push(" (none)");
} else {
for (const record of suspicious) {
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, input.raw);
lines.push(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, input.raw)))}`);
lines.push(` reasons: ${record.reasons.join(",")}`);
lines.push(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`);
}
}
return lines.join("\n");
}
export function formatRejectionQuality(input: {
path: string;
invalidLines: number;
summary: RejectionQualitySummary;
raw: boolean;
}): string {
const lines: string[] = [];
lines.push("Extraction rejection quality inspection");
lines.push("");
lines.push("Possible false-positive grouping is heuristic, not deterministic truth.");
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
lines.push("");
lines.push(`Total records: ${input.summary.totalRecords}`);
lines.push(`Unique texts: ${input.summary.uniqueTexts}`);
lines.push(`Workspace scoped: ${input.summary.workspaceScopedCount}`);
lines.push(`Legacy unscoped: ${input.summary.legacyUnscopedCount}`);
lines.push("");
lines.push("Reason distribution (raw records):");
for (const [reason, count] of Object.entries(input.summary.reasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
if (Object.keys(input.summary.reasonDistribution).length === 0) lines.push(" (none)");
lines.push("");
lines.push("Reason distribution (unique text):");
for (const [reason, count] of Object.entries(input.summary.uniqueReasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
if (Object.keys(input.summary.uniqueReasonDistribution).length === 0) lines.push(" (none)");
lines.push("");
lines.push("Possible false-positive groups (heuristic, not deterministic):");
for (const [group, data] of Object.entries(input.summary.possibleFalsePositiveGroups)) {
lines.push(` ${group}: ${data.count}`);
for (const sample of data.samples) lines.push(` - ${JSON.stringify(cleanText(sample, input.raw))}`);
}
return lines.join("\n");
}
+3 -5
View File
@@ -1,5 +1,8 @@
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome } from "../../src/evidence-log.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts";
import type { Command } from "./command-metadata.ts";
export type { Command, HiddenCommand, VisibleCommand } from "./command-metadata.ts";
export type MemoryRenderStatus =
| "rendered"
@@ -47,8 +50,6 @@ export type MemoryDiagJSON = {
}>;
};
export type Command = "status" | "rejected" | "missing" | "explain" | "coverage" | "audit";
export type LegacyCommand = "health" | "quality" | "rejections" | "disappearances" | "trace";
export type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
export type CliOptions = {
@@ -61,14 +62,11 @@ export type CliOptions = {
softOnly?: boolean;
triggerOnly?: boolean;
includeHistorical?: boolean;
quality?: boolean;
reason?: string;
unique?: boolean;
explain?: boolean;
since?: string;
migration?: string;
memory?: string;
legacyCommand?: LegacyCommand;
positional?: string[];
auditMode?: "coverage" | "migrations";
};
+36 -75
View File
@@ -2,14 +2,16 @@ 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", () => {
test("help returns usage without exposing hidden or removed commands", () => {
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/);
for (const command of ["health", "quality", "rejections", "disappearances", "trace", "coverage", "audit"]) {
assert.doesNotMatch(parsed.usage, new RegExp(command));
}
});
test("status defaults when no subcommand", () => {
@@ -30,17 +32,41 @@ test("unknown command returns usage error", () => {
assert.match(parsed.usage, /Usage:/);
});
test("removed legacy aliases are ordinary unknown subcommands", () => {
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
const parsed = parseArgs([command]);
assert.equal(parsed.ok, false, command);
if (parsed.ok) continue;
assert.equal(parsed.message, `Unknown subcommand: ${command}`);
assert.match(parsed.usage, /Usage:/);
}
});
test("hidden maintainer commands are accepted with neutral notices", () => {
const coverage = parseArgs(["coverage"]);
assert.equal(coverage.ok, true);
assert.equal("command" in coverage && coverage.command, "coverage");
assert.equal("deprecationNotice" in coverage && coverage.deprecationNotice, "Note: 'coverage' is a maintainer-only diagnostic and is not part of the public CLI surface.");
const audit = parseArgs(["audit"]);
assert.equal(audit.ok, true);
assert.equal("command" in audit && audit.command, "audit");
assert.equal("deprecationNotice" in audit && audit.deprecationNotice, "Note: 'audit' is a maintainer-only diagnostic and is not part of the public CLI surface.");
});
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: ["status", "--all"], message: "status 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: ["missing", "--all"], message: "missing does not accept --all" },
{ args: ["rejected", "--all"], message: "rejected 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" },
{ args: ["status", "--since", "forever"], message: "status does not accept rejection filters" },
{ args: ["status", "--reason", "bad_decision"], message: "status does not accept rejection filters" },
{ args: ["status", "--quality"], message: "Unknown option: --quality" },
{ args: ["rejected", "--unique"], message: "Unknown option: --unique" },
];
for (const item of cases) {
@@ -52,26 +78,8 @@ test("current command flag validation messages are preserved", () => {
}
});
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"]);
test("rejected invalid since value returns current error", () => {
const parsed = parseArgs(["rejected", "--since", "forever"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
@@ -79,53 +87,6 @@ test("rejections invalid since value returns current error", () => {
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"]);
+21 -38
View File
@@ -2,15 +2,11 @@ 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 { buildMissingJSON, formatMissing } from "../scripts/memory-diag/formatters/missing.ts";
import { formatTrace } from "../scripts/memory-diag/formatters/trace.ts";
import { rejectionQualitySummary } from "../scripts/memory-diag/rejections-model.ts";
function emptyInspectionModel(): MemoryInspectionReadModel {
return {
@@ -60,36 +56,6 @@ function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType;
};
}
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([]);
@@ -97,10 +63,27 @@ test("coverage formatter includes class counts section", () => {
assert.match(output, /Per-memory rows:\n \(none\)/);
});
test("disappearances formatter preserves empty-state label", () => {
const output = formatDisappearances([]);
test("missing formatter verbose output preserves disappearance details", () => {
const output = formatMissing([], { verbose: true });
assert.match(output, /No evidence-only memories found\./);
assert.match(output, /No missing memories found\./);
});
test("missing JSON includes disappearance rows and summary", () => {
const rows = [{
id: "historical-1",
classification: "historical_absent_unknown_reason" as const,
terminalType: "unknown" as const,
reasonCodes: [],
events: [event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted" })],
event: undefined,
}];
const output = buildMissingJSON(rows, { generatedAt: "2026-01-01T00:00:00.000Z" });
assert.equal(output.version, 1);
assert.deepEqual(output.summary, { total: 1, explained: 0, needsReview: 1 });
assert.deepEqual((output.disappearances as Array<{ id: string }>)[0]?.id, "historical-1");
});
test("trace formatter includes lifecycle section", () => {
+59 -157
View File
@@ -47,8 +47,8 @@ async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[],
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
async function runMemoryDiagHealth(root: string): Promise<string> {
return runMemoryDiag(["health", "--workspace", root]);
async function runMemoryDiagStatusVerbose(root: string): Promise<string> {
return runMemoryDiag(["status", "--workspace", root, "--verbose"]);
}
async function runMemoryDiag(args: string[]): Promise<string> {
@@ -96,27 +96,26 @@ function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
};
}
test("health handles missing workspace store as empty", async () => {
test("status 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]);
const stdout = await runMemoryDiag(["status", "--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/);
assert.match(stdout, /Memory status/);
assert.match(stdout, /active memories: 0/);
assert.match(stdout, /pending: 0/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("quality handles missing workspace store as empty", async () => {
test("status verbose 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]);
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
assert.match(stdout, /Memory quality inspection/);
assert.match(stdout, /0 active memories/);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /Caps:/);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -136,15 +135,15 @@ test("coverage and disappearances handle missing workspace store as empty", asyn
}
});
test("health with conflicting flags shows usage error", async () => {
test("status with unsupported all flag shows usage error", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-conflicting-flags-"));
try {
await assert.rejects(
runMemoryDiagResult(["health", "--all", "--workspace", root]),
runMemoryDiagResult(["status", "--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 ?? "", /status does not accept --all/);
assert.match(err.stderr ?? "", /Usage:/);
return true;
},
@@ -185,26 +184,21 @@ test("memory-diag defaults to status when no subcommand is supplied", async () =
assert.equal(result.stderr, "");
});
test("legacy health alias emits deprecation notice and still runs", async () => {
test("removed legacy aliases return unknown subcommand", 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\./);
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
await assert.rejects(
runMemoryDiagResult([command, "--workspace", root]),
(error: unknown) => {
const err = error as { code?: number; stderr?: string };
assert.notEqual(err.code, 0);
assert.match(err.stderr ?? "", new RegExp(`Unknown subcommand: ${command}`));
assert.match(err.stderr ?? "", /Usage:/);
return true;
},
);
}
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -219,11 +213,12 @@ test("memory health reports stored vs rendered retention counts", async () => {
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
const stdout = await runMemoryDiagStatusVerbose(root);
assert.match(stdout, /Stored active memories:/);
assert.match(stdout, /Rendered candidates:/);
assert.match(stdout, /feedback\s+stored=17\s+rendered=10/);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /active: 28 \/ 28/);
assert.match(stdout, /rendered: 20/);
assert.match(stdout, /feedback: 17 \/ 10 FULL/);
assert.match(stdout, /Top rendered candidates:\n\s+- strength=/);
} finally {
await rm(root, { recursive: true, force: true });
@@ -242,15 +237,14 @@ test("memory health reports dormancy and retention monitoring deprecations", asy
}));
await writeWorkspaceStore(root, entries, { lastActivityAt });
const stdout = await runMemoryDiagHealth(root);
const stdout = await runMemoryDiagStatusVerbose(root);
assert.match(stdout, /Dormancy:/);
assert.match(stdout, /wall days since activity: 19\.0/);
assert.match(stdout, /dormant discount active: yes/);
assert.match(stdout, /dormant days past grace: 5\.0/);
assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
assert.match(stdout, /safety_critical_count: 6 .*deprecated.* WARNING/);
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
assert.match(stdout, /Top rendered candidates:/);
assert.match(stdout, /Weakest active memories:/);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -267,9 +261,9 @@ test("memory health reports global cap overflow separately from type caps", asyn
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
const stdout = await runMemoryDiagStatusVerbose(root);
assert.match(stdout, /Rendered candidates: 28/);
assert.match(stdout, /rendered: 28/);
assert.match(stdout, /type-capped entries: 0/);
assert.match(stdout, /global-cap overflow: 6/);
} finally {
@@ -282,14 +276,13 @@ test("memory health reports missing dormancy and non-alert monitoring defaults",
try {
await writeWorkspaceStore(root, [], { omitLastActivityAt: true });
const stdout = await runMemoryDiagHealth(root);
const stdout = await runMemoryDiagStatusVerbose(root);
assert.match(stdout, /lastActivityAt: \(missing\)/);
assert.match(stdout, /wall days since activity: unknown/);
assert.match(stdout, /dormant discount active: no/);
assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
assert.match(stdout, /safety_critical_count: 0 \(deprecated field\)\n/);
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
assert.match(stdout, /Top rendered candidates:\n\s+\(none\)/);
assert.match(stdout, /Weakest active memories:\n\s+\(none\)/);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -311,8 +304,8 @@ test("memory health --json prints parseable privacy-safe diagnostics matching hu
evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["within_caps", "within_char_budget"] }),
]);
const human = await runMemoryDiagHealth(root);
const jsonText = await runMemoryDiag(["health", "--workspace", root, "--json"]);
const human = await runMemoryDiagStatusVerbose(root);
const jsonText = await runMemoryDiag(["status", "--workspace", root, "--json"]);
const parsed = JSON.parse(jsonText) as {
version: 1;
summary: { storedActive: number; rendered: number; pending: number; rejectedLast7Days: number; corruptStoresQuarantinedLast30Days: number };
@@ -321,9 +314,9 @@ test("memory health --json prints parseable privacy-safe diagnostics matching hu
};
assert.equal(parsed.version, 1);
assert.equal(parsed.summary.storedActive, Number(human.match(/Stored active memories: (\d+)/)?.[1]));
assert.equal(parsed.summary.rendered, Number(human.match(/Rendered candidates: (\d+)/)?.[1]));
assert.equal(parsed.summary.pending, Number(human.match(/Pending journal:\n\s+total: (\d+)/)?.[1]));
assert.equal(parsed.summary.storedActive, Number(human.match(/active: (\d+) \/ 28/)?.[1]));
assert.equal(parsed.summary.rendered, Number(human.match(/rendered: (\d+)/)?.[1]));
assert.equal(parsed.summary.pending, 1);
assert.equal(parsed.summary.rejectedLast7Days, 1);
assert.equal(parsed.summary.corruptStoresQuarantinedLast30Days, 1);
assert.ok(parsed.recentEvents.some(event => event.eventId && event.type === "render_selected" && event.outcome === "rendered" && event.createdAt && event.reasonCodes.includes("within_caps")));
@@ -383,7 +376,7 @@ test("memory-diag explain shows rendered, omitted, pending, and evidence reason
}
});
test("memory-diag trace prints lifecycle relations and redacts secrets", async () => {
test("memory-diag explain --memory prints lifecycle relations and redacts secrets", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-"));
try {
await writeWorkspaceStore(root, [
@@ -399,7 +392,7 @@ test("memory-diag trace prints lifecycle relations and redacts secrets", async (
evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "omitted", memory: { memoryId: "mem-life" } }], reasonCodes: ["superseded"] }),
]);
const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "mem-life"]);
const stdout = await runMemoryDiag(["explain", "--workspace", root, "--memory", "mem-life"]);
assert.match(stdout, /Memory mem-life: omitted_superseded/);
assert.match(stdout, /Lifecycle:/);
@@ -439,21 +432,10 @@ test("memory-diag explain positional memory id prints lifecycle", async () => {
}
});
test("memory-diag trace requires --memory and reports unknown IDs", async () => {
test("memory-diag explain --memory reports unknown IDs", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-"));
try {
await assert.rejects(
execFileAsync(process.execPath, ["--experimental-strip-types", "scripts/memory-diag.ts", "trace", "--workspace", root], { cwd: repoRoot }),
(error: unknown) => {
const err = error as { code?: number; stderr?: string };
assert.notEqual(err.code, 0);
assert.match(err.stderr ?? "", /--memory requires an id/);
assert.match(err.stderr ?? "", /Usage:/);
return true;
},
);
const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "missing-memory"]);
const stdout = await runMemoryDiag(["explain", "--workspace", root, "--memory", "missing-memory"]);
assert.match(stdout, /Memory missing-memory: unknown/);
assert.match(stdout, /Lifecycle:\n\(none\)/);
} finally {
@@ -481,12 +463,12 @@ async function setupQualityFixture(): Promise<string> {
return root;
}
test("quality human output includes summary and aggregate inspection counts", async () => {
test("status verbose output includes summary and aggregate inspection counts", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["quality", "--workspace", root]);
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
assert.match(stdout, /Memory quality inspection/);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /Summary: Workspace memory quality is degraded:/);
assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/);
assert.match(stdout, /decision: 14 \/ 10 FULL/);
@@ -499,26 +481,18 @@ test("quality human output includes summary and aggregate inspection counts", as
}
});
test("quality --json includes summaryText, caps, retention, evidence, and rejections", async () => {
test("status --json includes summary status fields", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["quality", "--workspace", root, "--json"]);
const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as {
summaryText: string;
caps: { active: number; capsFull: boolean };
retention: { invalid: number };
evidence: { currentWithEvidence: number; unknownDisappearances: number };
rejections: { workspaceScopedCount: number; legacyUnscopedCount: number };
summary: { storedActive: number; status: string; evidenceCoveragePercent: number; needsAttention: string[] };
};
assert.match(parsed.summaryText, /Workspace memory quality is degraded/);
assert.equal(parsed.caps.active, LONG_TERM_LIMITS.maxEntries);
assert.equal(parsed.caps.capsFull, true);
assert.equal(parsed.retention.invalid, 1);
assert.equal(parsed.evidence.currentWithEvidence, 1);
assert.equal(parsed.evidence.unknownDisappearances, 1);
assert.equal(parsed.rejections.workspaceScopedCount, 1);
assert.equal(parsed.rejections.legacyUnscopedCount, 1);
assert.equal(parsed.summary.storedActive, LONG_TERM_LIMITS.maxEntries);
assert.equal(parsed.summary.status, "degraded");
assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number");
assert.ok(parsed.summary.needsAttention.length > 0);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -732,25 +706,6 @@ test("missing --json includes disappearances and additive summary", async () =>
}
});
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 {
@@ -822,56 +777,3 @@ test("rejected --json includes quality summary and false-positive risk", async (
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 {
const key = await workspaceKey(root);
const now = new Date().toISOString();
await writeRejectionRecords([
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] },
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] },
{ timestamp: now, type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] },
]);
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/);
assert.match(stdout, /ambiguous: 1/);
assert.doesNotMatch(stdout, /deterministic truth\s*:/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("rejections --quality --json includes scoping, unique reasons, and possible false-positive groups", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-json-"));
try {
const key = await workspaceKey(root);
const now = new Date().toISOString();
await writeRejectionRecords([
{ timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
{ timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] },
]);
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--json"]);
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 {
await rm(root, { recursive: true, force: true });
}
});