mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }) };
|
||||
}
|
||||
@@ -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) };
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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 }) };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user