diff --git a/README.md b/README.md index 5522d00..087a926 100644 --- a/README.md +++ b/README.md @@ -200,30 +200,35 @@ The goal is to remember durable facts, not every detail. Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y". -For local development cleanup, use: +### Memory Diagnostics CLI + +Use the read-only diagnostics CLI when you want to understand what memory is doing for the current workspace. + +| Question | Command | +|---|---| +| Is memory healthy? | `npx --package opencode-working-memory memory-diag` or `npx --package opencode-working-memory memory-diag status` | +| Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` | +| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` | +| Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain ` | + +Global options: + +- `--workspace ` — inspect another workspace; defaults to the current directory. +- `--verbose` — show detailed diagnostics. +- `--json` — print machine-readable output where supported. + +Examples: ```bash -npm run cleanup:workspaces -- --dry-run -npm run cleanup:workspaces -- --quarantine +npx --package opencode-working-memory memory-diag status +npx --package opencode-working-memory memory-diag rejected --verbose +npx --package opencode-working-memory memory-diag missing --workspace /path/to/project +npx --package opencode-working-memory memory-diag status --json ``` -The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces. +The npm package is opencode-working-memory; the installed bin is memory-diag, so package-qualified npx avoids resolving a different package named memory-diag. -### Local Inspection CLI - -Maintainers can run read-only inspection surfaces without telemetry or mutation. Human output redacts absolute paths and credentials by default; pass `--raw` only when you intentionally need local paths. - -```bash -bun scripts/memory-diag.ts quality --workspace /path/to/repo -bun scripts/memory-diag.ts coverage --workspace /path/to/repo --include-historical -bun scripts/memory-diag.ts disappearances --workspace /path/to/repo --explain -bun scripts/memory-diag.ts rejections --quality --reason bad_decision --unique -``` - -- `quality` summarizes store caps, retention clocks, evidence coverage, disappearances, and rejection scoping. -- `coverage` classifies per-memory evidence lifecycle coverage, optionally including historical evidence-only memory IDs. -- `disappearances --explain` reports evidence memory IDs absent from the current store with terminal capacity, promotion, supersession, or render-omission context when available. -- `rejections --quality` groups rejection records by scope, reason distribution, and heuristic possible false-positive buckets. +Maintainer-only diagnostics and cleanup commands are intentionally not documented here. Future work: move those internal commands to `docs/development.md`. ## Configuration @@ -261,13 +266,12 @@ cd opencode-working-memory npm install npm test npm run typecheck -npm run cleanup:workspaces -- --dry-run ``` ## Requirements - OpenCode plugin API `>=1.2.0 <2.0.0` -- Node.js >= 18.0.0 +- Node.js >= 22.6.0 (for `memory-diag` CLI, which runs TypeScript with `--experimental-strip-types`) ## Limitations diff --git a/docs/configuration.md b/docs/configuration.md index 588a975..f77ca61 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -204,13 +204,21 @@ cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq ### Inspect Retention Health -From a source checkout, maintainers can inspect stored vs rendered memory behavior: +Use the diagnostics CLI to check memory health for the current workspace: ```bash -bun scripts/memory-diag.ts health +npx --package opencode-working-memory memory-diag status +# or from a source checkout: +npm run diag -- status ``` -The health output includes stored active memories, rendered candidates, type caps, global cap overflow, dormancy status, retention monitoring alerts, and strength-ranked top/weakest entries. +For detailed diagnostics, use `--verbose`: + +```bash +npx --package opencode-working-memory memory-diag status --verbose +``` + +The status output includes overall health (OK/WARNING/DEGRADED), key metrics, and suggested next steps when attention is needed. ### Clear Workspace Memory diff --git a/package.json b/package.json index 3d66782..a090e58 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,21 @@ "exports": { ".": "./index.ts" }, + "bin": { + "memory-diag": "./scripts/memory-diag-bin.cjs" + }, "files": [ "index.ts", "src/", + "scripts/memory-diag.ts", + "scripts/memory-diag/", + "scripts/memory-diag-bin.cjs", "README.md", "LICENSE" ], "scripts": { "build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"", + "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", @@ -47,6 +54,6 @@ "typescript": "^5.9.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.6.0" } } diff --git a/scripts/memory-diag-bin.cjs b/scripts/memory-diag-bin.cjs new file mode 100755 index 0000000..6ebfef7 --- /dev/null +++ b/scripts/memory-diag-bin.cjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +const { execFileSync } = require("child_process"); +const path = require("path"); + +function isSupportedNodeVersion(version) { + const [major = 0, minor = 0, patch = 0] = version.split(".").map(part => Number(part)); + void patch; + return major > 22 || (major === 22 && minor >= 6); +} + +if (!isSupportedNodeVersion(process.versions.node)) { + process.stderr.write(`memory-diag requires Node >=22.6.0 because it runs TypeScript with --experimental-strip-types. Current Node: v${process.versions.node}.\n`); + process.exit(1); +} + +const binDir = __dirname; +const tsScript = path.join(binDir, "memory-diag.ts"); +const args = ["--experimental-strip-types", tsScript, ...process.argv.slice(2)]; +try { + execFileSync(process.execPath, args, { stdio: "inherit" }); + process.exit(0); +} catch (e) { + process.exit(e.status || 1); +} diff --git a/scripts/memory-diag.ts b/scripts/memory-diag.ts index 5533526..d0ef963 100644 --- a/scripts/memory-diag.ts +++ b/scripts/memory-diag.ts @@ -4,1531 +4,51 @@ * Does not send telemetry, make API calls, or affect plugin runtime behavior. */ -import { createHash } from "node:crypto"; -import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; -import { join } from "node:path"; -import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; -import { assessMemoryQuality, HARD_QUALITY_REASONS, isArchitectureLikeDecision } from "../src/memory-quality.ts"; -import { redactCredentials } from "../src/redaction.ts"; -import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts"; -import { accountWorkspaceMemoryRender, renderWorkspaceMemory } from "../src/workspace-memory.ts"; -import { - DORMANT_DECAY_MULTIPLIER, - RETENTION_TYPE_MAX, - WORKSPACE_DORMANT_AFTER_DAYS, - calculateRetentionStrength, -} from "../src/retention.ts"; -import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts"; -import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts"; -import { - queryEvidenceEvents, - summarizeMemoryEvidence, - traceMemoryLifecycle, - type EvidenceEventType, - type EvidenceEventV1, - type EvidenceOutcome, -} from "../src/evidence-log.ts"; +import { parseArgs, type ParsedArgs } from "./memory-diag/cli.ts"; +import { dispatch } from "./memory-diag/command-registry.ts"; +import { CliInputError } from "./memory-diag/types.ts"; -export type MemoryRenderStatus = - | "rendered" - | "omitted_superseded" - | "omitted_type_cap" - | "omitted_global_cap" - | "omitted_char_budget" - | "omitted_absorbed_duplicate" - | "pending_retry" - | "pending_rejected_capacity" - | "quarantined_corrupt_store"; +type ParsedError = Extract; +type ParsedHelp = Extract; -export type MemoryDiagJSON = { - version: 1; - workspace: { rootHash: string; key: string }; - generatedAt: string; - summary: { - storedActive: number; - rendered: number; - pending: number; - rejectedLast7Days: number; - corruptStoresQuarantinedLast30Days: number; - }; - memories: Array<{ - id: string; - type: "feedback" | "project" | "decision" | "reference"; - source: "explicit" | "compaction" | "manual"; - status: MemoryRenderStatus; - strength?: number; - reasonCodes: string[]; - textPreview?: string; - evidenceEventIds: string[]; - }>; - recentEvents: Array<{ - eventId: string; - type: EvidenceEventType; - outcome: EvidenceOutcome; - createdAt: string; - memoryId?: string; - reasonCodes: string[]; - }>; -}; - -type Command = "health" | "quality" | "coverage" | "disappearances" | "rejections" | "audit" | "explain" | "trace"; -type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown"; - -type CliOptions = { - raw: boolean; - json?: boolean; - workspace?: string; - all?: boolean; - softOnly?: boolean; - triggerOnly?: boolean; - includeHistorical?: boolean; - quality?: boolean; - reason?: string; - unique?: boolean; - explain?: boolean; - since?: string; - migration?: string; - memory?: string; -}; - -type RejectionLogRecord = { - timestamp?: string; - workspaceKey?: string; - workspaceRoot?: string; - workspaceRootHash?: string; - type?: LongTermType; - source?: LongTermSource | string; - origin?: string; - fromTrigger?: boolean; - text?: string; - reasons?: string[]; -}; - -type NormalizedRejection = Required> & { - workspaceKey?: string; - workspaceRoot?: string; - workspaceRootHash?: string; - source?: string; - origin: Origin; - fromTrigger: boolean; -}; - -type MigrationLogRecord = { - migrationId?: string; - timestamp?: string; - workspaceKey?: string; - workspaceRoot?: string; - entryId?: string; - type?: LongTermType; - source?: LongTermSource | string; - text?: string; - reasons?: string[]; - hardReasons?: string[]; - beforeStatus?: string; - afterStatus?: string; -}; - -const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"]; -const SUSPICIOUS_REASONS = [ - "progress_snapshot", - "active_file_snapshot", - "commit_or_ci_snapshot", - "temporary_status", - "raw_error", - "code_or_api_signature", -] as const; -const ALLOWED_ORIGINS = new Set([ - "explicit_trigger", - "compaction_candidate", - "manual", - "migration_check", - "unknown", -]); - -function usage(): string { - return `Usage: - bun scripts/memory-diag.ts health [--workspace ] [--all] [--raw] [--json] - bun scripts/memory-diag.ts quality [--workspace ] [--raw] [--json] - bun scripts/memory-diag.ts coverage [--workspace ] [--include-historical] [--raw] [--json] - bun scripts/memory-diag.ts disappearances [--workspace ] [--explain] [--raw] [--json] - bun scripts/memory-diag.ts rejections --quality [--workspace ] [--reason ] [--unique] [--json] [--raw] - bun scripts/memory-diag.ts explain [--workspace ] [--raw] - bun scripts/memory-diag.ts trace --memory [--workspace ] [--raw] - bun scripts/memory-diag.ts rejections [--soft-only] [--trigger-only] [--since 14d] [--reason ] [--unique] [--workspace ] [--raw] - bun scripts/memory-diag.ts audit [--migration ] [--raw] -`; +function isParsedError(parsed: ParsedArgs): parsed is ParsedError { + return parsed.ok === false; } -function die(message: string): never { - console.error(message); - console.error(usage()); - process.exit(1); +function isParsedHelp(parsed: ParsedArgs): parsed is ParsedHelp { + return parsed.ok === true && "help" in parsed; } -function parseArgs(argv: string[]): { command: Command; options: CliOptions } { - const [command, ...rest] = argv; - if (!command || command === "--help" || command === "-h") { - console.log(usage()); - process.exit(0); +async function main(): Promise { + const parsed = parseArgs(process.argv.slice(2)); + if (isParsedError(parsed)) { + console.error(parsed.message); + console.error(parsed.usage); + return parsed.exitCode; } - if (command !== "health" && command !== "quality" && command !== "coverage" && command !== "disappearances" && command !== "rejections" && command !== "audit" && command !== "explain" && command !== "trace") { - die(`Unknown subcommand: ${command}`); + if (isParsedHelp(parsed)) { + console.log(parsed.usage); + return 0; } - const options: CliOptions = { raw: false }; - for (let i = 0; i < rest.length; i += 1) { - const arg = rest[i]; - if (arg === "--raw") options.raw = true; - else if (arg === "--json") options.json = true; - else if (arg === "--all") options.all = true; - 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]; - if (!value) die("--workspace requires a path"); - options.workspace = value; - } else if (arg === "--since") { - const value = rest[++i]; - if (!value) die("--since requires a duration or ISO timestamp"); - options.since = value; - } else if (arg === "--reason") { - const value = rest[++i]; - if (!value) die("--reason requires a reason code"); - options.reason = value; - } else if (arg === "--migration") { - const value = rest[++i]; - if (!value) die("--migration requires an id"); - options.migration = value; - } else if (arg === "--memory") { - const value = rest[++i]; - if (!value) die("--memory requires an id"); - options.memory = value; - } else { - die(`Unknown option: ${arg}`); - } - } - - if (command === "health") { - if (options.all && options.workspace) die("Use either --all or --workspace, not both"); - if (options.json && options.all) die("health --json does not support --all"); - } else if (command === "explain" || command === "trace") { - if (options.all) die(`${command} does not accept --all`); - } else if (command === "quality" || command === "coverage" || command === "disappearances" || command === "rejections") { - if (options.all) die(`${command} does not accept --all`); - } else { - if (options.all || options.workspace) die(`${command} does not accept --all or --workspace`); - } - if (options.json && command !== "health" && command !== "quality" && command !== "coverage" && command !== "disappearances" && !(command === "rejections" && options.quality)) { - die(`${command} does not accept --json`); - } - if (command !== "rejections" && (options.softOnly || options.triggerOnly || options.since)) { - die(`${command} does not accept rejection filters`); - } - if (command !== "coverage" && options.includeHistorical) die(`${command} does not accept --include-historical`); - if (command !== "rejections" && (options.quality || options.reason || options.unique)) die(`${command} does not accept rejection quality filters`); - if (command !== "disappearances" && options.explain) die(`${command} does not accept --explain`); - if (command === "rejections" && options.json && !options.quality) die("rejections --json requires --quality"); - if (command !== "audit" && options.migration) { - die(`${command} does not accept --migration`); - } - if (command !== "trace" && options.memory) { - die(`${command} does not accept --memory`); - } - if (command === "trace" && !options.memory) { - die("--memory requires an id"); - } - - return { command, options }; -} - -function countBy(items: T[]): Map { - const counts = new Map(); - for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1); - return counts; -} - -function sortedCounts(counts: Map): Array<[T, number]> { - return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); -} - -function workspaceRootHash(root: string): string { - return createHash("sha256").update(root).digest("hex").slice(0, 16); -} - -function redactAbsolutePaths(text: string): string { - return text.replace(/(?:^|[\s"'`(=:\[])(\/(?:Users|home|private|tmp|var|opt|Volumes|[^\s"'`)\],;:]+)\/[^\s"'`)\],;:]*)/g, (match, path) => match.replace(path, "")); -} - -function cleanText(text: string, raw: boolean): string { - if (raw) return text; - return redactAbsolutePaths(redactCredentials(text)); -} - -function cleanPath(path: string, raw: boolean): string { - return raw ? path : ""; -} - -function formatWorkspaceIdentity(workspaceKeyValue: string | undefined, workspaceRoot: string | undefined, raw: boolean): string { - const parts: string[] = []; - if (workspaceKeyValue) parts.push(`workspaceKey=${workspaceKeyValue}`); - if (workspaceRoot) { - parts.push(raw ? `workspaceRoot=${workspaceRoot}` : `workspaceRootHash=${workspaceRootHash(workspaceRoot)}`); - } - return parts.join(" "); -} - -function truncate(text: string, max = 120): string { - const collapsed = text.replace(/\s+/g, " ").trim(); - return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`; -} - -async function readJSONFile(path: string): Promise { try { - return JSON.parse(await readFile(path, "utf8")) as T; - } catch { - return null; - } -} - -async function readJSONLFile(path: string): Promise<{ records: T[]; invalidLines: number }> { - let content = ""; - try { - content = await readFile(path, "utf8"); - } catch { - return { records: [], invalidLines: 0 }; - } - - const records: T[] = []; - let invalidLines = 0; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - records.push(JSON.parse(trimmed) as T); - } catch { - invalidLines += 1; + const result = await dispatch(parsed.command, parsed.options); + const stderr = [parsed.deprecationNotice, result.stderr].filter(Boolean).join("\n"); + if (stderr) process.stderr.write(stderr.endsWith("\n") ? stderr : `${stderr}\n`); + if (result.stdout) process.stdout.write(result.stdout.endsWith("\n") ? result.stdout : `${result.stdout}\n`); + return result.exitCode ?? 0; + } catch (error) { + if (error instanceof CliInputError) { + process.stderr.write(`${error.message}\n`); + return 1; } - } - return { records, invalidLines }; -} - -function canonicalMemoryText(text: string): string { - return text - .normalize("NFKC") - .toLowerCase() - .replace(/[\s\p{P}]+/gu, " ") - .trim(); -} - -function ageDays(entry: LongTermMemoryEntry): number | null { - const time = new Date(entry.createdAt).getTime(); - if (Number.isNaN(time)) return null; - return Math.floor((Date.now() - time) / 86_400_000); -} - -function formatStrength(value: number): string { - return Number.isFinite(value) ? value.toFixed(3) : "0.000"; -} - -function daysSinceIso(value: string | undefined, now = Date.now()): number | null { - if (!value) return null; - const ms = new Date(value).getTime(); - if (!Number.isFinite(ms)) return null; - return Math.max(0, (now - ms) / 86_400_000); -} - -function formatPercent(ratio: number): string { - return `${(ratio * 100).toFixed(1)}%`; -} - -type RetentionDiagItem = { - entry: LongTermMemoryEntry; - strength: number; -}; - -function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean { - return entry.safetyCritical === true; -} - -function retentionCandidatesForDiag(store: WorkspaceMemoryStore): { - sorted: RetentionDiagItem[]; - rendered: RetentionDiagItem[]; - typeCapped: RetentionDiagItem[]; - globalCapped: RetentionDiagItem[]; -} { - const now = Date.now(); - const active = store.entries.filter(entry => entry.status !== "superseded"); - const sorted = active - .map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) })) - .sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id)); - - const rendered: RetentionDiagItem[] = []; - const typeCapped: RetentionDiagItem[] = []; - const globalCapped: RetentionDiagItem[] = []; - const typeCounts: Partial> = {}; - - for (const item of sorted) { - const count = typeCounts[item.entry.type] ?? 0; - const max = RETENTION_TYPE_MAX[item.entry.type] ?? Infinity; - if (count >= max) { - typeCapped.push(item); - continue; + if (error instanceof Error) { + process.stderr.write(`memory-diag failed: ${error.message}\n`); + return 1; } - typeCounts[item.entry.type] = count + 1; - - if (rendered.length < LONG_TERM_LIMITS.maxEntries) { - rendered.push(item); - } else { - globalCapped.push(item); - } - } - - return { sorted, rendered, typeCapped, globalCapped }; -} - -function promotionLimit(source: LongTermSource): number { - if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts; - return PROMOTION_RETRY_LIMITS.maxExplicitAttempts; -} - -function emptyStore(root: string, key: string): WorkspaceMemoryStore { - return { - version: 1, - workspace: { root, key }, - limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, - entries: [], - migrations: [], - updatedAt: new Date(0).toISOString(), - }; -} - -function normalizedStore(store: WorkspaceMemoryStore | null, root: string, key: string): WorkspaceMemoryStore { - const fallback = emptyStore(root, key); - return { - ...fallback, - ...(store ?? {}), - workspace: store?.workspace ?? fallback.workspace, - limits: { - maxRenderedChars: store?.limits?.maxRenderedChars ?? fallback.limits.maxRenderedChars, - maxEntries: store?.limits?.maxEntries ?? fallback.limits.maxEntries, - }, - entries: Array.isArray(store?.entries) ? store.entries : [], - migrations: Array.isArray(store?.migrations) ? store.migrations : [], - }; -} - -function normalizedJournal(journal: PendingMemoryJournalStore | null): PendingMemoryJournalStore { - return { - version: 1, - workspace: journal?.workspace ?? { root: "", key: "" }, - entries: Array.isArray(journal?.entries) ? journal.entries : [], - updatedAt: journal?.updatedAt ?? new Date(0).toISOString(), - }; -} - -type WorkspaceDiagSnapshot = { - store: WorkspaceMemoryStore; - journal: PendingMemoryJournalStore; - retention: ReturnType; - memories: MemoryDiagJSON["memories"]; - recentEvents: MemoryDiagJSON["recentEvents"]; - allEvents: EvidenceEventV1[]; - summary: MemoryDiagJSON["summary"]; -}; - -type MemoryInspectionReadModel = { - store: WorkspaceMemoryStore; - pending: PendingMemoryJournalStore; - evidenceEvents: EvidenceEventV1[]; - rejectionRecords: NormalizedRejection[]; - currentById: Map; - evidenceByMemoryId: Map; -}; - -type CoverageClass = "full_lifecycle" | "render_only" | "no_evidence" | "historical_absent_with_reason" | "historical_absent_unknown_reason"; - -type DisappearanceReason = { - classification: "historical_absent_with_reason" | "historical_absent_unknown_reason"; - terminalType: EvidenceEventType | "unknown"; - reasonCodes: string[]; - event?: EvidenceEventV1; -}; - -function uniqueStrings(values: string[]): string[] { - return [...new Set(values.filter(Boolean))]; -} - -function eventMemoryId(event: EvidenceEventV1): string | undefined { - return event.memory?.memoryId - ?? event.relations?.map(relation => relation.memory?.memoryId).find((id): id is string => Boolean(id)); -} - -function eventMemoryIds(event: EvidenceEventV1): string[] { - return uniqueStrings([ - event.memory?.memoryId ?? "", - ...(event.relations ?? []).map(relation => relation.memory?.memoryId ?? ""), - ]); -} - -function isWithinDays(iso: string, days: number): boolean { - const ms = new Date(iso).getTime(); - return Number.isFinite(ms) && ms >= Date.now() - days * 86_400_000; -} - -function renderStatusReason(status: MemoryRenderStatus, fallback?: string): string[] { - switch (status) { - case "rendered": return ["within_caps", "within_char_budget"]; - case "omitted_superseded": return ["superseded"]; - case "omitted_type_cap": return ["type_cap"]; - case "omitted_global_cap": return ["global_cap"]; - case "omitted_char_budget": return [fallback === "empty_render_budget" ? "empty_render_budget" : "char_budget"]; - case "omitted_absorbed_duplicate": return ["absorbed_duplicate"]; - case "pending_retry": return ["retryable_capacity_rejection"]; - case "pending_rejected_capacity": return ["capacity_rejected", "max_attempts_reached"]; - case "quarantined_corrupt_store": return ["invalid_json"]; + process.stderr.write(`memory-diag failed: ${String(error)}\n`); + return 1; } } -function statusFromOmissionReason(reason: string | undefined): MemoryRenderStatus { - if (reason === "superseded") return "omitted_superseded"; - if (reason === "type_cap") return "omitted_type_cap"; - if (reason === "global_cap") return "omitted_global_cap"; - return "omitted_char_budget"; -} - -function pendingStatus(entry: LongTermMemoryEntry): MemoryRenderStatus { - const attempts = entry.promotionAttempts ?? 0; - return attempts >= promotionLimit(entry.source) ? "pending_rejected_capacity" : "pending_retry"; -} - -function safeTextPreview(text: string): string { - return truncate(cleanText(text, false), 120); -} - -async function buildWorkspaceDiagSnapshot(input: { - root: string; - key: string; - memoryPath: string; - pendingPath: string; -}): Promise { - const rawStore = await readJSONFile(input.memoryPath); - const storeRoot = rawStore?.workspace?.root ?? input.root; - const storeKey = rawStore?.workspace?.key ?? input.key; - const store = normalizedStore(rawStore, storeRoot, storeKey); - const journal = normalizedJournal(await readJSONFile(input.pendingPath)); - const retention = retentionCandidatesForDiag(store); - const renderAccounting = accountWorkspaceMemoryRender(store); - const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id)); - const omittedById = new Map(renderAccounting.omitted.map(item => [item.memory.id, item.reason])); - const allEvents = await queryEvidenceEvents(input.root); - const recentEvidence = await queryEvidenceEvents(input.root, { newestFirst: true, limit: 50 }); - const memoryRows: MemoryDiagJSON["memories"] = []; - const seenIds = new Set(); - - for (const entry of store.entries) { - const omissionReason = omittedById.get(entry.id); - const status: MemoryRenderStatus = renderedIds.has(entry.id) - ? "rendered" - : statusFromOmissionReason(omissionReason ?? (entry.status === "superseded" ? "superseded" : undefined)); - const summary = await summarizeMemoryEvidence(input.root, { memoryId: entry.id }); - memoryRows.push({ - id: entry.id, - type: entry.type, - source: entry.source, - status, - strength: calculateRetentionStrength(entry, Date.now(), store.lastActivityAt), - reasonCodes: uniqueStrings([...renderStatusReason(status, omissionReason), ...summary.reasonCodes]), - textPreview: safeTextPreview(entry.text), - evidenceEventIds: summary.eventIds, - }); - seenIds.add(entry.id); - } - - for (const entry of journal.entries) { - const status = pendingStatus(entry); - const summary = await summarizeMemoryEvidence(input.root, { memoryId: entry.id }); - memoryRows.push({ - id: entry.id, - type: entry.type, - source: entry.source, - status, - strength: calculateRetentionStrength(entry, Date.now(), store.lastActivityAt), - reasonCodes: uniqueStrings([...renderStatusReason(status), entry.lastPromotionFailureReason ?? "", ...summary.reasonCodes]), - textPreview: safeTextPreview(entry.text), - evidenceEventIds: summary.eventIds, - }); - seenIds.add(entry.id); - } - - for (const event of allEvents) { - if (event.outcome !== "absorbed") continue; - const memory = event.memory; - if (!memory?.memoryId || !memory.type || !memory.source || seenIds.has(memory.memoryId)) continue; - const summary = await summarizeMemoryEvidence(input.root, { memoryId: memory.memoryId }); - memoryRows.push({ - id: memory.memoryId, - type: memory.type, - source: memory.source, - status: "omitted_absorbed_duplicate", - reasonCodes: uniqueStrings([...renderStatusReason("omitted_absorbed_duplicate"), ...summary.reasonCodes]), - evidenceEventIds: summary.eventIds.length > 0 ? summary.eventIds : [event.eventId], - }); - seenIds.add(memory.memoryId); - } - - const recentEvents = recentEvidence.map(event => ({ - eventId: event.eventId, - type: event.type, - outcome: event.outcome, - createdAt: event.createdAt, - memoryId: eventMemoryId(event), - reasonCodes: uniqueStrings([ - ...event.reasonCodes, - ...(event.type === "storage_corrupt_json_quarantined" ? ["quarantined_corrupt_store"] : []), - ]), - })); - - return { - store, - journal, - retention, - memories: memoryRows, - recentEvents, - allEvents, - summary: { - storedActive: store.entries.filter(entry => entry.status !== "superseded").length, - rendered: retention.rendered.length, - pending: journal.entries.length, - rejectedLast7Days: allEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7)).length, - corruptStoresQuarantinedLast30Days: allEvents.filter(event => event.type === "storage_corrupt_json_quarantined" && isWithinDays(event.createdAt, 30)).length, - }, - }; -} - -async function buildMemoryDiagJSON(root: string): Promise { - const key = await workspaceKey(root); - const snapshot = await buildWorkspaceDiagSnapshot({ - root, - key, - memoryPath: await workspaceMemoryPath(root), - pendingPath: await workspacePendingJournalPath(root), - }); - - return { - version: 1, - workspace: { rootHash: workspaceRootHash(snapshot.store.workspace.root || root), key: snapshot.store.workspace.key || key }, - generatedAt: new Date().toISOString(), - summary: snapshot.summary, - memories: snapshot.memories, - recentEvents: snapshot.recentEvents, - }; -} - -async function runHealth(options: CliOptions): Promise { - if (options.json) { - const root = options.workspace ?? process.cwd(); - console.log(JSON.stringify(await buildMemoryDiagJSON(root), null, 2)); - return; - } - - if (options.all) { - const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 }); - console.log("Workspace memory health"); - console.log(""); - if (scan.results.length === 0) { - console.log("No workspace stores found."); - return; - } - for (let i = 0; i < scan.results.length; i += 1) { - const result = scan.results[i]; - if (i > 0) console.log(""); - await printWorkspaceHealth({ - 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; - } - - const root = options.workspace ?? process.cwd(); - const key = await workspaceKey(root); - await printWorkspaceHealth({ - root, - key, - memoryPath: await workspaceMemoryPath(root), - pendingPath: await workspacePendingJournalPath(root), - raw: options.raw, - includeTitle: true, - }); -} - -async function printWorkspaceHealth(input: { - root?: string; - key: string; - memoryPath: string; - pendingPath: string; - raw: boolean; - includeTitle?: boolean; -}): Promise { - if (input.includeTitle) { - console.log("Workspace memory health"); - console.log(""); - } - - const rawStore = await readJSONFile(input.memoryPath); - const storeRoot = rawStore?.workspace?.root ?? input.root ?? ""; - const storeKey = rawStore?.workspace?.key ?? input.key; - const store = normalizedStore(rawStore, storeRoot, storeKey); - const journal = normalizedJournal(await readJSONFile(input.pendingPath)); - - const identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw); - if (identity) console.log(identity); - console.log(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`); - console.log(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`); - if (!rawStore) console.log("memory store: missing or unreadable (treated as empty)"); - if (!existsSync(input.pendingPath)) console.log("pending journal: missing (treated as empty)"); - console.log(""); - - const active = store.entries.filter(entry => entry.status !== "superseded"); - const superseded = store.entries.filter(entry => entry.status === "superseded"); - const retention = retentionCandidatesForDiag(store); - const renderedEntries = retention.rendered.map(item => item.entry); - const renderedEstimate = renderWorkspaceMemory(store).length; - - console.log(`Stored active memories: ${active.length}`); - console.log(`Superseded memories: ${superseded.length}`); - console.log(`Rendered candidates: ${renderedEntries.length}`); - console.log(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`); - console.log(""); - - 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)); - console.log("Pending journal:"); - console.log(` total: ${pendingEntries.length}`); - console.log(` retryable: ${retryable}`); - console.log(` near retry limit: ${nearRetryLimit}`); - console.log(" by source:"); - for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) { - console.log(` ${source}: ${pendingBySource.get(source) ?? 0}`); - } - console.log(""); - - console.log("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; - console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${RETENTION_TYPE_MAX[type]} superseded=${supersededCount}`); - } - console.log(""); - - console.log("Retention caps:"); - console.log(` type-capped entries: ${retention.typeCapped.length}`); - console.log(` global-cap overflow: ${retention.globalCapped.length}`); - console.log(""); - - const olderThan30 = active.filter(entry => (ageDays(entry) ?? 0) > 30).length; - const olderThan90 = active.filter(entry => (ageDays(entry) ?? 0) > 90).length; - const staleMarked = active.filter(entry => { - const days = ageDays(entry); - return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays); - }).length; - console.log("Age:"); - console.log(` stale-marked: ${staleMarked}`); - console.log(` older than 30d: ${olderThan30}`); - console.log(` older than 90d: ${olderThan90}`); - console.log(""); - - const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt); - const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS; - const dormantDaysPastGrace = wallDaysSinceActivity === null - ? 0 - : Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS); - console.log("Dormancy:"); - console.log(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`); - console.log(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`); - console.log(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`); - console.log(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`); - console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`); - console.log(""); - - 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; - console.log("Retention monitoring:"); - console.log(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`); - console.log(` safety_critical_count: ${safetyCriticalCount} (deprecated field)${safetyCriticalWarning ? " WARNING" : ""}`); - console.log(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`); - console.log(""); - - 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); - console.log("Quality warnings:"); - console.log(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`); - console.log(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`); - console.log(` duplicate-ish exact canonical text: ${duplicateExtras}`); - console.log(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`); - console.log(""); - - console.log("Suspicious active memories:"); - for (const reason of SUSPICIOUS_REASONS) { - console.log(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`); - } - - const failingQuality = qualityByEntry.filter(item => !item.quality.accepted); - if (failingQuality.length > 0) { - console.log(""); - console.log("Active memories failing offline quality checks:"); - for (const item of failingQuality.slice(0, 8)) { - console.log(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`); - } - } - - console.log(""); - console.log("Top rendered candidates:"); - const top = retention.rendered.slice(0, 5); - if (top.length === 0) { - console.log(" (none)"); - } else { - for (const item of top) { - console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`); - } - } - - console.log(""); - console.log("Weakest active memories:"); - const weakest = retention.sorted.slice(-5).reverse(); - if (weakest.length === 0) { - console.log(" (none)"); - } else { - for (const item of weakest) { - console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`); - } - } -} - -function inferOrigin(record: RejectionLogRecord): Origin { - if (record.origin && ALLOWED_ORIGINS.has(record.origin as Origin)) return record.origin as Origin; - if (record.source === "compaction") return "compaction_candidate"; - if (record.source === "explicit") return "explicit_trigger"; - if (record.source === "manual") return "manual"; - return "unknown"; -} - -function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | null { - if (!record.text || !Array.isArray(record.reasons)) return null; - const origin = inferOrigin(record); - return { - timestamp: record.timestamp ?? "", - workspaceKey: record.workspaceKey, - workspaceRoot: record.workspaceRoot, - workspaceRootHash: record.workspaceRootHash, - type: record.type ?? "project", - source: record.source, - origin, - fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger", - text: record.text, - reasons: record.reasons, - }; -} - -function sinceCutoff(rawSince: string | undefined): number | null { - if (!rawSince) return null; - const relative = rawSince.match(/^(\d+)([dhm])$/i); - if (relative) { - const amount = Number(relative[1]); - const unit = relative[2].toLowerCase(); - const multiplier = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : 60_000; - return Date.now() - amount * multiplier; - } - const timestamp = new Date(rawSince).getTime(); - if (Number.isNaN(timestamp)) die(`Invalid --since value: ${rawSince}`); - return timestamp; -} - -function hasSoftReason(record: NormalizedRejection): boolean { - return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason)); -} - -function hasWorkspaceScope(record: NormalizedRejection): boolean { - return Boolean(record.workspaceKey || record.workspaceRoot || record.workspaceRootHash); -} - -async function workspaceKeyForOption(options: CliOptions): Promise { - return options.workspace ? workspaceKey(options.workspace) : undefined; -} - -async function loadRejectionRecords(options: CliOptions): Promise<{ path: string; invalidLines: number; records: NormalizedRejection[] }> { - const path = extractionRejectionLogPath(); - const { records, invalidLines } = await readJSONLFile(path); - const cutoff = sinceCutoff(options.since); - const requestedWorkspaceKey = await workspaceKeyForOption(options); - let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null); - - if (requestedWorkspaceKey) { - normalized = normalized.filter(record => !record.workspaceKey || record.workspaceKey === requestedWorkspaceKey); - } - if (cutoff !== null) { - normalized = normalized.filter(record => { - const timestamp = new Date(record.timestamp).getTime(); - return !Number.isNaN(timestamp) && timestamp >= cutoff; - }); - } - if (options.softOnly) normalized = normalized.filter(hasSoftReason); - if (options.triggerOnly) normalized = normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger"); - if (options.reason) normalized = normalized.filter(record => record.reasons.includes(options.reason ?? "")); - - return { path, invalidLines, records: normalized }; -} - -function uniqueByCanonicalText(records: NormalizedRejection[]): NormalizedRejection[] { - const byText = new Map(); - for (const record of records) { - const key = `${record.type}:${canonicalMemoryText(record.text)}`; - if (!byText.has(key)) byText.set(key, record); - } - return [...byText.values()]; -} - -function objectFromCounts(counts: Map): Record { - return Object.fromEntries(sortedCounts(counts)); -} - -function groupEvidenceByMemoryId(events: EvidenceEventV1[]): Map { - const grouped = new Map(); - for (const event of events) { - for (const id of eventMemoryIds(event)) { - const bucket = grouped.get(id) ?? []; - bucket.push(event); - grouped.set(id, bucket); - } - } - return grouped; -} - -async function buildInspectionReadModel(options: CliOptions): Promise { - const snapshot = await snapshotForOptions(options); - const rejections = await loadRejectionRecords(options); - return { - store: snapshot.store, - pending: snapshot.journal, - evidenceEvents: snapshot.allEvents, - rejectionRecords: rejections.records, - currentById: new Map(snapshot.store.entries.map(entry => [entry.id, entry])), - evidenceByMemoryId: groupEvidenceByMemoryId(snapshot.allEvents), - }; -} - -function terminalDisappearanceReason(events: EvidenceEventV1[]): DisappearanceReason { - const terminal = [...events].reverse().find(event => - event.type === "memory_removed_capacity" - || event.type === "promotion_absorbed_exact" - || event.type === "promotion_absorbed_identity" - || event.type === "promotion_superseded" - ); - const renderOmission = [...events].reverse().find(event => event.type === "render_omitted"); - const event = terminal ?? renderOmission; - if (!event) { - return { classification: "historical_absent_unknown_reason", terminalType: "unknown", reasonCodes: [] }; - } - return { - classification: "historical_absent_with_reason", - terminalType: event.type, - reasonCodes: event.reasonCodes, - event, - }; -} - -function coverageClassForMemory(id: string, model: MemoryInspectionReadModel): CoverageClass { - const events = model.evidenceByMemoryId.get(id) ?? []; - if (!model.currentById.has(id)) return terminalDisappearanceReason(events).classification; - if (events.length === 0) return "no_evidence"; - if (events.every(event => event.phase === "render")) return "render_only"; - return "full_lifecycle"; -} - -function eventCounts(events: EvidenceEventV1[]): { total: number; byType: Record; byPhase: Record } { - return { - total: events.length, - byType: objectFromCounts(countBy(events.map(event => event.type))), - byPhase: objectFromCounts(countBy(events.map(event => event.phase))), - }; -} - -function coverageRows(model: MemoryInspectionReadModel, includeHistorical: boolean): Array<{ - id: string; - class: CoverageClass; - current: boolean; - type?: LongTermType; - eventCounts: ReturnType; -}> { - const ids = new Set(model.currentById.keys()); - if (includeHistorical) { - for (const id of model.evidenceByMemoryId.keys()) ids.add(id); - } - return [...ids].sort().map(id => { - const entry = model.currentById.get(id); - const events = model.evidenceByMemoryId.get(id) ?? []; - return { - id, - class: coverageClassForMemory(id, model), - current: Boolean(entry), - type: entry?.type ?? events.find(event => event.memory?.memoryId === id)?.memory?.type, - eventCounts: eventCounts(events), - }; - }); -} - -function disappearanceRows(model: MemoryInspectionReadModel): Array<{ - id: string; - classification: DisappearanceReason["classification"]; - terminalType: DisappearanceReason["terminalType"]; - reasonCodes: string[]; - event?: EvidenceEventV1; - events: EvidenceEventV1[]; -}> { - const rows = [...model.evidenceByMemoryId.entries()] - .filter(([id]) => !model.currentById.has(id)) - .map(([id, events]) => { - const reason = terminalDisappearanceReason(events); - return { id, ...reason, events }; - }); - return rows.sort((a, b) => a.classification.localeCompare(b.classification) || a.id.localeCompare(b.id)); -} - -function formatDetails(details: EvidenceEventV1["details"]): string { - if (!details || Object.keys(details).length === 0) return "none"; - return Object.entries(details).map(([key, value]) => `${key}=${Array.isArray(value) ? value.join("|") : String(value)}`).join(" "); -} - -function retentionClockSummary(entries: LongTermMemoryEntry[]): { present: number; missing: number; invalid: number } { - let present = 0; - let missing = 0; - let invalid = 0; - for (const entry of entries) { - if (entry.retentionClock === undefined) { - missing += 1; - } else if (!Number.isFinite(entry.retentionClock) || entry.retentionClock <= 0) { - invalid += 1; - } else { - present += 1; - } - } - return { present, missing, invalid }; -} - -function rejectionQualitySummary(records: NormalizedRejection[]): { - totalRecords: number; - uniqueTexts: number; - workspaceScopedCount: number; - legacyUnscopedCount: number; - reasonDistribution: Record; - uniqueReasonDistribution: Record; - possibleFalsePositiveGroups: Record; -} { - const uniqueRecords = uniqueByCanonicalText(records); - const badDecisionUnique = uniqueRecords.filter(record => record.reasons.includes("bad_decision")); - const groups: Record = { - architecture_like_possible_false_positive: { count: 0, samples: [] }, - clearly_garbage: { count: 0, samples: [] }, - ambiguous: { count: 0, samples: [] }, - }; - - for (const record of badDecisionUnique) { - const hardReasons = record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)); - const statusLike = /\b(?:implemented|added|updated|fixed|completed|reviewed|tests?|CI|commit|wave|phase|task|session)\b/i.test(record.text); - const group = isArchitectureLikeDecision(record.text) && hardReasons.length === 0 && !statusLike - ? "architecture_like_possible_false_positive" - : hardReasons.length > 0 || statusLike - ? "clearly_garbage" - : "ambiguous"; - groups[group].count += 1; - if (groups[group].samples.length < 5) groups[group].samples.push(truncate(cleanText(record.text, false), 120)); - } - - return { - totalRecords: records.length, - uniqueTexts: uniqueRecords.length, - workspaceScopedCount: records.filter(hasWorkspaceScope).length, - legacyUnscopedCount: records.filter(record => !hasWorkspaceScope(record)).length, - reasonDistribution: objectFromCounts(countBy(records.flatMap(record => record.reasons))), - uniqueReasonDistribution: objectFromCounts(countBy(uniqueRecords.flatMap(record => record.reasons))), - possibleFalsePositiveGroups: groups, - }; -} - -function rejectionFalsePositiveRisk(summary: ReturnType): "low" | "high" { - const possible = summary.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count; - return possible >= 3 || (summary.uniqueTexts > 0 && possible / summary.uniqueTexts >= 0.5) ? "high" : "low"; -} - -async function runCoverage(options: CliOptions): Promise { - const model = await buildInspectionReadModel(options); - const rows = coverageRows(model, options.includeHistorical === true); - const classCounts = objectFromCounts(countBy(rows.map(row => row.class))); - - if (options.json) { - console.log(JSON.stringify({ - version: 1, - generatedAt: new Date().toISOString(), - classCounts, - memories: rows, - }, null, 2)); - return; - } - - console.log("Memory evidence coverage"); - console.log(""); - console.log("Class counts:"); - for (const cls of ["full_lifecycle", "render_only", "no_evidence", "historical_absent_with_reason", "historical_absent_unknown_reason"] as CoverageClass[]) { - console.log(` ${cls}: ${classCounts[cls] ?? 0}`); - } - console.log(""); - console.log("Per-memory rows:"); - if (rows.length === 0) console.log(" (none)"); - for (const row of rows) { - const phases = row.eventCounts.byPhase; - console.log(` ${row.id} ${row.class} current=${row.current ? "yes" : "no"} total=${row.eventCounts.total} extraction=${phases.extraction ?? 0} promotion=${phases.promotion ?? 0} render=${phases.render ?? 0} storage=${phases.storage ?? 0}`); - } -} - -async function runDisappearances(options: CliOptions): Promise { - const model = await buildInspectionReadModel(options); - const rows = disappearanceRows(model); - - if (options.json) { - console.log(JSON.stringify({ - version: 1, - 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, - })), - }, null, 2)); - return; - } - - console.log("Memory disappearances"); - console.log(""); - if (rows.length === 0) { - console.log("No evidence-only memories found."); - return; - } - for (const row of rows) { - const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none"; - console.log(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`); - if (options.explain) { - console.log(` events: ${row.events.map(event => event.type).join(", ")}`); - if (row.event?.type === "memory_removed_capacity") { - console.log(` 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) { - console.log(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`); - } - } - } -} - -async function runQuality(options: CliOptions): Promise { - const model = await buildInspectionReadModel(options); - const active = model.store.entries.filter(entry => entry.status !== "superseded"); - const retention = retentionCandidatesForDiag(model.store); - 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, - }; - - if (options.json) { - console.log(JSON.stringify({ - version: 1, - generatedAt: new Date().toISOString(), - 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 }, - }, null, 2)); - return; - } - - console.log("Memory quality inspection"); - console.log(""); - console.log(summaryText); - console.log(""); - console.log("Caps:"); - console.log(` active: ${caps.active} / ${caps.maxEntries}`); - for (const type of TYPES) { - const count = caps.typeCounts[type] ?? 0; - const limit = RETENTION_TYPE_MAX[type]; - const marker = count >= limit ? " FULL" : ""; - console.log(` ${type}: ${count} / ${limit}${marker}`); - } - console.log(` rendered: ${caps.rendered}`); - console.log(` type-capped entries: ${caps.typeCapped}`); - console.log(` global-cap overflow: ${caps.globalCapped}`); - console.log(` caps full: ${caps.capsFull ? "yes" : "no"}`); - console.log(""); - console.log("Retention clocks:"); - console.log(` present: ${clocks.present}`); - console.log(` missing: ${clocks.missing}`); - console.log(` invalid: ${clocks.invalid}`); - console.log(""); - console.log("Evidence:"); - console.log(` current with evidence: ${evidence.currentWithEvidence}`); - console.log(` current without evidence: ${evidence.currentWithoutEvidence}`); - console.log(` evidence memory ids: ${evidence.evidenceMemoryIds}`); - console.log(` disappearances: ${evidence.disappearances}`); - console.log(` unknown disappearances: ${evidence.unknownDisappearances}`); - console.log(""); - console.log("Rejection scoping:"); - console.log(` total records: ${rejectionSummary.totalRecords}`); - console.log(` workspace scoped: ${rejectionSummary.workspaceScopedCount}`); - console.log(` legacy unscoped: ${rejectionSummary.legacyUnscopedCount}`); - console.log(` false-positive risk: ${falsePositiveRisk}`); -} - -async function runRejections(options: CliOptions): Promise { - const { path, invalidLines, records } = await loadRejectionRecords(options); - const normalized = options.unique ? uniqueByCanonicalText(records) : records; - - if (options.quality) { - const summary = rejectionQualitySummary(records); - if (options.json) { - console.log(JSON.stringify({ - version: 1, - generatedAt: new Date().toISOString(), - ...summary, - }, null, 2)); - return; - } - - console.log("Extraction rejection quality inspection"); - console.log(""); - console.log("Possible false-positive grouping is heuristic, not deterministic truth."); - console.log(`logPath=${cleanPath(path, options.raw)}`); - if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`); - console.log(""); - console.log(`Total records: ${summary.totalRecords}`); - console.log(`Unique texts: ${summary.uniqueTexts}`); - console.log(`Workspace scoped: ${summary.workspaceScopedCount}`); - console.log(`Legacy unscoped: ${summary.legacyUnscopedCount}`); - console.log(""); - console.log("Reason distribution (raw records):"); - for (const [reason, count] of Object.entries(summary.reasonDistribution)) console.log(` ${reason.padEnd(36)} ${count}`); - if (Object.keys(summary.reasonDistribution).length === 0) console.log(" (none)"); - console.log(""); - console.log("Reason distribution (unique text):"); - for (const [reason, count] of Object.entries(summary.uniqueReasonDistribution)) console.log(` ${reason.padEnd(36)} ${count}`); - if (Object.keys(summary.uniqueReasonDistribution).length === 0) console.log(" (none)"); - console.log(""); - console.log("Possible false-positive groups (heuristic, not deterministic):"); - for (const [group, data] of Object.entries(summary.possibleFalsePositiveGroups)) { - console.log(` ${group}: ${data.count}`); - for (const sample of data.samples) console.log(` - ${JSON.stringify(cleanText(sample, options.raw))}`); - } - return; - } - - console.log("Extraction rejection summary"); - console.log(""); - console.log(`logPath=${cleanPath(path, options.raw)}`); - if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`); - console.log(""); - console.log(`Total rejected: ${normalized.length}`); - console.log(""); - - console.log("By reason:"); - const byReason = sortedCounts(countBy(normalized.flatMap(record => record.reasons))); - if (byReason.length === 0) console.log(" (none)"); - else for (const [reason, count] of byReason) console.log(` ${reason.padEnd(24)} ${count}`); - console.log(""); - - console.log("By origin:"); - const byOrigin = sortedCounts(countBy(normalized.map(record => record.origin))); - if (byOrigin.length === 0) console.log(" (none)"); - else for (const [origin, count] of byOrigin) console.log(` ${origin.padEnd(24)} ${count}`); - console.log(""); - - console.log("Trigger-origin rejections (high priority for v1.5):"); - const triggerReasons = sortedCounts(countBy(normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons))); - if (triggerReasons.length === 0) console.log(" (none)"); - else for (const [reason, count] of triggerReasons) console.log(` ${reason.padEnd(24)} ${count}`); - console.log(""); - - console.log("Recent suspicious soft rejects:"); - const suspicious = normalized - .filter(hasSoftReason) - .sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0)) - .slice(0, 8); - if (suspicious.length === 0) { - console.log(" (none)"); - } else { - for (const record of suspicious) { - const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw); - console.log(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, options.raw)))}`); - console.log(` reasons: ${record.reasons.join(",")}`); - console.log(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`); - } - } -} - -function migrationLogsRoot(): string { - return join(dataHome(), "opencode-working-memory", "migration-logs"); -} - -async function migrationLogPaths(options: CliOptions): Promise { - if (options.migration) return [migrationLogPath(options.migration)]; - const root = migrationLogsRoot(); - let entries: string[] = []; - try { - entries = await readdir(root); - } catch { - return []; - } - return entries.filter(entry => entry.endsWith(".jsonl")).sort().map(entry => join(root, entry)); -} - -function migrationIdFromPath(path: string): string { - return path.split("/").pop()?.replace(/\.jsonl$/, "") ?? "unknown"; -} - -function riskySupersedeReasons(record: MigrationLogRecord): string[] { - const reasons: string[] = []; - const hardReasonsMissing = !Array.isArray(record.hardReasons); - const hardReasons = Array.isArray(record.hardReasons) ? record.hardReasons : []; - const qualityReasons = Array.isArray(record.reasons) ? record.reasons : []; - const text = record.text ?? ""; - - if (hardReasonsMissing || hardReasons.length === 0) reasons.push("missing_or_empty_hardReasons"); - if (qualityReasons.length > 0 && hardReasons.length === 0) reasons.push("soft_reasons_without_hardReasons"); - if (/\b(?:User|user|prefers|requires|wants|insists)\b|用戶|使用者|偏好|要求|不要|不刪除/u.test(text)) reasons.push("user_preference_marker"); - if (/\b(?:must|should|do not|never|is|are|follows)\b|必須|應該|採用|維持|需支援/iu.test(text)) reasons.push("durable_rule_marker"); - if ((record.type === "feedback" || record.type === "decision") && hardReasons.length === 1 && hardReasons[0] === "path_heavy") { - reasons.push("feedback_or_decision_path_heavy_only"); - } - - return reasons; -} - -async function runAudit(options: CliOptions): Promise { - const paths = await migrationLogPaths(options); - console.log("Migration audit report"); - console.log(""); - if (paths.length === 0) { - console.log("No migration logs found."); - return; - } - - for (let i = 0; i < paths.length; i += 1) { - const path = paths[i]; - const migrationId = options.migration ?? migrationIdFromPath(path); - const { records, invalidLines } = await readJSONLFile(path); - const superseded = records.filter(record => !record.afterStatus || record.afterStatus === "superseded"); - const hardReasons = superseded.flatMap(record => { - if (Array.isArray(record.hardReasons)) return record.hardReasons; - return Array.isArray(record.reasons) ? record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)) : []; - }); - const risky = superseded - .map(record => ({ record, reasons: riskySupersedeReasons(record) })) - .filter(item => item.reasons.length > 0); - - if (i > 0) console.log(""); - console.log(`Migration: ${migrationId}`); - console.log(`logPath=${cleanPath(path, options.raw)}`); - if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`); - console.log(`Superseded entries: ${superseded.length}`); - console.log(""); - - console.log("By hard reason:"); - const byHardReason = sortedCounts(countBy(hardReasons)); - if (byHardReason.length === 0) console.log(" (none)"); - else for (const [reason, count] of byHardReason) console.log(` ${reason.padEnd(24)} ${count}`); - console.log(""); - - console.log("Potentially risky supersedes:"); - console.log(` ${risky.length}`); - for (const item of risky.slice(0, 10)) { - const record = item.record; - const hard = Array.isArray(record.hardReasons) ? record.hardReasons : []; - const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw); - console.log(` - [${record.type ?? "unknown"}] hardReasons=${JSON.stringify(hard)} risk=${item.reasons.join(",")} ${JSON.stringify(truncate(cleanText(record.text ?? "", options.raw)))}`); - if (identity) console.log(` ${identity}`); - } - } -} - -async function snapshotForOptions(options: CliOptions): Promise { - const root = options.workspace ?? process.cwd(); - const key = await workspaceKey(root); - return buildWorkspaceDiagSnapshot({ - root, - key, - memoryPath: await workspaceMemoryPath(root), - pendingPath: await workspacePendingJournalPath(root), - }); -} - -function formatEvidenceRefs(eventIds: string[], allEvents: EvidenceEventV1[]): string { - if (eventIds.length === 0) return "(none)"; - const byId = new Map(allEvents.map(event => [event.eventId, event])); - return eventIds - .map(id => { - const event = byId.get(id); - return event ? `${event.eventId} ${event.type}` : id; - }) - .join(", "); -} - -async function runExplain(options: CliOptions): Promise { - const snapshot = await snapshotForOptions(options); - console.log("Workspace memory explainability"); - console.log(""); - - if (snapshot.memories.length === 0) { - console.log("No memories found."); - } - - for (const memory of snapshot.memories) { - console.log(`Memory ${memory.id}: ${memory.status}`); - const strength = typeof memory.strength === "number" ? formatStrength(memory.strength) : "n/a"; - console.log(`- strength=${strength}, type=${memory.type}, source=${memory.source}`); - console.log(`- reasons: ${memory.reasonCodes.length > 0 ? memory.reasonCodes.join(", ") : "(none)"}`); - console.log(`- evidence: ${formatEvidenceRefs(memory.evidenceEventIds, snapshot.allEvents)}`); - console.log(""); - } - - const quarantines = snapshot.recentEvents.filter(event => event.type === "storage_corrupt_json_quarantined"); - if (quarantines.length > 0) { - console.log("Quarantined stores:"); - for (const event of quarantines) { - console.log(`- quarantined_corrupt_store: ${event.eventId} ${event.type}; reasons=${event.reasonCodes.join(",")}`); - } - } -} - -function statusFromTraceEvent(event: EvidenceEventV1 | undefined): string { - if (!event) return "unknown"; - if (event.type === "render_selected") return "rendered"; - if (event.type === "render_omitted") return statusFromOmissionReason(event.reasonCodes[0]); - if (event.type === "promotion_absorbed_exact" || event.type === "promotion_absorbed_identity") return "omitted_absorbed_duplicate"; - if (event.type === "promotion_retry_scheduled") return "pending_retry"; - if (event.type === "promotion_rejected_capacity" || event.type === "promotion_retry_exhausted") return "pending_rejected_capacity"; - if (event.type === "storage_corrupt_json_quarantined") return "quarantined_corrupt_store"; - if (event.outcome === "superseded") return "omitted_superseded"; - return event.outcome; -} - -function formatTraceEvent(event: EvidenceEventV1): string { - const reasons = event.reasonCodes.length > 0 ? event.reasonCodes.join(",") : "none"; - const relations = (event.relations ?? []) - .map(relation => relation.memory?.memoryId ? `${relation.role}=${relation.memory.memoryId}` : undefined) - .filter((value): value is string => Boolean(value)); - const relationText = relations.length > 0 ? `; ${relations.join(", ")}` : ""; - return `- ${event.eventId} ${event.type}: ${event.outcome}; reasons=${reasons}${relationText}`; -} - -function relationMemoryIds(events: EvidenceEventV1[], role: string): string[] { - return uniqueStrings(events.flatMap(event => (event.relations ?? []) - .filter(relation => relation.role === role) - .map(relation => relation.memory?.memoryId ?? ""))); -} - -async function runTrace(options: CliOptions): Promise { - const root = options.workspace ?? process.cwd(); - const memoryId = options.memory; - if (!memoryId) die("--memory requires an id"); - - const [snapshot, trace] = await Promise.all([ - snapshotForOptions(options), - traceMemoryLifecycle(root, { memoryId }), - ]); - const memoryRow = snapshot.memories.find(memory => memory.id === memoryId); - const status = memoryRow?.status ?? statusFromTraceEvent(trace.events.at(-1)); - - console.log(`Memory ${memoryId}: ${status}`); - console.log(""); - console.log("Lifecycle:"); - if (trace.events.length === 0) { - console.log("(none)"); - return; - } - - for (const event of trace.events) { - console.log(formatTraceEvent(event)); - } - - const supersededBy = relationMemoryIds(trace.events, "superseded_by"); - if (supersededBy.length > 0) { - console.log(""); - console.log("Superseded by:"); - for (const id of supersededBy) console.log(`- ${id}`); - } - - const reinforcedBy = relationMemoryIds(trace.events, "reinforced_by"); - if (reinforcedBy.length > 0) { - console.log(""); - console.log("Reinforced by:"); - for (const id of reinforcedBy) console.log(`- ${id}`); - } -} - -const { command, options } = parseArgs(process.argv.slice(2)); - -if (command === "health") await runHealth(options); -else if (command === "quality") await runQuality(options); -else if (command === "coverage") await runCoverage(options); -else if (command === "disappearances") await runDisappearances(options); -else if (command === "rejections") await runRejections(options); -else if (command === "audit") await runAudit(options); -else if (command === "explain") await runExplain(options); -else await runTrace(options); +process.exitCode = await main(); diff --git a/scripts/memory-diag/cli.ts b/scripts/memory-diag/cli.ts new file mode 100644 index 0000000..5772b48 --- /dev/null +++ b/scripts/memory-diag/cli.ts @@ -0,0 +1,175 @@ +import type { CliOptions, Command, LegacyCommand, ParsedArgs } from "./types.ts"; + +export type { ParsedArgs } from "./types.ts"; + +export function usage(): string { + return `Usage: + memory-diag [status] [--workspace ] [--verbose] [--json] + memory-diag rejected [--workspace ] [--verbose] [--json] + memory-diag missing [--workspace ] [--verbose] [--json] + memory-diag explain [memory-id] [--workspace ] [--raw] + +Global options: + --workspace Workspace path (default: current directory) + --verbose Show detailed diagnostics + --json Print machine-readable JSON where supported + --no-emoji Disable emoji in human output +`; +} + +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()); +} + +export function parseArgs(argv: string[]): ParsedArgs { + const [first, ...tail] = argv; + if (first === "--help" || first === "-h") { + return { ok: true, help: true, usage: usage() }; + } + + 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 ' is now 'explain '. This alias will be removed in v2.0."; + } + } 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; + else if (arg === "--json") options.json = true; + else if (arg === "--verbose") options.verbose = true; + else if (arg === "--no-emoji") options.noEmoji = true; + else if (arg === "--all") options.all = true; + 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]; + if (!value) return error("--workspace requires a path"); + options.workspace = value; + } else if (arg === "--since") { + const value = rest[++i]; + if (!value) return error("--since requires a duration or ISO timestamp"); + options.since = value; + } else if (arg === "--reason") { + const value = rest[++i]; + if (!value) return error("--reason requires a reason code"); + options.reason = value; + } else if (arg === "--migration") { + const value = rest[++i]; + if (!value) return error("--migration requires an id"); + options.migration = value; + } else if (arg === "--memory") { + const value = rest[++i]; + if (!value) return error("--memory requires an id"); + options.memory = value; + } else if (!arg.startsWith("--") && command === "explain" && legacyCommand !== "trace") { + 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"); + if (positional.length === 1 && options.memory) return error("Use either explain or --memory, not both"); + if (positional.length === 1) options.memory = positional[0]; + } else if ((options.positional ?? []).length > 0) { + 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`); + } else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain") { + if (options.all) return error(`${displayCommand} does not accept --all`); + } else { + if (options.all || options.workspace) return error(`${displayCommand} does not accept --all or --workspace`); + } + if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage") { + return error(`${displayCommand} 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`); + } + 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 !== "audit" && options.migration) { + return error(`${displayCommand} does not accept --migration`); + } + if (legacyCommand === "trace" && !options.memory) { + return error("--memory requires an id"); + } + if (command !== "explain" && options.memory) { + return error(`${displayCommand} does not accept --memory`); + } + if (command === "rejected" && options.since && !isValidSince(options.since)) { + return error(`Invalid --since value: ${options.since}`); + } + + return { ok: true, command, options, deprecationNotice }; +} diff --git a/scripts/memory-diag/command-registry.ts b/scripts/memory-diag/command-registry.ts new file mode 100644 index 0000000..4c95ff6 --- /dev/null +++ b/scripts/memory-diag/command-registry.ts @@ -0,0 +1,29 @@ +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 { + 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); + case "missing": return runMissing(options); + case "coverage": return runCoverage(options); + case "audit": return runAudit(options); + case "explain": return runExplain(options); + } +} diff --git a/scripts/memory-diag/commands/audit.ts b/scripts/memory-diag/commands/audit.ts new file mode 100644 index 0000000..d16dbad --- /dev/null +++ b/scripts/memory-diag/commands/audit.ts @@ -0,0 +1,24 @@ +import { HARD_QUALITY_REASONS } from "../../../src/memory-quality.ts"; +import { formatMigrationAudit, type MigrationAuditReport } from "../formatters/audit.ts"; +import { readJSONLFile } from "../io.ts"; +import { migrationIdFromPath, migrationLogPaths, riskySupersedeReasons } from "../migrations-model.ts"; +import type { CliOptions, CommandResult, MigrationLogRecord } from "../types.ts"; + +export async function runAudit(options: CliOptions): Promise { + const paths = await migrationLogPaths(options); + const reports: MigrationAuditReport[] = []; + for (const path of paths) { + const migrationId = options.migration ?? migrationIdFromPath(path); + const { records, invalidLines } = await readJSONLFile(path); + const superseded = records.filter(record => !record.afterStatus || record.afterStatus === "superseded"); + const hardReasons = superseded.flatMap(record => { + if (Array.isArray(record.hardReasons)) return record.hardReasons; + return Array.isArray(record.reasons) ? record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)) : []; + }); + const risky = superseded + .map(record => ({ record, reasons: riskySupersedeReasons(record) })) + .filter(item => item.reasons.length > 0); + reports.push({ migrationId, path, invalidLines, superseded, hardReasons, risky }); + } + return { stdout: formatMigrationAudit(reports, { raw: options.raw }) }; +} diff --git a/scripts/memory-diag/commands/coverage.ts b/scripts/memory-diag/commands/coverage.ts new file mode 100644 index 0000000..0b280bd --- /dev/null +++ b/scripts/memory-diag/commands/coverage.ts @@ -0,0 +1,14 @@ +import { buildInspectionReadModel, coverageRows } from "../inspection-model.ts"; +import { buildCoverageJSON, formatCoverage } from "../formatters/coverage.ts"; +import type { CliOptions, CommandResult } from "../types.ts"; + +export async function runCoverage(options: CliOptions): Promise { + const model = await buildInspectionReadModel(options); + const rows = coverageRows(model, options.includeHistorical === true); + + if (options.json) { + return { stdout: JSON.stringify(buildCoverageJSON(rows), null, 2) }; + } + + return { stdout: formatCoverage(rows) }; +} diff --git a/scripts/memory-diag/commands/disappearances.ts b/scripts/memory-diag/commands/disappearances.ts new file mode 100644 index 0000000..de567bd --- /dev/null +++ b/scripts/memory-diag/commands/disappearances.ts @@ -0,0 +1,14 @@ +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 { + 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 }) }; +} diff --git a/scripts/memory-diag/commands/explain.ts b/scripts/memory-diag/commands/explain.ts new file mode 100644 index 0000000..b7107a8 --- /dev/null +++ b/scripts/memory-diag/commands/explain.ts @@ -0,0 +1,11 @@ +import { formatExplain } from "../formatters/explain.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 { + if (options.memory) return runTrace(options); + + const snapshot = await snapshotForOptions(options); + return { stdout: formatExplain(snapshot) }; +} diff --git a/scripts/memory-diag/commands/health.ts b/scripts/memory-diag/commands/health.ts new file mode 100644 index 0000000..4cda542 --- /dev/null +++ b/scripts/memory-diag/commands/health.ts @@ -0,0 +1,59 @@ +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; + +async function workspaceHealthOutput(input: WorkspaceHealthCommandInput): Promise { + return formatWorkspaceHealth({ ...input, now: Date.now() }, { + rawStore: await readJSONFile(input.memoryPath), + rawJournal: await readJSONFile(input.pendingPath), + pendingExists: pathExists(input.pendingPath), + }); +} + +export async function runHealth(options: CliOptions): Promise { + 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, + }), + }; +} diff --git a/scripts/memory-diag/commands/missing.ts b/scripts/memory-diag/commands/missing.ts new file mode 100644 index 0000000..775ca1f --- /dev/null +++ b/scripts/memory-diag/commands/missing.ts @@ -0,0 +1,14 @@ +import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts"; +import { buildMissingJSON, formatMissing } from "../formatters/missing.ts"; +import type { CliOptions, CommandResult } from "../types.ts"; + +export async function runMissing(options: CliOptions): Promise { + const model = await buildInspectionReadModel(options); + const rows = disappearanceRows(model); + + if (options.json) { + return { stdout: JSON.stringify(buildMissingJSON(rows, { explain: options.explain }), null, 2) }; + } + + return { stdout: formatMissing(rows, { verbose: options.verbose, explain: options.explain }) }; +} diff --git a/scripts/memory-diag/commands/quality.ts b/scripts/memory-diag/commands/quality.ts new file mode 100644 index 0000000..13bd0fd --- /dev/null +++ b/scripts/memory-diag/commands/quality.ts @@ -0,0 +1,14 @@ +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 { + 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) }; +} diff --git a/scripts/memory-diag/commands/rejected.ts b/scripts/memory-diag/commands/rejected.ts new file mode 100644 index 0000000..c9df008 --- /dev/null +++ b/scripts/memory-diag/commands/rejected.ts @@ -0,0 +1,25 @@ +import { buildRejectedJSON, formatRejected } from "../formatters/rejected.ts"; +import { loadRejectionRecords, rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts"; +import type { CliOptions, CommandResult } from "../types.ts"; + +export async function runRejected(options: CliOptions): Promise { + const { path, invalidLines, records } = await loadRejectionRecords(options); + const summary = rejectionQualitySummary(records); + const falsePositiveRisk = rejectionFalsePositiveRisk(summary); + + if (options.json) { + return { stdout: JSON.stringify(buildRejectedJSON({ summary, falsePositiveRisk }), null, 2) }; + } + + return { + stdout: formatRejected({ + path, + invalidLines, + records, + summary, + falsePositiveRisk, + raw: options.raw, + verbose: options.verbose, + }), + }; +} diff --git a/scripts/memory-diag/commands/rejections.ts b/scripts/memory-diag/commands/rejections.ts new file mode 100644 index 0000000..129c4a6 --- /dev/null +++ b/scripts/memory-diag/commands/rejections.ts @@ -0,0 +1,19 @@ +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 { + 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 }) }; +} diff --git a/scripts/memory-diag/commands/status.ts b/scripts/memory-diag/commands/status.ts new file mode 100644 index 0000000..52c7e29 --- /dev/null +++ b/scripts/memory-diag/commands/status.ts @@ -0,0 +1,115 @@ +import { RETENTION_TYPE_MAX, WORKSPACE_DORMANT_AFTER_DAYS } from "../../../src/retention.ts"; +import { TYPES } from "../constants.ts"; +import { formatStatus, type StatusReadout } from "../formatters/status.ts"; +import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts"; +import { retentionCandidatesForDiag, retentionClockSummary, daysSinceIso } from "../retention-model.ts"; +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"); + 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 rejectedLast7Days = model.evidenceEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7, now)).length; + const evidenceCoveragePercent = active.length === 0 ? 100 : Math.round((evidenceCovered / active.length) * 100); + const needsAttention: string[] = []; + if (clocks.invalid > 0) needsAttention.push(`${clocks.invalid} invalid retention clocks`); + if (unknownDisappearances > 0) needsAttention.push(`${unknownDisappearances} memories disappeared without terminal evidence`); + if (capsFull) needsAttention.push("memory store is at or over retention caps"); + if (rejectionSummary.legacyUnscopedCount > 0) needsAttention.push(`${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records`); + if (falsePositiveRisk === "high") needsAttention.push("possible rejection false-positive risk is high"); + const suggestedNextSteps: string[] = []; + if (clocks.invalid > 0 || unknownDisappearances > 0) suggestedNextSteps.push("Run memory-diag missing --verbose to inspect missing-memory evidence."); + if (capsFull) suggestedNextSteps.push("Run memory-diag explain to see which memories are hidden by caps."); + if (rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high") suggestedNextSteps.push("Run memory-diag rejected --verbose to inspect rejection quality."); + const wallDaysSinceActivity = daysSinceIso(model.store.lastActivityAt, now); + const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS; + const dormantDaysPastGrace = wallDaysSinceActivity === null ? 0 : Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS); + + return { + status, + 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.`, + active: active.length, + rendered: retention.rendered.length, + pending: model.pending.entries.length, + rejectedLast7Days, + evidenceCoveragePercent, + needsAttention, + suggestedNextSteps, + caps: { + active: active.length, + maxEntries: model.store.limits.maxEntries, + rendered: retention.rendered.length, + typeCapped: retention.typeCapped.length, + globalCapped: retention.globalCapped.length, + typeCounts, + capsFull, + }, + retention: clocks, + evidence: { + currentWithEvidence: evidenceCovered, + currentWithoutEvidence: active.length - evidenceCovered, + evidenceMemoryIds: model.evidenceByMemoryId.size, + disappearances: disappearances.length, + unknownDisappearances, + withTerminalReason: disappearances.length - unknownDisappearances, + }, + rejections: { ...rejectionSummary, falsePositiveRisk }, + dormant: { + lastActivityAt: model.store.lastActivityAt, + wallDaysSinceActivity, + dormantDiscountActive, + dormantDaysPastGrace, + }, + topRendered: retention.rendered.slice(0, 5), + weakestActive: retention.sorted.slice(-5).reverse(), + }; +} + +export async function runStatus(options: CliOptions): Promise { + if (options.legacyCommand === "health" && options.all) return runHealth(options); + + const now = Date.now(); + const model = await buildInspectionReadModel(options); + const readout = buildStatusReadout(model, now); + + if (options.json) { + const root = options.workspace ?? process.cwd(); + const diag = memoryDiagJSONFromSnapshot(root, model.snapshot); + return { + stdout: JSON.stringify({ + ...diag, + summary: { + ...diag.summary, + status: readout.status, + evidenceCoveragePercent: readout.evidenceCoveragePercent, + needsAttention: readout.needsAttention, + suggestedNextSteps: readout.suggestedNextSteps, + }, + }, null, 2), + }; + } + + return { + stdout: formatStatus(readout, model, { + verbose: options.verbose, + noEmoji: options.noEmoji, + isTty: process.stdout.isTTY === true, + raw: options.raw, + }), + }; +} diff --git a/scripts/memory-diag/commands/trace.ts b/scripts/memory-diag/commands/trace.ts new file mode 100644 index 0000000..c22939d --- /dev/null +++ b/scripts/memory-diag/commands/trace.ts @@ -0,0 +1,16 @@ +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 { + 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) }; +} diff --git a/scripts/memory-diag/constants.ts b/scripts/memory-diag/constants.ts new file mode 100644 index 0000000..205cf4a --- /dev/null +++ b/scripts/memory-diag/constants.ts @@ -0,0 +1,21 @@ +import type { LongTermType } from "../../src/types.ts"; +import type { Origin } from "./types.ts"; + +export const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"]; + +export const SUSPICIOUS_REASONS = [ + "progress_snapshot", + "active_file_snapshot", + "commit_or_ci_snapshot", + "temporary_status", + "raw_error", + "code_or_api_signature", +] as const; + +export const ALLOWED_ORIGINS = new Set([ + "explicit_trigger", + "compaction_candidate", + "manual", + "migration_check", + "unknown", +]); diff --git a/scripts/memory-diag/evidence-model.ts b/scripts/memory-diag/evidence-model.ts new file mode 100644 index 0000000..2f9d2b8 --- /dev/null +++ b/scripts/memory-diag/evidence-model.ts @@ -0,0 +1,21 @@ +import type { EvidenceEventV1 } from "../../src/evidence-log.ts"; +import { uniqueStrings } from "./text.ts"; + +export function eventMemoryIds(event: EvidenceEventV1): string[] { + return uniqueStrings([ + event.memory?.memoryId ?? "", + ...(event.relations ?? []).map(relation => relation.memory?.memoryId ?? ""), + ]); +} + +export function groupEvidenceByMemoryId(events: EvidenceEventV1[]): Map { + const grouped = new Map(); + for (const event of events) { + for (const id of eventMemoryIds(event)) { + const bucket = grouped.get(id) ?? []; + bucket.push(event); + grouped.set(id, bucket); + } + } + return grouped; +} \ No newline at end of file diff --git a/scripts/memory-diag/formatters/audit.ts b/scripts/memory-diag/formatters/audit.ts new file mode 100644 index 0000000..ed07e89 --- /dev/null +++ b/scripts/memory-diag/formatters/audit.ts @@ -0,0 +1,48 @@ +import { cleanPath, cleanText, countBy, formatWorkspaceIdentity, sortedCounts, truncate } from "../text.ts"; +import type { MigrationLogRecord } from "../types.ts"; + +export type MigrationAuditReport = { + migrationId: string; + path: string; + invalidLines: number; + superseded: MigrationLogRecord[]; + hardReasons: string[]; + risky: Array<{ record: MigrationLogRecord; reasons: string[] }>; +}; + +export function formatMigrationAudit(reports: MigrationAuditReport[], options: { raw: boolean }): string { + const lines: string[] = []; + lines.push("Migration audit report"); + lines.push(""); + if (reports.length === 0) { + lines.push("No migration logs found."); + return lines.join("\n"); + } + + for (let i = 0; i < reports.length; i += 1) { + const report = reports[i]; + if (i > 0) lines.push(""); + lines.push(`Migration: ${report.migrationId}`); + lines.push(`logPath=${cleanPath(report.path, options.raw)}`); + if (report.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${report.invalidLines}`); + lines.push(`Superseded entries: ${report.superseded.length}`); + lines.push(""); + + lines.push("By hard reason:"); + const byHardReason = sortedCounts(countBy(report.hardReasons)); + if (byHardReason.length === 0) lines.push(" (none)"); + else for (const [reason, count] of byHardReason) lines.push(` ${reason.padEnd(24)} ${count}`); + lines.push(""); + + lines.push("Potentially risky supersedes:"); + lines.push(` ${report.risky.length}`); + for (const item of report.risky.slice(0, 10)) { + const record = item.record; + const hard = Array.isArray(record.hardReasons) ? record.hardReasons : []; + const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw); + lines.push(` - [${record.type ?? "unknown"}] hardReasons=${JSON.stringify(hard)} risk=${item.reasons.join(",")} ${JSON.stringify(truncate(cleanText(record.text ?? "", options.raw)))}`); + if (identity) lines.push(` ${identity}`); + } + } + return lines.join("\n"); +} diff --git a/scripts/memory-diag/formatters/coverage.ts b/scripts/memory-diag/formatters/coverage.ts new file mode 100644 index 0000000..d7b3ecb --- /dev/null +++ b/scripts/memory-diag/formatters/coverage.ts @@ -0,0 +1,34 @@ +import type { coverageRows } from "../inspection-model.ts"; +import { countBy, objectFromCounts } from "../text.ts"; +import type { CoverageClass } from "../types.ts"; + +export type CoverageRows = ReturnType; + +export function buildCoverageJSON(rows: CoverageRows, generatedAt = new Date().toISOString()): Record { + const classCounts = objectFromCounts(countBy(rows.map(row => row.class))); + return { + version: 1, + generatedAt, + classCounts, + memories: rows, + }; +} + +export function formatCoverage(rows: CoverageRows): string { + const classCounts = objectFromCounts(countBy(rows.map(row => row.class))); + const lines: string[] = []; + lines.push("Memory evidence coverage"); + lines.push(""); + lines.push("Class counts:"); + for (const cls of ["full_lifecycle", "render_only", "no_evidence", "historical_absent_with_reason", "historical_absent_unknown_reason"] as CoverageClass[]) { + lines.push(` ${cls}: ${classCounts[cls] ?? 0}`); + } + lines.push(""); + lines.push("Per-memory rows:"); + if (rows.length === 0) lines.push(" (none)"); + for (const row of rows) { + const phases = row.eventCounts.byPhase; + lines.push(` ${row.id} ${row.class} current=${row.current ? "yes" : "no"} total=${row.eventCounts.total} extraction=${phases.extraction ?? 0} promotion=${phases.promotion ?? 0} render=${phases.render ?? 0} storage=${phases.storage ?? 0}`); + } + return lines.join("\n"); +} diff --git a/scripts/memory-diag/formatters/disappearances.ts b/scripts/memory-diag/formatters/disappearances.ts new file mode 100644 index 0000000..b0fc763 --- /dev/null +++ b/scripts/memory-diag/formatters/disappearances.ts @@ -0,0 +1,45 @@ +import type { disappearanceRows } from "../inspection-model.ts"; +import { eventCounts } from "../inspection-model.ts"; +import { formatDetails } from "../text.ts"; + +export type DisappearanceRows = ReturnType; + +export function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record { + 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"); +} diff --git a/scripts/memory-diag/formatters/explain.ts b/scripts/memory-diag/formatters/explain.ts new file mode 100644 index 0000000..ac95656 --- /dev/null +++ b/scripts/memory-diag/formatters/explain.ts @@ -0,0 +1,42 @@ +import type { EvidenceEventV1 } from "../../../src/evidence-log.ts"; +import { formatStrength } from "../retention-model.ts"; +import type { WorkspaceDiagSnapshot } from "../types.ts"; + +export function formatEvidenceRefs(eventIds: string[], allEvents: EvidenceEventV1[]): string { + if (eventIds.length === 0) return "(none)"; + const byId = new Map(allEvents.map(event => [event.eventId, event])); + return eventIds + .map(id => { + const event = byId.get(id); + return event ? `${event.eventId} ${event.type}` : id; + }) + .join(", "); +} + +export function formatExplain(snapshot: WorkspaceDiagSnapshot): string { + const lines: string[] = []; + lines.push("Workspace memory explainability"); + lines.push(""); + + if (snapshot.memories.length === 0) { + lines.push("No memories found."); + } + + for (const memory of snapshot.memories) { + lines.push(`Memory ${memory.id}: ${memory.status}`); + const strength = typeof memory.strength === "number" ? formatStrength(memory.strength) : "n/a"; + lines.push(`- strength=${strength}, type=${memory.type}, source=${memory.source}`); + lines.push(`- reasons: ${memory.reasonCodes.length > 0 ? memory.reasonCodes.join(", ") : "(none)"}`); + lines.push(`- evidence: ${formatEvidenceRefs(memory.evidenceEventIds, snapshot.allEvents)}`); + lines.push(""); + } + + const quarantines = snapshot.recentEvents.filter(event => event.type === "storage_corrupt_json_quarantined"); + if (quarantines.length > 0) { + lines.push("Quarantined stores:"); + for (const event of quarantines) { + lines.push(`- quarantined_corrupt_store: ${event.eventId} ${event.type}; reasons=${event.reasonCodes.join(",")}`); + } + } + return lines.join("\n"); +} diff --git a/scripts/memory-diag/formatters/health.ts b/scripts/memory-diag/formatters/health.ts new file mode 100644 index 0000000..a2fe092 --- /dev/null +++ b/scripts/memory-diag/formatters/health.ts @@ -0,0 +1,193 @@ +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"); +} diff --git a/scripts/memory-diag/formatters/missing.ts b/scripts/memory-diag/formatters/missing.ts new file mode 100644 index 0000000..0e07819 --- /dev/null +++ b/scripts/memory-diag/formatters/missing.ts @@ -0,0 +1,58 @@ +import { buildDisappearancesJSON, formatDisappearances, type DisappearanceRows } from "./disappearances.ts"; + +export type MissingSummary = { + total: number; + explained: number; + needsReview: number; +}; + +export function missingSummary(rows: DisappearanceRows): MissingSummary { + return { + total: rows.length, + explained: rows.filter(row => row.classification === "historical_absent_with_reason").length, + needsReview: rows.filter(row => row.classification === "historical_absent_unknown_reason").length, + }; +} + +export function buildMissingJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record { + return { + ...buildDisappearancesJSON(rows, options), + summary: missingSummary(rows), + }; +} + +export function formatMissing(rows: DisappearanceRows, options: { verbose?: boolean; explain?: boolean } = {}): string { + const summary = missingSummary(rows); + const lines: string[] = []; + lines.push("Missing memory summary"); + lines.push(""); + if (rows.length === 0) { + lines.push("No missing memories found."); + return lines.join("\n"); + } + + lines.push(`Total missing: ${summary.total}`); + lines.push(`Explained: ${summary.explained}`); + lines.push(`Needs review: ${summary.needsReview}`); + lines.push(""); + + const unknownRows = rows + .filter(row => row.classification === "historical_absent_unknown_reason") + .slice(0, 5); + lines.push("Unknown disappearance samples:"); + if (unknownRows.length === 0) { + lines.push(" (none)"); + } else { + for (const row of unknownRows) { + const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none"; + lines.push(` - ${row.id} terminal=${row.terminalType} reasons=${reasons}`); + } + } + + if (options.verbose || options.explain) { + lines.push(""); + lines.push(formatDisappearances(rows, { explain: true })); + } + + return lines.join("\n"); +} diff --git a/scripts/memory-diag/formatters/quality.ts b/scripts/memory-diag/formatters/quality.ts new file mode 100644 index 0000000..14d5c51 --- /dev/null +++ b/scripts/memory-diag/formatters/quality.ts @@ -0,0 +1,119 @@ +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 { + 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; + 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"); +} diff --git a/scripts/memory-diag/formatters/rejected.ts b/scripts/memory-diag/formatters/rejected.ts new file mode 100644 index 0000000..4559e3f --- /dev/null +++ b/scripts/memory-diag/formatters/rejected.ts @@ -0,0 +1,93 @@ +import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts"; +import { cleanPath, cleanText, countBy, sortedCounts, truncate } from "../text.ts"; +import type { NormalizedRejection } from "../types.ts"; + +export type RejectedSummary = ReturnType; +export type RejectedFalsePositiveRisk = ReturnType; + +export function buildRejectedJSON(input: { + summary: RejectedSummary; + falsePositiveRisk: RejectedFalsePositiveRisk; + generatedAt?: string; +}): Record { + return { + version: 1, + generatedAt: input.generatedAt ?? new Date().toISOString(), + ...input.summary, + falsePositiveRisk: input.falsePositiveRisk, + }; +} + +export function formatRejected(input: { + path: string; + invalidLines: number; + records: NormalizedRejection[]; + summary: RejectedSummary; + falsePositiveRisk: RejectedFalsePositiveRisk; + raw: boolean; + verbose?: boolean; +}): string { + const lines: string[] = []; + lines.push("Rejected memory summary"); + lines.push(""); + lines.push(`Total rejected: ${input.summary.totalRecords}`); + lines.push(`Unique texts: ${input.summary.uniqueTexts}`); + lines.push(""); + + lines.push("Top reasons:"); + const byReason = sortedCounts(countBy(input.records.flatMap(record => record.reasons))).slice(0, 5); + if (byReason.length === 0) lines.push(" (none)"); + else for (const [reason, count] of byReason) lines.push(` ${reason.padEnd(36)} ${count}`); + lines.push(""); + + lines.push(`False-positive risk: ${input.falsePositiveRisk}`); + if (input.falsePositiveRisk === "high") { + lines.push("⚠️ Possible false positives detected. Use --verbose to inspect samples."); + } + lines.push(""); + + lines.push("Recent samples:"); + const samples = [...input.records] + .sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0)) + .slice(0, 5); + if (samples.length === 0) { + lines.push(" (none)"); + } else { + for (const record of samples) { + lines.push(` - [${record.type}] ${truncate(cleanText(record.text, input.raw))}`); + lines.push(` reasons: ${record.reasons.length > 0 ? record.reasons.join(",") : "none"}`); + } + } + + if (!input.verbose) return lines.join("\n"); + + 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("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("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(36)} ${count}`); + 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"); +} diff --git a/scripts/memory-diag/formatters/rejections.ts b/scripts/memory-diag/formatters/rejections.ts new file mode 100644 index 0000000..b984f22 --- /dev/null +++ b/scripts/memory-diag/formatters/rejections.ts @@ -0,0 +1,98 @@ +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; + +export function buildRejectionQualityJSON(summary: RejectionQualitySummary, generatedAt = new Date().toISOString()): Record { + 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"); +} diff --git a/scripts/memory-diag/formatters/status.ts b/scripts/memory-diag/formatters/status.ts new file mode 100644 index 0000000..75d9914 --- /dev/null +++ b/scripts/memory-diag/formatters/status.ts @@ -0,0 +1,155 @@ +import { + DORMANT_DECAY_MULTIPLIER, + RETENTION_TYPE_MAX, +} from "../../../src/retention.ts"; +import { TYPES } from "../constants.ts"; +import { daysSinceIso, formatStrength } from "../retention-model.ts"; +import { cleanText, truncate } from "../text.ts"; +import type { MemoryInspectionReadModel, RetentionDiagItem } from "../types.ts"; + +export type MemoryStatusLevel = "ok" | "warning" | "degraded"; + +export type StatusReadout = { + status: MemoryStatusLevel; + summaryText: string; + active: number; + rendered: number; + pending: number; + rejectedLast7Days: number; + evidenceCoveragePercent: number; + needsAttention: string[]; + suggestedNextSteps: string[]; + caps: { + active: number; + maxEntries: number; + rendered: number; + typeCapped: number; + globalCapped: number; + typeCounts: Record; + capsFull: boolean; + }; + retention: { present: number; missing: number; invalid: number }; + evidence: { + currentWithEvidence: number; + currentWithoutEvidence: number; + evidenceMemoryIds: number; + disappearances: number; + unknownDisappearances: number; + withTerminalReason: number; + }; + rejections: { + totalRecords: number; + workspaceScopedCount: number; + legacyUnscopedCount: number; + falsePositiveRisk: string; + }; + dormant: { + lastActivityAt?: string; + wallDaysSinceActivity: number | null; + dormantDiscountActive: boolean; + dormantDaysPastGrace: number; + }; + topRendered: RetentionDiagItem[]; + weakestActive: RetentionDiagItem[]; +}; + +export function formatStatus(readout: StatusReadout, model: MemoryInspectionReadModel, options: { verbose?: boolean; noEmoji?: boolean; isTty?: boolean; raw?: boolean } = {}): string { + if (options.verbose) return formatStatusVerbose(readout, model, options); + + const lines: string[] = []; + const label = statusLabel(readout.status, options); + lines.push(`${label} Memory status`); + lines.push(""); + lines.push("Key metrics:"); + lines.push(` active memories: ${readout.active}`); + lines.push(` rendered: ${readout.rendered}`); + lines.push(` pending: ${readout.pending}`); + lines.push(` rejected 7d: ${readout.rejectedLast7Days}`); + lines.push(` evidence coverage: ${readout.evidenceCoveragePercent}%`); + + if (readout.needsAttention.length > 0) { + lines.push(""); + lines.push("Needs attention:"); + for (const item of readout.needsAttention) lines.push(` - ${item}`); + } + + if (readout.status !== "ok" && readout.suggestedNextSteps.length > 0) { + lines.push(""); + lines.push("Suggested next steps:"); + for (const item of readout.suggestedNextSteps) lines.push(` - ${item}`); + } + + return lines.join("\n"); +} + +function statusLabel(status: MemoryStatusLevel, options: { noEmoji?: boolean; isTty?: boolean }): string { + const text = status === "ok" ? "OK" : status === "warning" ? "WARNING" : "DEGRADED"; + if (options.noEmoji || !options.isTty) return text; + const emoji = status === "ok" ? "🧠" : status === "warning" ? "⚠️" : "✖️"; + return `${emoji} ${text}`; +} + +function formatStatusVerbose(readout: StatusReadout, model: MemoryInspectionReadModel, options: { raw?: boolean } = {}): string { + const lines: string[] = []; + lines.push("Memory status inspection"); + lines.push(""); + lines.push(readout.summaryText); + lines.push(""); + lines.push("Caps:"); + lines.push(` active: ${readout.caps.active} / ${readout.caps.maxEntries}`); + for (const type of TYPES) { + const count = readout.caps.typeCounts[type] ?? 0; + const limit = RETENTION_TYPE_MAX[type]; + const marker = count >= limit ? " FULL" : ""; + lines.push(` ${type}: ${count} / ${limit}${marker}`); + } + lines.push(` rendered: ${readout.caps.rendered}`); + lines.push(` type-capped entries: ${readout.caps.typeCapped}`); + lines.push(` global-cap overflow: ${readout.caps.globalCapped}`); + lines.push(` caps full: ${readout.caps.capsFull ? "yes" : "no"}`); + lines.push(""); + lines.push("Retention clocks:"); + lines.push(` present: ${readout.retention.present}`); + lines.push(` missing: ${readout.retention.missing}`); + lines.push(` invalid: ${readout.retention.invalid}`); + lines.push(""); + lines.push("Evidence:"); + lines.push(` current with evidence: ${readout.evidence.currentWithEvidence}`); + lines.push(` current without evidence: ${readout.evidence.currentWithoutEvidence}`); + lines.push(` evidence memory ids: ${readout.evidence.evidenceMemoryIds}`); + lines.push(` disappearances: ${readout.evidence.disappearances}`); + lines.push(` unknown disappearances: ${readout.evidence.unknownDisappearances}`); + lines.push(""); + lines.push("Rejection scoping:"); + lines.push(` total records: ${readout.rejections.totalRecords}`); + lines.push(` workspace scoped: ${readout.rejections.workspaceScopedCount}`); + lines.push(` legacy unscoped: ${readout.rejections.legacyUnscopedCount}`); + lines.push(` false-positive risk: ${readout.rejections.falsePositiveRisk}`); + lines.push(""); + lines.push("Dormancy:"); + lines.push(` lastActivityAt: ${readout.dormant.lastActivityAt ?? "(missing)"}`); + lines.push(` wall days since activity: ${readout.dormant.wallDaysSinceActivity === null ? "unknown" : readout.dormant.wallDaysSinceActivity.toFixed(1)}`); + lines.push(` dormant discount active: ${readout.dormant.dormantDiscountActive ? "yes" : "no"}`); + lines.push(` dormant days past grace: ${readout.dormant.dormantDaysPastGrace.toFixed(1)}`); + lines.push(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`); + lines.push(""); + lines.push("Top rendered candidates:"); + pushMemoryItems(lines, readout.topRendered, options.raw === true); + lines.push(""); + lines.push("Weakest active memories:"); + pushMemoryItems(lines, readout.weakestActive, options.raw === true); + lines.push(""); + lines.push("Store:"); + lines.push(` superseded: ${model.store.entries.length - readout.active}`); + return lines.join("\n"); +} + +function pushMemoryItems(lines: string[], items: RetentionDiagItem[], raw: boolean): void { + if (items.length === 0) { + lines.push(" (none)"); + return; + } + for (const item of items) { + lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, raw))}`); + } +} diff --git a/scripts/memory-diag/formatters/trace.ts b/scripts/memory-diag/formatters/trace.ts new file mode 100644 index 0000000..f0f9a52 --- /dev/null +++ b/scripts/memory-diag/formatters/trace.ts @@ -0,0 +1,36 @@ +import type { EvidenceEventV1 } from "../../../src/evidence-log.ts"; +import { formatTraceEvent, relationMemoryIds, statusFromTraceEvent } from "../trace-model.ts"; +import type { WorkspaceDiagSnapshot } from "../types.ts"; + +export function formatTrace(memoryId: string, snapshot: WorkspaceDiagSnapshot, trace: { events: EvidenceEventV1[] }): string { + const lines: string[] = []; + const memoryRow = snapshot.memories.find(memory => memory.id === memoryId); + const status = memoryRow?.status ?? statusFromTraceEvent(trace.events.at(-1)); + + lines.push(`Memory ${memoryId}: ${status}`); + lines.push(""); + lines.push("Lifecycle:"); + if (trace.events.length === 0) { + lines.push("(none)"); + return lines.join("\n"); + } + + for (const event of trace.events) { + lines.push(formatTraceEvent(event)); + } + + const supersededBy = relationMemoryIds(trace.events, "superseded_by"); + if (supersededBy.length > 0) { + lines.push(""); + lines.push("Superseded by:"); + for (const id of supersededBy) lines.push(`- ${id}`); + } + + const reinforcedBy = relationMemoryIds(trace.events, "reinforced_by"); + if (reinforcedBy.length > 0) { + lines.push(""); + lines.push("Reinforced by:"); + for (const id of reinforcedBy) lines.push(`- ${id}`); + } + return lines.join("\n"); +} diff --git a/scripts/memory-diag/inspection-model.ts b/scripts/memory-diag/inspection-model.ts new file mode 100644 index 0000000..01eed6b --- /dev/null +++ b/scripts/memory-diag/inspection-model.ts @@ -0,0 +1,98 @@ +import type { EvidenceEventV1 } from "../../src/evidence-log.ts"; +import type { LongTermType } from "../../src/types.ts"; +import { countBy, objectFromCounts, uniqueStrings } from "./text.ts"; +import { groupEvidenceByMemoryId } from "./evidence-model.ts"; +import { loadRejectionRecords } from "./rejections-model.ts"; +import { snapshotForOptions } from "./workspace-snapshot.ts"; +import type { CliOptions, CoverageClass, DisappearanceReason, MemoryInspectionReadModel } from "./types.ts"; + +export async function buildInspectionReadModel(options: CliOptions): Promise { + const snapshot = await snapshotForOptions(options); + const rejections = await loadRejectionRecords(options); + return { + snapshot, + store: snapshot.store, + pending: snapshot.journal, + evidenceEvents: snapshot.allEvents, + rejectionRecords: rejections.records, + currentById: new Map(snapshot.store.entries.map(entry => [entry.id, entry])), + evidenceByMemoryId: groupEvidenceByMemoryId(snapshot.allEvents), + }; +} + +export function terminalDisappearanceReason(events: EvidenceEventV1[]): DisappearanceReason { + const terminal = [...events].reverse().find(event => + event.type === "memory_removed_capacity" + || event.type === "promotion_absorbed_exact" + || event.type === "promotion_absorbed_identity" + || event.type === "promotion_superseded" + ); + const renderOmission = [...events].reverse().find(event => event.type === "render_omitted"); + const event = terminal ?? renderOmission; + if (!event) { + return { classification: "historical_absent_unknown_reason", terminalType: "unknown", reasonCodes: [] }; + } + return { + classification: "historical_absent_with_reason", + terminalType: event.type, + reasonCodes: event.reasonCodes, + event, + }; +} + +export function coverageClassForMemory(id: string, model: MemoryInspectionReadModel): CoverageClass { + const events = model.evidenceByMemoryId.get(id) ?? []; + if (!model.currentById.has(id)) return terminalDisappearanceReason(events).classification; + if (events.length === 0) return "no_evidence"; + if (events.every(event => event.phase === "render")) return "render_only"; + return "full_lifecycle"; +} + +export function eventCounts(events: EvidenceEventV1[]): { total: number; byType: Record; byPhase: Record } { + return { + total: events.length, + byType: objectFromCounts(countBy(events.map(event => event.type))), + byPhase: objectFromCounts(countBy(events.map(event => event.phase))), + }; +} + +export function coverageRows(model: MemoryInspectionReadModel, includeHistorical: boolean): Array<{ + id: string; + class: CoverageClass; + current: boolean; + type?: LongTermType; + eventCounts: ReturnType; +}> { + const ids = new Set(model.currentById.keys()); + if (includeHistorical) { + for (const id of model.evidenceByMemoryId.keys()) ids.add(id); + } + return [...ids].sort().map(id => { + const entry = model.currentById.get(id); + const events = model.evidenceByMemoryId.get(id) ?? []; + return { + id, + class: coverageClassForMemory(id, model), + current: Boolean(entry), + type: entry?.type ?? events.find(event => event.memory?.memoryId === id)?.memory?.type, + eventCounts: eventCounts(events), + }; + }); +} + +export function disappearanceRows(model: MemoryInspectionReadModel): Array<{ + id: string; + classification: DisappearanceReason["classification"]; + terminalType: DisappearanceReason["terminalType"]; + reasonCodes: string[]; + event?: EvidenceEventV1; + events: EvidenceEventV1[]; +}> { + const rows = [...model.evidenceByMemoryId.entries()] + .filter(([id]) => !model.currentById.has(id)) + .map(([id, events]) => { + const reason = terminalDisappearanceReason(events); + return { id, ...reason, events }; + }); + return rows.sort((a, b) => a.classification.localeCompare(b.classification) || a.id.localeCompare(b.id)); +} diff --git a/scripts/memory-diag/io.ts b/scripts/memory-diag/io.ts new file mode 100644 index 0000000..48a4d27 --- /dev/null +++ b/scripts/memory-diag/io.ts @@ -0,0 +1,36 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; + +export async function readJSONFile(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")) as T; + } catch { + return null; + } +} + +export async function readJSONLFile(path: string): Promise<{ records: T[]; invalidLines: number }> { + let content = ""; + try { + content = await readFile(path, "utf8"); + } catch { + return { records: [], invalidLines: 0 }; + } + + const records: T[] = []; + let invalidLines = 0; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + records.push(JSON.parse(trimmed) as T); + } catch { + invalidLines += 1; + } + } + return { records, invalidLines }; +} + +export function pathExists(path: string): boolean { + return existsSync(path); +} diff --git a/scripts/memory-diag/migrations-model.ts b/scripts/memory-diag/migrations-model.ts new file mode 100644 index 0000000..b2b172b --- /dev/null +++ b/scripts/memory-diag/migrations-model.ts @@ -0,0 +1,42 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { dataHome, migrationLogPath } from "../../src/paths.ts"; +import type { CliOptions, MigrationLogRecord } from "./types.ts"; + +export function migrationLogsRoot(): string { + return join(dataHome(), "opencode-working-memory", "migration-logs"); +} + +export async function migrationLogPaths(options: CliOptions): Promise { + if (options.migration) return [migrationLogPath(options.migration)]; + const root = migrationLogsRoot(); + let entries: string[] = []; + try { + entries = await readdir(root); + } catch { + return []; + } + return entries.filter(entry => entry.endsWith(".jsonl")).sort().map(entry => join(root, entry)); +} + +export function migrationIdFromPath(path: string): string { + return path.split("/").pop()?.replace(/\.jsonl$/, "") ?? "unknown"; +} + +export function riskySupersedeReasons(record: MigrationLogRecord): string[] { + const reasons: string[] = []; + const hardReasonsMissing = !Array.isArray(record.hardReasons); + const hardReasons = Array.isArray(record.hardReasons) ? record.hardReasons : []; + const qualityReasons = Array.isArray(record.reasons) ? record.reasons : []; + const text = record.text ?? ""; + + if (hardReasonsMissing || hardReasons.length === 0) reasons.push("missing_or_empty_hardReasons"); + if (qualityReasons.length > 0 && hardReasons.length === 0) reasons.push("soft_reasons_without_hardReasons"); + if (/\b(?:User|user|prefers|requires|wants|insists)\b|用戶|使用者|偏好|要求|不要|不刪除/u.test(text)) reasons.push("user_preference_marker"); + if (/\b(?:must|should|do not|never|is|are|follows)\b|必須|應該|採用|維持|需支援/iu.test(text)) reasons.push("durable_rule_marker"); + if ((record.type === "feedback" || record.type === "decision") && hardReasons.length === 1 && hardReasons[0] === "path_heavy") { + reasons.push("feedback_or_decision_path_heavy_only"); + } + + return reasons; +} diff --git a/scripts/memory-diag/rejections-model.ts b/scripts/memory-diag/rejections-model.ts new file mode 100644 index 0000000..8f02cc6 --- /dev/null +++ b/scripts/memory-diag/rejections-model.ts @@ -0,0 +1,136 @@ +import { extractionRejectionLogPath, workspaceKey } from "../../src/paths.ts"; +import { HARD_QUALITY_REASONS, isArchitectureLikeDecision } from "../../src/memory-quality.ts"; +import { ALLOWED_ORIGINS } from "./constants.ts"; +import { readJSONLFile } from "./io.ts"; +import { canonicalMemoryText, cleanText, countBy, objectFromCounts, truncate } from "./text.ts"; +import { CliInputError, type CliOptions, type NormalizedRejection, type Origin, type RejectionLogRecord } from "./types.ts"; + +export { CliInputError } from "./types.ts"; + +export function inferOrigin(record: RejectionLogRecord): Origin { + if (record.origin && ALLOWED_ORIGINS.has(record.origin as Origin)) return record.origin as Origin; + if (record.source === "compaction") return "compaction_candidate"; + if (record.source === "explicit") return "explicit_trigger"; + if (record.source === "manual") return "manual"; + return "unknown"; +} + +export function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | null { + if (!record.text || !Array.isArray(record.reasons)) return null; + const origin = inferOrigin(record); + return { + timestamp: record.timestamp ?? "", + workspaceKey: record.workspaceKey, + workspaceRoot: record.workspaceRoot, + workspaceRootHash: record.workspaceRootHash, + type: record.type ?? "project", + source: record.source, + origin, + fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger", + text: record.text, + reasons: record.reasons, + }; +} + +export function sinceCutoff(rawSince: string | undefined, now = Date.now()): number | null { + if (!rawSince) return null; + const relative = rawSince.match(/^(\d+)([dhm])$/i); + if (relative) { + const amount = Number(relative[1]); + const unit = relative[2].toLowerCase(); + const multiplier = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : 60_000; + return now - amount * multiplier; + } + const timestamp = new Date(rawSince).getTime(); + if (Number.isNaN(timestamp)) throw new CliInputError(`Invalid --since value: ${rawSince}`); + return timestamp; +} + +export function hasSoftReason(record: NormalizedRejection): boolean { + return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason)); +} + +export function hasWorkspaceScope(record: NormalizedRejection): boolean { + return Boolean(record.workspaceKey || record.workspaceRoot || record.workspaceRootHash); +} + +export async function workspaceKeyForOption(options: CliOptions): Promise { + return options.workspace ? workspaceKey(options.workspace) : undefined; +} + +export async function loadRejectionRecords(options: CliOptions): Promise<{ path: string; invalidLines: number; records: NormalizedRejection[] }> { + const path = extractionRejectionLogPath(); + const { records, invalidLines } = await readJSONLFile(path); + const cutoff = sinceCutoff(options.since); + const requestedWorkspaceKey = await workspaceKeyForOption(options); + let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null); + + if (requestedWorkspaceKey) { + normalized = normalized.filter(record => !record.workspaceKey || record.workspaceKey === requestedWorkspaceKey); + } + if (cutoff !== null) { + normalized = normalized.filter(record => { + const timestamp = new Date(record.timestamp).getTime(); + return !Number.isNaN(timestamp) && timestamp >= cutoff; + }); + } + if (options.softOnly) normalized = normalized.filter(hasSoftReason); + if (options.triggerOnly) normalized = normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger"); + if (options.reason) normalized = normalized.filter(record => record.reasons.includes(options.reason ?? "")); + + return { path, invalidLines, records: normalized }; +} + +export function uniqueByCanonicalText(records: NormalizedRejection[]): NormalizedRejection[] { + const byText = new Map(); + for (const record of records) { + const key = `${record.type}:${canonicalMemoryText(record.text)}`; + if (!byText.has(key)) byText.set(key, record); + } + return [...byText.values()]; +} + +export function rejectionQualitySummary(records: NormalizedRejection[]): { + totalRecords: number; + uniqueTexts: number; + workspaceScopedCount: number; + legacyUnscopedCount: number; + reasonDistribution: Record; + uniqueReasonDistribution: Record; + possibleFalsePositiveGroups: Record; +} { + const uniqueRecords = uniqueByCanonicalText(records); + const badDecisionUnique = uniqueRecords.filter(record => record.reasons.includes("bad_decision")); + const groups: Record = { + architecture_like_possible_false_positive: { count: 0, samples: [] }, + clearly_garbage: { count: 0, samples: [] }, + ambiguous: { count: 0, samples: [] }, + }; + + for (const record of badDecisionUnique) { + const hardReasons = record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)); + const statusLike = /\b(?:implemented|added|updated|fixed|completed|reviewed|tests?|CI|commit|wave|phase|task|session)\b/i.test(record.text); + const group = isArchitectureLikeDecision(record.text) && hardReasons.length === 0 && !statusLike + ? "architecture_like_possible_false_positive" + : hardReasons.length > 0 || statusLike + ? "clearly_garbage" + : "ambiguous"; + groups[group].count += 1; + if (groups[group].samples.length < 5) groups[group].samples.push(truncate(cleanText(record.text, false), 120)); + } + + return { + totalRecords: records.length, + uniqueTexts: uniqueRecords.length, + workspaceScopedCount: records.filter(hasWorkspaceScope).length, + legacyUnscopedCount: records.filter(record => !hasWorkspaceScope(record)).length, + reasonDistribution: objectFromCounts(countBy(records.flatMap(record => record.reasons))), + uniqueReasonDistribution: objectFromCounts(countBy(uniqueRecords.flatMap(record => record.reasons))), + possibleFalsePositiveGroups: groups, + }; +} + +export function rejectionFalsePositiveRisk(summary: ReturnType): "low" | "high" { + const possible = summary.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count; + return possible >= 3 || (summary.uniqueTexts > 0 && possible / summary.uniqueTexts >= 0.5) ? "high" : "low"; +} diff --git a/scripts/memory-diag/retention-model.ts b/scripts/memory-diag/retention-model.ts new file mode 100644 index 0000000..9f6102c --- /dev/null +++ b/scripts/memory-diag/retention-model.ts @@ -0,0 +1,84 @@ +import { + RETENTION_TYPE_MAX, + calculateRetentionStrength, +} from "../../src/retention.ts"; +import type { LongTermMemoryEntry, LongTermSource, LongTermType, WorkspaceMemoryStore } from "../../src/types.ts"; +import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../../src/types.ts"; +import type { RetentionDiagItem } from "./types.ts"; + +export function ageDays(entry: LongTermMemoryEntry, now = Date.now()): number | null { + const time = new Date(entry.createdAt).getTime(); + if (Number.isNaN(time)) return null; + return Math.floor((now - time) / 86_400_000); +} + +export function formatStrength(value: number): string { + return Number.isFinite(value) ? value.toFixed(3) : "0.000"; +} + +export function daysSinceIso(value: string | undefined, now = Date.now()): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + if (!Number.isFinite(ms)) return null; + return Math.max(0, (now - ms) / 86_400_000); +} + +export function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean { + return entry.safetyCritical === true; +} + +export function retentionCandidatesForDiag(store: WorkspaceMemoryStore, now = Date.now()): { + sorted: RetentionDiagItem[]; + rendered: RetentionDiagItem[]; + typeCapped: RetentionDiagItem[]; + globalCapped: RetentionDiagItem[]; +} { + const active = store.entries.filter(entry => entry.status !== "superseded"); + const sorted = active + .map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) })) + .sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id)); + + const rendered: RetentionDiagItem[] = []; + const typeCapped: RetentionDiagItem[] = []; + const globalCapped: RetentionDiagItem[] = []; + const typeCounts: Partial> = {}; + + for (const item of sorted) { + const count = typeCounts[item.entry.type] ?? 0; + const max = RETENTION_TYPE_MAX[item.entry.type] ?? Infinity; + if (count >= max) { + typeCapped.push(item); + continue; + } + typeCounts[item.entry.type] = count + 1; + + if (rendered.length < LONG_TERM_LIMITS.maxEntries) { + rendered.push(item); + } else { + globalCapped.push(item); + } + } + + return { sorted, rendered, typeCapped, globalCapped }; +} + +export function promotionLimit(source: LongTermSource): number { + if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts; + return PROMOTION_RETRY_LIMITS.maxExplicitAttempts; +} + +export function retentionClockSummary(entries: LongTermMemoryEntry[]): { present: number; missing: number; invalid: number } { + let present = 0; + let missing = 0; + let invalid = 0; + for (const entry of entries) { + if (entry.retentionClock === undefined) { + missing += 1; + } else if (!Number.isFinite(entry.retentionClock) || entry.retentionClock <= 0) { + invalid += 1; + } else { + present += 1; + } + } + return { present, missing, invalid }; +} diff --git a/scripts/memory-diag/text.ts b/scripts/memory-diag/text.ts new file mode 100644 index 0000000..0ad41ec --- /dev/null +++ b/scripts/memory-diag/text.ts @@ -0,0 +1,69 @@ +import { createHash } from "node:crypto"; +import type { EvidenceEventV1 } from "../../src/evidence-log.ts"; +import { redactCredentials } from "../../src/redaction.ts"; + +export function countBy(items: T[]): Map { + const counts = new Map(); + for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1); + return counts; +} + +export function sortedCounts(counts: Map): Array<[T, number]> { + return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); +} + +export function workspaceRootHash(root: string): string { + return createHash("sha256").update(root).digest("hex").slice(0, 16); +} + +export function redactAbsolutePaths(text: string): string { + return text.replace(/(?:^|[\s"'`(=:\[])(\/(?:Users|home|private|tmp|var|opt|Volumes|[^\s"'`)\],;:]+)\/[^\s"'`)\],;:]*)/g, (match, path) => match.replace(path, "")); +} + +export function cleanText(text: string, raw: boolean): string { + if (raw) return text; + return redactAbsolutePaths(redactCredentials(text)); +} + +export function cleanPath(path: string, raw: boolean): string { + return raw ? path : ""; +} + +export function formatWorkspaceIdentity(workspaceKeyValue: string | undefined, workspaceRoot: string | undefined, raw: boolean): string { + const parts: string[] = []; + if (workspaceKeyValue) parts.push(`workspaceKey=${workspaceKeyValue}`); + if (workspaceRoot) { + parts.push(raw ? `workspaceRoot=${workspaceRoot}` : `workspaceRootHash=${workspaceRootHash(workspaceRoot)}`); + } + return parts.join(" "); +} + +export function truncate(text: string, max = 120): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`; +} + +export function canonicalMemoryText(text: string): string { + return text + .normalize("NFKC") + .toLowerCase() + .replace(/[\s\p{P}]+/gu, " ") + .trim(); +} + +export function formatPercent(ratio: number): string { + return `${(ratio * 100).toFixed(1)}%`; +} + +export function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +export function objectFromCounts(counts: Map): Record { + return Object.fromEntries(sortedCounts(counts)); +} + +export function formatDetails(details: EvidenceEventV1["details"]): string { + if (!details || Object.keys(details).length === 0) return "none"; + return Object.entries(details).map(([key, value]) => `${key}=${Array.isArray(value) ? value.join("|") : String(value)}`).join(" "); +} diff --git a/scripts/memory-diag/trace-model.ts b/scripts/memory-diag/trace-model.ts new file mode 100644 index 0000000..ba23c0c --- /dev/null +++ b/scripts/memory-diag/trace-model.ts @@ -0,0 +1,30 @@ +import type { EvidenceEventV1 } from "../../src/evidence-log.ts"; +import { uniqueStrings } from "./text.ts"; +import { statusFromOmissionReason } from "./workspace-snapshot.ts"; + +export function statusFromTraceEvent(event: EvidenceEventV1 | undefined): string { + if (!event) return "unknown"; + if (event.type === "render_selected") return "rendered"; + if (event.type === "render_omitted") return statusFromOmissionReason(event.reasonCodes[0]); + if (event.type === "promotion_absorbed_exact" || event.type === "promotion_absorbed_identity") return "omitted_absorbed_duplicate"; + if (event.type === "promotion_retry_scheduled") return "pending_retry"; + if (event.type === "promotion_rejected_capacity" || event.type === "promotion_retry_exhausted") return "pending_rejected_capacity"; + if (event.type === "storage_corrupt_json_quarantined") return "quarantined_corrupt_store"; + if (event.outcome === "superseded") return "omitted_superseded"; + return event.outcome; +} + +export function formatTraceEvent(event: EvidenceEventV1): string { + const reasons = event.reasonCodes.length > 0 ? event.reasonCodes.join(",") : "none"; + const relations = (event.relations ?? []) + .map(relation => relation.memory?.memoryId ? `${relation.role}=${relation.memory.memoryId}` : undefined) + .filter((value): value is string => Boolean(value)); + const relationText = relations.length > 0 ? `; ${relations.join(", ")}` : ""; + return `- ${event.eventId} ${event.type}: ${event.outcome}; reasons=${reasons}${relationText}`; +} + +export function relationMemoryIds(events: EvidenceEventV1[], role: string): string[] { + return uniqueStrings(events.flatMap(event => (event.relations ?? []) + .filter(relation => relation.role === role) + .map(relation => relation.memory?.memoryId ?? ""))); +} diff --git a/scripts/memory-diag/types.ts b/scripts/memory-diag/types.ts new file mode 100644 index 0000000..1d23d40 --- /dev/null +++ b/scripts/memory-diag/types.ts @@ -0,0 +1,159 @@ +import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome } from "../../src/evidence-log.ts"; +import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts"; + +export type MemoryRenderStatus = + | "rendered" + | "omitted_superseded" + | "omitted_type_cap" + | "omitted_global_cap" + | "omitted_char_budget" + | "omitted_absorbed_duplicate" + | "pending_retry" + | "pending_rejected_capacity" + | "quarantined_corrupt_store"; + +export type MemoryDiagJSON = { + version: 1; + workspace: { rootHash: string; key: string }; + generatedAt: string; + summary: { + storedActive: number; + rendered: number; + pending: number; + rejectedLast7Days: number; + corruptStoresQuarantinedLast30Days: number; + status?: "ok" | "warning" | "degraded"; + evidenceCoveragePercent?: number; + needsAttention?: string[]; + suggestedNextSteps?: string[]; + }; + memories: Array<{ + id: string; + type: "feedback" | "project" | "decision" | "reference"; + source: "explicit" | "compaction" | "manual"; + status: MemoryRenderStatus; + strength?: number; + reasonCodes: string[]; + textPreview?: string; + evidenceEventIds: string[]; + }>; + recentEvents: Array<{ + eventId: string; + type: EvidenceEventType; + outcome: EvidenceOutcome; + createdAt: string; + memoryId?: string; + reasonCodes: string[]; + }>; +}; + +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 = { + raw: boolean; + json?: boolean; + verbose?: boolean; + noEmoji?: boolean; + workspace?: string; + all?: boolean; + 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"; +}; + +export type ParsedArgs = + | { ok: true; command: Command; options: CliOptions; deprecationNotice?: string } + | { ok: true; help: true; usage: string } + | { ok: false; message: string; usage: string; exitCode: number }; + +export type CommandResult = { stdout: string; stderr?: string; exitCode?: number }; + +export class CliInputError extends Error {} + +export type RejectionLogRecord = { + timestamp?: string; + workspaceKey?: string; + workspaceRoot?: string; + workspaceRootHash?: string; + type?: LongTermType; + source?: LongTermSource | string; + origin?: string; + fromTrigger?: boolean; + text?: string; + reasons?: string[]; +}; + +export type NormalizedRejection = Required> & { + workspaceKey?: string; + workspaceRoot?: string; + workspaceRootHash?: string; + source?: string; + origin: Origin; + fromTrigger: boolean; +}; + +export type MigrationLogRecord = { + migrationId?: string; + timestamp?: string; + workspaceKey?: string; + workspaceRoot?: string; + entryId?: string; + type?: LongTermType; + source?: LongTermSource | string; + text?: string; + reasons?: string[]; + hardReasons?: string[]; + beforeStatus?: string; + afterStatus?: string; +}; + +export type RetentionDiagItem = { + entry: LongTermMemoryEntry; + strength: number; +}; + +export type WorkspaceDiagSnapshot = { + store: WorkspaceMemoryStore; + journal: PendingMemoryJournalStore; + retention: { + sorted: RetentionDiagItem[]; + rendered: RetentionDiagItem[]; + typeCapped: RetentionDiagItem[]; + globalCapped: RetentionDiagItem[]; + }; + memories: MemoryDiagJSON["memories"]; + recentEvents: MemoryDiagJSON["recentEvents"]; + allEvents: EvidenceEventV1[]; + summary: MemoryDiagJSON["summary"]; +}; + +export type MemoryInspectionReadModel = { + snapshot: WorkspaceDiagSnapshot; + store: WorkspaceMemoryStore; + pending: PendingMemoryJournalStore; + evidenceEvents: EvidenceEventV1[]; + rejectionRecords: NormalizedRejection[]; + currentById: Map; + evidenceByMemoryId: Map; +}; + +export type CoverageClass = "full_lifecycle" | "render_only" | "no_evidence" | "historical_absent_with_reason" | "historical_absent_unknown_reason"; + +export type DisappearanceReason = { + classification: "historical_absent_with_reason" | "historical_absent_unknown_reason"; + terminalType: EvidenceEventType | "unknown"; + reasonCodes: string[]; + event?: EvidenceEventV1; +}; diff --git a/scripts/memory-diag/workspace-snapshot.ts b/scripts/memory-diag/workspace-snapshot.ts new file mode 100644 index 0000000..45b20a7 --- /dev/null +++ b/scripts/memory-diag/workspace-snapshot.ts @@ -0,0 +1,236 @@ +import { calculateRetentionStrength } from "../../src/retention.ts"; +import { + queryEvidenceEvents, + type EvidenceEventV1, +} from "../../src/evidence-log.ts"; +import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../../src/paths.ts"; +import { accountWorkspaceMemoryRender } from "../../src/workspace-memory.ts"; +import type { LongTermMemoryEntry, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts"; +import { LONG_TERM_LIMITS } from "../../src/types.ts"; +import { readJSONFile } from "./io.ts"; +import { groupEvidenceByMemoryId } from "./evidence-model.ts"; +import { promotionLimit, retentionCandidatesForDiag } from "./retention-model.ts"; +import { cleanText, truncate, uniqueStrings, workspaceRootHash } from "./text.ts"; +import type { CliOptions, MemoryDiagJSON, MemoryRenderStatus, WorkspaceDiagSnapshot } from "./types.ts"; + +export function emptyStore(root: string, key: string): WorkspaceMemoryStore { + return { + version: 1, + workspace: { root, key }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [], + migrations: [], + updatedAt: new Date(0).toISOString(), + }; +} + +export function normalizedStore(store: WorkspaceMemoryStore | null, root: string, key: string): WorkspaceMemoryStore { + const fallback = emptyStore(root, key); + return { + ...fallback, + ...(store ?? {}), + workspace: store?.workspace ?? fallback.workspace, + limits: { + maxRenderedChars: store?.limits?.maxRenderedChars ?? fallback.limits.maxRenderedChars, + maxEntries: store?.limits?.maxEntries ?? fallback.limits.maxEntries, + }, + entries: Array.isArray(store?.entries) ? store.entries : [], + migrations: Array.isArray(store?.migrations) ? store.migrations : [], + }; +} + +export function normalizedJournal(journal: PendingMemoryJournalStore | null): PendingMemoryJournalStore { + return { + version: 1, + workspace: journal?.workspace ?? { root: "", key: "" }, + entries: Array.isArray(journal?.entries) ? journal.entries : [], + updatedAt: journal?.updatedAt ?? new Date(0).toISOString(), + }; +} + +export function eventMemoryId(event: EvidenceEventV1): string | undefined { + return event.memory?.memoryId + ?? event.relations?.map(relation => relation.memory?.memoryId).find((id): id is string => Boolean(id)); +} + +export function isWithinDays(iso: string, days: number, now = Date.now()): boolean { + const ms = new Date(iso).getTime(); + return Number.isFinite(ms) && ms >= now - days * 86_400_000; +} + +export function renderStatusReason(status: MemoryRenderStatus, fallback?: string): string[] { + switch (status) { + case "rendered": return ["within_caps", "within_char_budget"]; + case "omitted_superseded": return ["superseded"]; + case "omitted_type_cap": return ["type_cap"]; + case "omitted_global_cap": return ["global_cap"]; + case "omitted_char_budget": return [fallback === "empty_render_budget" ? "empty_render_budget" : "char_budget"]; + case "omitted_absorbed_duplicate": return ["absorbed_duplicate"]; + case "pending_retry": return ["retryable_capacity_rejection"]; + case "pending_rejected_capacity": return ["capacity_rejected", "max_attempts_reached"]; + case "quarantined_corrupt_store": return ["invalid_json"]; + } +} + +export function statusFromOmissionReason(reason: string | undefined): MemoryRenderStatus { + if (reason === "superseded") return "omitted_superseded"; + if (reason === "type_cap") return "omitted_type_cap"; + if (reason === "global_cap") return "omitted_global_cap"; + return "omitted_char_budget"; +} + +export function pendingStatus(entry: LongTermMemoryEntry): MemoryRenderStatus { + const attempts = entry.promotionAttempts ?? 0; + return attempts >= promotionLimit(entry.source) ? "pending_rejected_capacity" : "pending_retry"; +} + +export function safeTextPreview(text: string): string { + return truncate(cleanText(text, false), 120); +} + +function evidenceSummaryForMemory(grouped: Map, memoryId: string): { eventIds: string[]; reasonCodes: string[] } { + const events = grouped.get(memoryId) ?? []; + const reasonCodes = new Set(); + for (const event of events) { + for (const reason of event.reasonCodes) reasonCodes.add(reason); + } + return { + eventIds: events.map(event => event.eventId), + reasonCodes: [...reasonCodes], + }; +} + +export async function buildWorkspaceDiagSnapshot(input: { + root: string; + key: string; + memoryPath: string; + pendingPath: string; +}, now = Date.now()): Promise { + const rawStore = await readJSONFile(input.memoryPath); + const storeRoot = rawStore?.workspace?.root ?? input.root; + const storeKey = rawStore?.workspace?.key ?? input.key; + const store = normalizedStore(rawStore, storeRoot, storeKey); + const journal = normalizedJournal(await readJSONFile(input.pendingPath)); + const retention = retentionCandidatesForDiag(store, now); + const renderAccounting = accountWorkspaceMemoryRender(store); + const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id)); + const omittedById = new Map(renderAccounting.omitted.map(item => [item.memory.id, item.reason])); + const allEvents = await queryEvidenceEvents(input.root); + const evidenceByMemoryId = groupEvidenceByMemoryId(allEvents); + const recentEvidence = await queryEvidenceEvents(input.root, { newestFirst: true, limit: 50 }); + const memoryRows: MemoryDiagJSON["memories"] = []; + const seenIds = new Set(); + + for (const entry of store.entries) { + const omissionReason = omittedById.get(entry.id); + const status: MemoryRenderStatus = renderedIds.has(entry.id) + ? "rendered" + : statusFromOmissionReason(omissionReason ?? (entry.status === "superseded" ? "superseded" : undefined)); + const summary = evidenceSummaryForMemory(evidenceByMemoryId, entry.id); + memoryRows.push({ + id: entry.id, + type: entry.type, + source: entry.source, + status, + strength: calculateRetentionStrength(entry, now, store.lastActivityAt), + reasonCodes: uniqueStrings([...renderStatusReason(status, omissionReason), ...summary.reasonCodes]), + textPreview: safeTextPreview(entry.text), + evidenceEventIds: summary.eventIds, + }); + seenIds.add(entry.id); + } + + for (const entry of journal.entries) { + const status = pendingStatus(entry); + const summary = evidenceSummaryForMemory(evidenceByMemoryId, entry.id); + memoryRows.push({ + id: entry.id, + type: entry.type, + source: entry.source, + status, + strength: calculateRetentionStrength(entry, now, store.lastActivityAt), + reasonCodes: uniqueStrings([...renderStatusReason(status), entry.lastPromotionFailureReason ?? "", ...summary.reasonCodes]), + textPreview: safeTextPreview(entry.text), + evidenceEventIds: summary.eventIds, + }); + seenIds.add(entry.id); + } + + for (const event of allEvents) { + if (event.outcome !== "absorbed") continue; + const memory = event.memory; + if (!memory?.memoryId || !memory.type || !memory.source || seenIds.has(memory.memoryId)) continue; + const summary = evidenceSummaryForMemory(evidenceByMemoryId, memory.memoryId); + memoryRows.push({ + id: memory.memoryId, + type: memory.type, + source: memory.source, + status: "omitted_absorbed_duplicate", + reasonCodes: uniqueStrings([...renderStatusReason("omitted_absorbed_duplicate"), ...summary.reasonCodes]), + evidenceEventIds: summary.eventIds.length > 0 ? summary.eventIds : [event.eventId], + }); + seenIds.add(memory.memoryId); + } + + const recentEvents = recentEvidence.map(event => ({ + eventId: event.eventId, + type: event.type, + outcome: event.outcome, + createdAt: event.createdAt, + memoryId: eventMemoryId(event), + reasonCodes: uniqueStrings([ + ...event.reasonCodes, + ...(event.type === "storage_corrupt_json_quarantined" ? ["quarantined_corrupt_store"] : []), + ]), + })); + + return { + store, + journal, + retention, + memories: memoryRows, + recentEvents, + allEvents, + summary: { + storedActive: store.entries.filter(entry => entry.status !== "superseded").length, + rendered: retention.rendered.length, + pending: journal.entries.length, + rejectedLast7Days: allEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7, now)).length, + corruptStoresQuarantinedLast30Days: allEvents.filter(event => event.type === "storage_corrupt_json_quarantined" && isWithinDays(event.createdAt, 30, now)).length, + }, + }; +} + +export async function buildMemoryDiagJSON(root: string): Promise { + const key = await workspaceKey(root); + const snapshot = await buildWorkspaceDiagSnapshot({ + root, + key, + memoryPath: await workspaceMemoryPath(root), + pendingPath: await workspacePendingJournalPath(root), + }); + + return memoryDiagJSONFromSnapshot(root, snapshot); +} + +export function memoryDiagJSONFromSnapshot(root: string, snapshot: WorkspaceDiagSnapshot, generatedAt = new Date().toISOString()): MemoryDiagJSON { + return { + version: 1, + workspace: { rootHash: workspaceRootHash(snapshot.store.workspace.root || root), key: snapshot.store.workspace.key }, + generatedAt, + summary: snapshot.summary, + memories: snapshot.memories, + recentEvents: snapshot.recentEvents, + }; +} + +export async function snapshotForOptions(options: CliOptions): Promise { + const root = options.workspace ?? process.cwd(); + const key = await workspaceKey(root); + return buildWorkspaceDiagSnapshot({ + root, + key, + memoryPath: await workspaceMemoryPath(root), + pendingPath: await workspacePendingJournalPath(root), + }); +} diff --git a/tests/memory-diag-analysis-models.test.ts b/tests/memory-diag-analysis-models.test.ts new file mode 100644 index 0000000..b620c9b --- /dev/null +++ b/tests/memory-diag-analysis-models.test.ts @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts"; +import type { LongTermMemoryEntry } from "../src/types.ts"; +import type { MemoryInspectionReadModel } from "../scripts/memory-diag/types.ts"; +import { CliInputError, normalizeRejection, rejectionQualitySummary, sinceCutoff } from "../scripts/memory-diag/rejections-model.ts"; +import { coverageRows, disappearanceRows } from "../scripts/memory-diag/inspection-model.ts"; +import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts"; +import { statusFromTraceEvent } from "../scripts/memory-diag/trace-model.ts"; + +function entry(id: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry { + const now = new Date("2026-01-01T00:00:00.000Z").toISOString(); + return { + id, + type, + text: `${id} text`, + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + }; +} + +function event(overrides: Partial & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 { + return { + version: 1, + eventId: `evt-${overrides.type}-${Math.random()}`, + createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(), + workspaceKey: "workspace-key", + workspaceRootHash: "workspace-root-hash", + reasonCodes: [], + ...overrides, + }; +} + +function model(entries: LongTermMemoryEntry[], events: EvidenceEventV1[]): MemoryInspectionReadModel { + return { + store: { + version: 1, + workspace: { root: "/tmp/workspace", key: "workspace-key" }, + limits: { maxRenderedChars: 24_000, maxEntries: 28 }, + entries, + migrations: [], + updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(), + }, + pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() }, + evidenceEvents: events, + rejectionRecords: [], + currentById: new Map(entries.map(memory => [memory.id, memory])), + evidenceByMemoryId: groupEvidenceByMemoryId(events), + }; +} + +test("normalizeRejection infers origins from source", () => { + assert.equal(normalizeRejection({ source: "compaction", text: "a", reasons: ["bad_decision"] })?.origin, "compaction_candidate"); + assert.equal(normalizeRejection({ source: "explicit", text: "a", reasons: ["bad_feedback"] })?.origin, "explicit_trigger"); + assert.equal(normalizeRejection({ source: "manual", text: "a", reasons: ["bad_feedback"] })?.origin, "manual"); + assert.equal(normalizeRejection({ source: "unknown-source", text: "a", reasons: ["bad_feedback"] })?.origin, "unknown"); +}); + +test("sinceCutoff accepts relative durations and ISO timestamps", () => { + const now = new Date("2026-01-15T12:00:00.000Z").getTime(); + + assert.equal(sinceCutoff("14d", now), now - 14 * 86_400_000); + assert.equal(sinceCutoff("3h", now), now - 3 * 3_600_000); + assert.equal(sinceCutoff("30m", now), now - 30 * 60_000); + assert.equal(sinceCutoff("2026-01-01T00:00:00.000Z", now), new Date("2026-01-01T00:00:00.000Z").getTime()); + assert.throws(() => sinceCutoff("forever", now), (error: unknown) => { + assert.ok(error instanceof CliInputError); + assert.equal((error as Error).message, "Invalid --since value: forever"); + return true; + }); +}); + +test("rejectionQualitySummary keeps architecture-like false-positive grouping", () => { + const records = [ + normalizeRejection({ type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] }), + normalizeRejection({ type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] }), + normalizeRejection({ type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] }), + ].filter(record => record !== null); + + const summary = rejectionQualitySummary(records); + + assert.equal(summary.totalRecords, 3); + assert.equal(summary.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1); + assert.equal(summary.possibleFalsePositiveGroups.clearly_garbage.count, 1); + assert.equal(summary.possibleFalsePositiveGroups.ambiguous.count, 1); +}); + +test("coverageRows classifies current and historical memory evidence", () => { + const entries = [entry("mem-full", "feedback"), entry("mem-render-only", "decision"), entry("mem-no-evidence", "project")]; + const events = [ + event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }), + event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }), + event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-render-only", type: "decision", source: "compaction" } }), + event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "historical-cap", type: "project", source: "compaction" }, reasonCodes: ["global_cap"] }), + event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "reference", source: "compaction" } }), + ]; + + const rows = coverageRows(model(entries, events), true); + const byId = new Map(rows.map(row => [row.id, row.class])); + + assert.equal(byId.get("mem-full"), "full_lifecycle"); + assert.equal(byId.get("mem-render-only"), "render_only"); + assert.equal(byId.get("mem-no-evidence"), "no_evidence"); + assert.equal(byId.get("historical-cap"), "historical_absent_with_reason"); + assert.equal(byId.get("historical-unknown"), "historical_absent_unknown_reason"); +}); + +test("disappearanceRows surfaces terminal capacity evidence", () => { + const events = [ + event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" } }), + event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" }, reasonCodes: ["type_cap"] }), + ]; + + const rows = disappearanceRows(model([], events)); + + assert.equal(rows.length, 1); + assert.equal(rows[0].id, "capacity-loser"); + assert.equal(rows[0].classification, "historical_absent_with_reason"); + assert.equal(rows[0].terminalType, "memory_removed_capacity"); + assert.deepEqual(rows[0].reasonCodes, ["type_cap"]); +}); + +test("statusFromTraceEvent maps lifecycle events", () => { + assert.equal(statusFromTraceEvent(undefined), "unknown"); + assert.equal(statusFromTraceEvent(event({ type: "render_selected", phase: "render", outcome: "rendered" })), "rendered"); + assert.equal(statusFromTraceEvent(event({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["type_cap"] })), "omitted_type_cap"); + assert.equal(statusFromTraceEvent(event({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed" })), "omitted_absorbed_duplicate"); + assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_scheduled", phase: "promotion", outcome: "retried" })), "pending_retry"); + assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_exhausted", phase: "promotion", outcome: "exhausted" })), "pending_rejected_capacity"); + assert.equal(statusFromTraceEvent(event({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined" })), "quarantined_corrupt_store"); + assert.equal(statusFromTraceEvent(event({ type: "promotion_superseded", phase: "promotion", outcome: "superseded" })), "omitted_superseded"); +}); diff --git a/tests/memory-diag-cli.test.ts b/tests/memory-diag-cli.test.ts new file mode 100644 index 0000000..4451fb8 --- /dev/null +++ b/tests/memory-diag-cli.test.ts @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseArgs } from "../scripts/memory-diag/cli.ts"; + +test("help returns usage without exiting", () => { + const parsed = parseArgs(["--help"]); + + assert.equal(parsed.ok, true); + assert.equal("help" in parsed && parsed.help, true); + assert.match(parsed.usage, /Usage:/); + assert.match(parsed.usage, /memory-diag \[status\]/); + assert.doesNotMatch(parsed.usage, /health/); +}); + +test("status defaults when no subcommand", () => { + const parsed = parseArgs([]); + + assert.equal(parsed.ok, true); + assert.equal("command" in parsed && parsed.command, "status"); + assert.deepEqual("options" in parsed && parsed.options, { raw: false, positional: [] }); +}); + +test("unknown command returns usage error", () => { + const parsed = parseArgs(["unknown"]); + + assert.equal(parsed.ok, false); + if (parsed.ok) return; + assert.equal(parsed.message, "Unknown subcommand: unknown"); + assert.equal(parsed.exitCode, 1); + assert.match(parsed.usage, /Usage:/); +}); + +test("current command flag validation messages are preserved", () => { + const cases: Array<{ args: string[]; message: string }> = [ + { args: ["health", "--json", "--all"], message: "health --json does not support --all" }, + { args: ["quality", "--all"], message: "quality does not accept --all" }, + { args: ["coverage", "--all"], message: "coverage does not accept --all" }, + { args: ["disappearances", "--all"], message: "disappearances does not accept --all" }, + { args: ["rejections", "--all"], message: "rejections does not accept --all" }, + { args: ["audit", "--workspace", "/tmp/workspace"], message: "audit does not accept --all or --workspace" }, + { args: ["explain", "--all"], message: "explain does not accept --all" }, + { args: ["trace", "--all", "--memory", "mem-1"], message: "trace does not accept --all" }, + { args: ["quality", "--since", "forever"], message: "quality does not accept rejection filters" }, + ]; + + for (const item of cases) { + const parsed = parseArgs(item.args); + assert.equal(parsed.ok, false, item.args.join(" ")); + if (parsed.ok) continue; + assert.equal(parsed.message, item.message); + assert.match(parsed.usage, /Usage:/); + } +}); + +test("trace without memory returns current required id error", () => { + const parsed = parseArgs(["trace"]); + + assert.equal(parsed.ok, false); + if (parsed.ok) return; + assert.equal(parsed.message, "--memory requires an id"); + assert.match(parsed.usage, /Usage:/); +}); + +test("health with all and workspace returns current conflict error", () => { + const parsed = parseArgs(["health", "--all", "--workspace", "/tmp/workspace"]); + + assert.equal(parsed.ok, false); + if (parsed.ok) return; + assert.equal(parsed.message, "Use either --all or --workspace, not both"); + assert.match(parsed.usage, /Usage:/); +}); + +test("rejections invalid since value returns current error", () => { + const parsed = parseArgs(["rejections", "--since", "forever"]); + + assert.equal(parsed.ok, false); + if (parsed.ok) return; + assert.equal(parsed.message, "Invalid --since value: forever"); + assert.match(parsed.usage, /Usage:/); +}); + +test("legacy health alias emits deprecation notice and maps to status", () => { + const parsed = parseArgs(["health"]); + + assert.equal(parsed.ok, true); + assert.equal("command" in parsed && parsed.command, "status"); + assert.equal("options" in parsed && parsed.options.legacyCommand, "health"); + assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'health' is now 'status'. This alias will be removed in v2.0."); +}); + +test("legacy quality alias sets verbose and emits deprecation notice", () => { + const parsed = parseArgs(["quality"]); + + assert.equal(parsed.ok, true); + assert.equal("command" in parsed && parsed.command, "status"); + assert.equal("options" in parsed && parsed.options.verbose, true); + assert.equal("options" in parsed && parsed.options.legacyCommand, "quality"); + assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0."); +}); + +test("legacy rejections alias emits deprecation notice", () => { + const parsed = parseArgs(["rejections"]); + + assert.equal(parsed.ok, true); + assert.equal("command" in parsed && parsed.command, "rejected"); + assert.equal("options" in parsed && parsed.options.legacyCommand, "rejections"); + assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0."); +}); + +test("legacy disappearances alias emits deprecation notice", () => { + const parsed = parseArgs(["disappearances"]); + + assert.equal(parsed.ok, true); + assert.equal("command" in parsed && parsed.command, "missing"); + assert.equal("options" in parsed && parsed.options.legacyCommand, "disappearances"); + assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0."); +}); + +test("legacy trace alias emits deprecation notice", () => { + const parsed = parseArgs(["trace", "--memory", "mem-1"]); + + assert.equal(parsed.ok, true); + assert.equal("command" in parsed && parsed.command, "explain"); + assert.equal("options" in parsed && parsed.options.legacyCommand, "trace"); + assert.equal("options" in parsed && parsed.options.memory, "mem-1"); + assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'trace --memory ' is now 'explain '. This alias will be removed in v2.0."); +}); + +test("explain accepts positional memory id", () => { + const parsed = parseArgs(["explain", "mem-1", "--workspace", "/tmp/workspace"]); + + assert.equal(parsed.ok, true); + assert.equal("command" in parsed && parsed.command, "explain"); + assert.equal("options" in parsed && parsed.options.memory, "mem-1"); + assert.deepEqual("options" in parsed && parsed.options.positional, ["mem-1"]); +}); + +test("explain with both positional and memory flag errors", () => { + const parsed = parseArgs(["explain", "mem-1", "--memory", "mem-2"]); + + assert.equal(parsed.ok, false); + if (parsed.ok) return; + assert.equal(parsed.message, "Use either explain or --memory, not both"); +}); diff --git a/tests/memory-diag-formatters.test.ts b/tests/memory-diag-formatters.test.ts new file mode 100644 index 0000000..c21bf2e --- /dev/null +++ b/tests/memory-diag-formatters.test.ts @@ -0,0 +1,127 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts"; +import type { MemoryInspectionReadModel, WorkspaceDiagSnapshot } from "../scripts/memory-diag/types.ts"; +import { formatWorkspaceHealth } from "../scripts/memory-diag/formatters/health.ts"; +import { formatQuality } from "../scripts/memory-diag/formatters/quality.ts"; +import { formatCoverage } from "../scripts/memory-diag/formatters/coverage.ts"; +import { formatDisappearances } from "../scripts/memory-diag/formatters/disappearances.ts"; +import { formatRejectionQuality } from "../scripts/memory-diag/formatters/rejections.ts"; +import { formatMigrationAudit } from "../scripts/memory-diag/formatters/audit.ts"; +import { formatExplain } from "../scripts/memory-diag/formatters/explain.ts"; +import { formatTrace } from "../scripts/memory-diag/formatters/trace.ts"; +import { rejectionQualitySummary } from "../scripts/memory-diag/rejections-model.ts"; + +function emptyInspectionModel(): MemoryInspectionReadModel { + return { + store: { + version: 1, + workspace: { root: "/tmp/workspace", key: "workspace-key" }, + limits: { maxRenderedChars: 24_000, maxEntries: 28 }, + entries: [], + migrations: [], + updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(), + }, + pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() }, + evidenceEvents: [], + rejectionRecords: [], + currentById: new Map(), + evidenceByMemoryId: new Map(), + }; +} + +function emptySnapshot(): WorkspaceDiagSnapshot { + return { + store: emptyInspectionModel().store, + journal: emptyInspectionModel().pending, + retention: { sorted: [], rendered: [], typeCapped: [], globalCapped: [] }, + memories: [], + recentEvents: [], + allEvents: [], + summary: { + storedActive: 0, + rendered: 0, + pending: 0, + rejectedLast7Days: 0, + corruptStoresQuarantinedLast30Days: 0, + }, + }; +} + +function event(overrides: Partial & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 { + return { + version: 1, + eventId: `evt-${overrides.type}`, + createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(), + workspaceKey: "workspace-key", + workspaceRootHash: "workspace-root-hash", + reasonCodes: [], + ...overrides, + }; +} + +test("health formatter includes existing retention cap label", () => { + const output = formatWorkspaceHealth({ + root: "/tmp/workspace", + key: "workspace-key", + memoryPath: "/tmp/workspace-memory.json", + pendingPath: "/tmp/workspace-pending-journal.json", + raw: false, + now: new Date("2026-01-01T00:00:00.000Z").getTime(), + includeTitle: true, + }, { rawStore: null, rawJournal: null, pendingExists: false }); + + assert.match(output, /Workspace memory health/); + assert.match(output, /Retention caps:/); +}); + +test("quality formatter includes caps and retention clock sections", () => { + const output = formatQuality(emptyInspectionModel(), new Date("2026-01-01T00:00:00.000Z").getTime()); + + assert.match(output, /Caps:/); + assert.match(output, /Retention clocks:/); +}); + +test("rejection quality formatter includes reason distribution sections", () => { + const summary = rejectionQualitySummary([]); + const output = formatRejectionQuality({ path: "/tmp/rejections.jsonl", invalidLines: 0, summary, raw: false }); + + assert.match(output, /Reason distribution \(raw records\):/); + assert.match(output, /Reason distribution \(unique text\):/); +}); + +test("coverage formatter includes class counts section", () => { + const output = formatCoverage([]); + + assert.match(output, /Class counts:/); + assert.match(output, /Per-memory rows:\n \(none\)/); +}); + +test("disappearances formatter preserves empty-state label", () => { + const output = formatDisappearances([]); + + assert.match(output, /No evidence-only memories found\./); +}); + +test("trace formatter includes lifecycle section", () => { + const output = formatTrace("mem-1", emptySnapshot(), { + events: [event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-1", type: "feedback", source: "explicit" } })], + }); + + assert.match(output, /Lifecycle:/); + assert.match(output, /evt-render_selected render_selected/); +}); + +test("audit formatter preserves no-log output", () => { + const output = formatMigrationAudit([], { raw: false }); + + assert.match(output, /Migration audit report/); + assert.match(output, /No migration logs found\./); +}); + +test("explain formatter preserves no-memory output", () => { + const output = formatExplain(emptySnapshot()); + + assert.match(output, /Workspace memory explainability/); + assert.match(output, /No memories found\./); +}); diff --git a/tests/memory-diag-utils.test.ts b/tests/memory-diag-utils.test.ts new file mode 100644 index 0000000..15e692c --- /dev/null +++ b/tests/memory-diag-utils.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { canonicalMemoryText, cleanText, countBy, sortedCounts, truncate } from "../scripts/memory-diag/text.ts"; +import { readJSONLFile } from "../scripts/memory-diag/io.ts"; + +test("cleanText redacts credentials and absolute paths unless raw", () => { + const text = "Use password: sushi and api_key=abc123 in /Users/alice/project/config.json"; + + const cleaned = cleanText(text, false); + + assert.match(cleaned, /password: \[REDACTED\]/); + assert.match(cleaned, /api_key=\[REDACTED\]/); + assert.match(cleaned, //); + assert.doesNotMatch(cleaned, /sushi/); + assert.doesNotMatch(cleaned, /\/Users\/alice/); + assert.equal(cleanText(text, true), text); +}); + +test("truncate collapses whitespace and applies ellipsis at max length", () => { + assert.equal(truncate(" hello\n\tworld "), "hello world"); + assert.equal(truncate("abcdef", 5), "abcd…"); +}); + +test("canonicalMemoryText normalizes punctuation and case", () => { + assert.equal(canonicalMemoryText("Hello, WORLD!!! Path?"), "hello world path"); +}); + +test("sortedCounts sorts by count descending then key ascending", () => { + const counts = new Map([["b", 2], ["a", 2], ["c", 3]]); + + assert.deepEqual(sortedCounts(counts), [["c", 3], ["a", 2], ["b", 2]]); +}); + +test("countBy counts string items", () => { + assert.deepEqual([...countBy(["beta", "alpha", "beta"]).entries()], [["beta", 2], ["alpha", 1]]); +}); + +test("readJSONLFile returns valid records and invalid line count", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-utils-")); + try { + const path = join(root, "records.jsonl"); + await writeFile(path, '{"id":"one"}\nnot-json\n\n{"id":"two"}\n', "utf8"); + + const result = await readJSONLFile<{ id: string }>(path); + + assert.deepEqual(result.records, [{ id: "one" }, { id: "two" }]); + assert.equal(result.invalidLines, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/memory-diag-workspace-model.test.ts b/tests/memory-diag-workspace-model.test.ts new file mode 100644 index 0000000..831f7d7 --- /dev/null +++ b/tests/memory-diag-workspace-model.test.ts @@ -0,0 +1,224 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { appendEvidenceEvents, queryEvidenceEvents, summarizeMemoryEvidence, type EvidenceEventInput, type EvidenceEventV1 } from "../src/evidence-log.ts"; +import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts"; +import { retentionCandidatesForDiag } from "../scripts/memory-diag/retention-model.ts"; +import { buildMemoryDiagJSON, memoryDiagJSONFromSnapshot, normalizedJournal, normalizedStore, snapshotForOptions } from "../scripts/memory-diag/workspace-snapshot.ts"; +import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; +import { LONG_TERM_LIMITS, type LongTermMemoryEntry, type PendingMemoryJournalStore, type WorkspaceMemoryStore } from "../src/types.ts"; + +function entry(id: string, text: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry { + const now = new Date("2026-01-01T00:00:00.000Z").toISOString(); + return { + id, + type, + text, + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + retentionClock: new Date(now).getTime(), + }; +} + +function evidence(overrides: Partial): EvidenceEventInput { + return { + type: "promotion_promoted", + phase: "promotion", + outcome: "promoted", + memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, + reasonCodes: ["new_workspace_entry"], + ...overrides, + }; +} + +function groupedEvidenceSummary(grouped: Map, memoryId: string): { eventIds: string[]; reasonCodes: string[] } { + const events = grouped.get(memoryId) ?? []; + const reasonCodes = new Set(); + for (const event of events) { + for (const reason of event.reasonCodes) reasonCodes.add(reason); + } + return { + eventIds: events.map(event => event.eventId), + reasonCodes: [...reasonCodes], + }; +} + +async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[]): Promise { + const key = await workspaceKey(root); + const path = await workspaceMemoryPath(root); + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries, + migrations: [], + updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(), + }; + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(store, null, 2), "utf8"); +} + +async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise { + const key = await workspaceKey(root); + const path = await workspacePendingJournalPath(root); + const store: PendingMemoryJournalStore = { + version: 1, + workspace: { root, key }, + entries, + updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(), + }; + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(store, null, 2), "utf8"); +} + +test("normalizedStore returns an empty store with limits and empty arrays", () => { + const store = normalizedStore(null, "/tmp/example-workspace", "workspace-key"); + + assert.equal(store.version, 1); + assert.deepEqual(store.workspace, { root: "/tmp/example-workspace", key: "workspace-key" }); + assert.deepEqual(store.limits, { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }); + assert.deepEqual(store.entries, []); + assert.deepEqual(store.migrations, []); +}); + +test("normalizedJournal returns an empty journal", () => { + const journal = normalizedJournal(null); + + assert.equal(journal.version, 1); + assert.deepEqual(journal.workspace, { root: "", key: "" }); + assert.deepEqual(journal.entries, []); + assert.equal(journal.updatedAt, new Date(0).toISOString()); +}); + +test("retentionCandidatesForDiag separates rendered, type-capped, and global-capped entries", () => { + const entries: LongTermMemoryEntry[] = [ + ...Array.from({ length: 11 }, (_, i) => entry(`feedback-${String(i).padStart(2, "0")}`, `Feedback memory ${i}`, "feedback")), + ...Array.from({ length: 10 }, (_, i) => entry(`decision-${String(i).padStart(2, "0")}`, `Decision memory ${i}`, "decision")), + ...Array.from({ length: 8 }, (_, i) => entry(`project-${String(i).padStart(2, "0")}`, `Project memory ${i}`, "project")), + ...Array.from({ length: 6 }, (_, i) => entry(`reference-${String(i).padStart(2, "0")}`, `Reference memory ${i}`, "reference")), + ]; + const store = normalizedStore({ entries, workspace: { root: "/tmp/root", key: "key" } } as WorkspaceMemoryStore, "/tmp/root", "key"); + + const candidates = retentionCandidatesForDiag(store, new Date("2026-01-02T00:00:00.000Z").getTime()); + + assert.equal(candidates.rendered.length, LONG_TERM_LIMITS.maxEntries); + assert.equal(candidates.typeCapped.length, 1); + assert.equal(candidates.globalCapped.length, 6); + assert.equal(candidates.typeCapped[0].entry.type, "feedback"); +}); + +test("buildMemoryDiagJSON redacts previews, includes pending entries, and preserves summary fields", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-workspace-model-")); + try { + const active = entry("mem-active", "Remember password: sushi and file /Users/alice/private.txt", "decision"); + const pending = { ...entry("mem-pending", "Pending api_key=secret-value", "project"), promotionAttempts: 1 }; + await writeWorkspaceStore(root, [active]); + await writePendingJournal(root, [pending]); + + const diag = await buildMemoryDiagJSON(root); + + assert.equal(diag.version, 1); + assert.equal(diag.summary.storedActive, 1); + assert.equal(diag.summary.rendered, 1); + assert.equal(diag.summary.pending, 1); + assert.equal(diag.summary.rejectedLast7Days, 0); + assert.equal(diag.summary.corruptStoresQuarantinedLast30Days, 0); + assert.equal(diag.memories.length, 2); + assert.equal(diag.memories.find(memory => memory.id === "mem-pending")?.status, "pending_retry"); + assert.ok(diag.memories.some(memory => memory.textPreview?.includes("[REDACTED]"))); + assert.ok(diag.memories.some(memory => memory.textPreview?.includes(""))); + assert.ok(!JSON.stringify(diag).includes("sushi")); + assert.ok(!JSON.stringify(diag).includes("secret-value")); + assert.equal(diag.workspace.key, await workspaceKey(root)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memoryDiagJSONFromSnapshot serializes an existing snapshot with fixed generatedAt", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-snapshot-json-")); + try { + const active = entry("mem-active", "Stable decision memory", "decision"); + const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 }; + await writeWorkspaceStore(root, [active]); + await writePendingJournal(root, [pending]); + + const snapshot = await snapshotForOptions({ raw: false, workspace: root }); + const generatedAt = "2026-05-02T00:00:00.000Z"; + const diag = memoryDiagJSONFromSnapshot(root, snapshot, generatedAt); + + assert.equal(diag.version, 1); + assert.equal(diag.generatedAt, generatedAt); + assert.equal(diag.memories, snapshot.memories); + assert.equal(diag.recentEvents, snapshot.recentEvents); + assert.equal(diag.summary, snapshot.summary); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("grouped evidence summaries match per-memory summaries for stored pending and absorbed memories", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-equivalence-")); + try { + const active = entry("mem-active", "Stable decision memory", "decision"); + const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 }; + await writeWorkspaceStore(root, [active]); + await writePendingJournal(root, [pending]); + await appendEvidenceEvents(root, [ + evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }), + evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }), + evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }), + ]); + + const grouped = groupEvidenceByMemoryId(await queryEvidenceEvents(root)); + for (const id of ["mem-active", "mem-pending", "mem-absorbed"]) { + const oldSummary = await summarizeMemoryEvidence(root, { memoryId: id }); + const groupedSummary = groupedEvidenceSummary(grouped, id); + + assert.deepEqual(groupedSummary.eventIds, oldSummary.eventIds); + assert.deepEqual(groupedSummary.reasonCodes, oldSummary.reasonCodes); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("buildMemoryDiagJSON preserves evidence ids and reason codes for stored pending and absorbed memories", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-rows-")); + try { + const active = entry("mem-active", "Stable decision memory", "decision"); + const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 }; + await writeWorkspaceStore(root, [active]); + await writePendingJournal(root, [pending]); + const events = await appendEvidenceEvents(root, [ + evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }), + evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }), + evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }), + ]); + + const diag = await buildMemoryDiagJSON(root); + const activeRow = diag.memories.find(memory => memory.id === "mem-active"); + const pendingRow = diag.memories.find(memory => memory.id === "mem-pending"); + const absorbedRow = diag.memories.find(memory => memory.id === "mem-absorbed"); + + assert.ok(activeRow); + assert.ok(pendingRow); + assert.ok(absorbedRow); + assert.deepEqual(activeRow.evidenceEventIds, [events[0].eventId]); + assert.ok(activeRow.reasonCodes.includes("stored_reason")); + assert.deepEqual(pendingRow.evidenceEventIds, [events[1].eventId]); + assert.ok(pendingRow.reasonCodes.includes("pending_reason")); + assert.deepEqual(absorbedRow.evidenceEventIds, [events[2].eventId]); + assert.ok(absorbedRow.reasonCodes.includes("same_exact_key")); + assert.ok(absorbedRow.reasonCodes.includes("absorbed_duplicate")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/memory-diag.test.ts b/tests/memory-diag.test.ts index 686bdf9..83e0d5a 100644 --- a/tests/memory-diag.test.ts +++ b/tests/memory-diag.test.ts @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; +import { mkdtempSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -9,7 +10,7 @@ import { promisify } from "node:util"; import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } from "../src/types.ts"; -import { extractionRejectionLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; +import { extractionRejectionLogPath, workspaceEvidenceLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; const execFileAsync = promisify(execFile); const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -51,13 +52,18 @@ async function runMemoryDiagHealth(root: string): Promise { } async function runMemoryDiag(args: string[]): Promise { - const { stdout } = await execFileAsync(process.execPath, [ + const { stdout } = await runMemoryDiagResult(args); + return stdout; +} + +async function runMemoryDiagResult(args: string[], options: { cwd?: string } = {}): Promise<{ stdout: string; stderr: string }> { + const { stdout, stderr } = await execFileAsync(process.execPath, [ "--experimental-strip-types", "scripts/memory-diag.ts", ...args, - ], { cwd: repoRoot }); + ], { cwd: options.cwd ?? repoRoot }); - return stdout; + return { stdout: stdout.trim(), stderr: stderr.trim() }; } async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise { @@ -90,6 +96,120 @@ function evidence(overrides: Partial): EvidenceEventInput { }; } +test("health handles missing workspace store as empty", async () => { + const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-")); + try { + const stdout = await runMemoryDiag(["health", "--workspace", root]); + + assert.match(stdout, /memory store: missing or unreadable \(treated as empty\)/); + assert.match(stdout, /pending journal: missing \(treated as empty\)/); + assert.match(stdout, /Stored active memories: 0/); + assert.match(stdout, /Pending journal:\n\s+total: 0/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality handles missing workspace store as empty", async () => { + const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-quality-")); + try { + const stdout = await runMemoryDiag(["quality", "--workspace", root]); + + assert.match(stdout, /Memory quality inspection/); + assert.match(stdout, /0 active memories/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("coverage and disappearances handle missing workspace store as empty", async () => { + const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-inspection-")); + try { + const coverageStdout = await runMemoryDiag(["coverage", "--workspace", root]); + assert.match(coverageStdout, /no_evidence: 0/); + assert.match(coverageStdout, /Per-memory rows:\n\s+\(none\)/); + + const missingStdout = await runMemoryDiag(["missing", "--workspace", root]); + assert.match(missingStdout, /No missing memories found\./); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("health with conflicting flags shows usage error", async () => { + const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-conflicting-flags-")); + try { + await assert.rejects( + runMemoryDiagResult(["health", "--all", "--workspace", root]), + (error: unknown) => { + const err = error as { code?: number; stderr?: string }; + assert.notEqual(err.code, 0); + assert.match(err.stderr ?? "", /Use either --all or --workspace, not both/); + assert.match(err.stderr ?? "", /Usage:/); + return true; + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag reports unexpected command errors without stack traces", async () => { + const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-error-boundary-")); + try { + await writeWorkspaceStore(root, []); + // Invalid rejection filter values are parse-caught before dispatch, so this + // fixture exercises the same top-level boundary with a command-thrown Error. + await mkdir(await workspaceEvidenceLogPath(root), { recursive: true }); + + await assert.rejects( + runMemoryDiagResult(["status", "--workspace", root]), + (error: unknown) => { + const err = error as { code?: number; stderr?: string }; + assert.notEqual(err.code, 0); + assert.match(err.stderr ?? "", /memory-diag failed:/); + assert.doesNotMatch(err.stderr ?? "", /\n\s+at\s|Error:/); + return true; + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("memory-diag defaults to status when no subcommand is supplied", async () => { + const result = await runMemoryDiagResult([]); + + assert.match(result.stdout, /Memory status/); + assert.match(result.stdout, /Key metrics:/); + assert.equal(result.stderr, ""); +}); + +test("legacy health alias emits deprecation notice and still runs", async () => { + const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-")); + try { + const result = await runMemoryDiagResult(["health", "--workspace", root]); + + assert.match(result.stdout, /Workspace memory health/); + assert.match(result.stderr, /Note: 'health' is now 'status'\. This alias will be removed in v2\.0\./); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("legacy trace alias emits deprecation notice and still traces memory", async () => { + const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-trace-")); + try { + const result = await runMemoryDiagResult(["trace", "--memory", "test-id", "--workspace", root]); + + assert.match(result.stdout, /Memory test-id: unknown/); + assert.match(result.stdout, /Lifecycle:/); + assert.match(result.stderr, /Note: 'trace --memory ' is now 'explain '\. This alias will be removed in v2\.0\./); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("memory health reports stored vs rendered retention counts", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-")); try { @@ -295,6 +415,30 @@ test("memory-diag trace prints lifecycle relations and redacts secrets", async ( } }); +test("memory-diag explain positional memory id prints lifecycle", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-explain-positional-")); + try { + await writeWorkspaceStore(root, [ + { ...entry("mem-life", "Old token password: sushi should be redacted", "decision"), status: "superseded" as const }, + entry("mem-new", "Replacement memory", "decision"), + ]); + await appendEvidenceEvents(root, [ + evidence({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, reasonCodes: ["quality_gate_passed"], textPreview: "password: sushi" }), + evidence({ type: "promotion_superseded", phase: "promotion", outcome: "superseded", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "superseded_by", memory: { memoryId: "mem-new" } }], reasonCodes: ["superseded_existing"] }), + ]); + + const stdout = await runMemoryDiag(["explain", "mem-life", "--workspace", root]); + + assert.match(stdout, /Memory mem-life: omitted_superseded/); + assert.match(stdout, /Lifecycle:/); + assert.match(stdout, /promotion_superseded: superseded; reasons=superseded_existing; .*superseded_by=mem-new/); + assert.match(stdout, /Superseded by:\n- mem-new/); + assert.ok(!stdout.includes("sushi")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("memory-diag trace requires --memory and reports unknown IDs", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-")); try { @@ -380,6 +524,94 @@ test("quality --json includes summaryText, caps, retention, evidence, and reject } }); +test("status default output is concise and actionable", async () => { + const root = await setupQualityFixture(); + try { + const stdout = await runMemoryDiag(["status", "--workspace", root]); + + assert.match(stdout, /OK Memory status|WARNING Memory status|DEGRADED Memory status/); + assert.match(stdout, /Key metrics:/); + assert.match(stdout, /active memories:/); + assert.match(stdout, /rendered:/); + assert.match(stdout, /pending:/); + assert.match(stdout, /rejected 7d:/); + assert.match(stdout, /evidence coverage:/); + assert.match(stdout, /Needs attention:/); + assert.match(stdout, /Suggested next steps:/); + assert.doesNotMatch(stdout, /Caps:|Retention clocks:|Rejection scoping:|Dormancy:/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("status --verbose includes detailed diagnostics", async () => { + const root = await setupQualityFixture(); + try { + const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]); + + assert.match(stdout, /Memory status inspection/); + assert.match(stdout, /Caps:/); + assert.match(stdout, /Retention clocks:/); + assert.match(stdout, /Evidence:/); + assert.match(stdout, /Rejection scoping:/); + assert.match(stdout, /Dormancy:/); + assert.match(stdout, /Top rendered candidates:/); + assert.match(stdout, /Weakest active memories:/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("status --json includes additive summary fields", async () => { + const root = await setupQualityFixture(); + try { + const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]); + const parsed = JSON.parse(stdout) as { + version: 1; + summary: { + storedActive: number; + rendered: number; + pending: number; + rejectedLast7Days: number; + corruptStoresQuarantinedLast30Days: number; + status?: string; + evidenceCoveragePercent?: number; + needsAttention?: string[]; + suggestedNextSteps?: string[]; + }; + memories: unknown[]; + recentEvents: unknown[]; + }; + + assert.equal(parsed.version, 1); + assert.equal(typeof parsed.summary.storedActive, "number"); + assert.equal(typeof parsed.summary.rendered, "number"); + assert.equal(typeof parsed.summary.pending, "number"); + assert.equal(typeof parsed.summary.rejectedLast7Days, "number"); + assert.equal(typeof parsed.summary.corruptStoresQuarantinedLast30Days, "number"); + assert.match(parsed.summary.status ?? "", /ok|warning|degraded/); + assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number"); + assert.ok(Array.isArray(parsed.summary.needsAttention)); + assert.ok(Array.isArray(parsed.summary.suggestedNextSteps)); + assert.ok(Array.isArray(parsed.memories)); + assert.ok(Array.isArray(parsed.recentEvents)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("status non-tty and --no-emoji use text labels", async () => { + const root = await setupQualityFixture(); + try { + const stdout = await runMemoryDiag(["status", "--workspace", root, "--no-emoji"]); + + assert.match(stdout, /^DEGRADED Memory status/); + assert.doesNotMatch(stdout, /🧠|⚠️|✖️/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("coverage human output includes class counts and per-memory rows", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-coverage-")); try { @@ -428,7 +660,7 @@ test("coverage --json includes event counts", async () => { } }); -test("disappearances labels historical evidence-only memory unknown without terminal event", async () => { +test("missing default output summarizes unknown disappearances", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-")); try { await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]); @@ -436,15 +668,21 @@ test("disappearances labels historical evidence-only memory unknown without term evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), ]); - const stdout = await runMemoryDiag(["disappearances", "--workspace", root]); + const stdout = await runMemoryDiag(["missing", "--workspace", root]); - assert.match(stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/); + assert.match(stdout, /Missing memory summary/); + assert.match(stdout, /Total missing: 1/); + assert.match(stdout, /Explained: 0/); + assert.match(stdout, /Needs review: 1/); + assert.match(stdout, /Unknown disappearance samples:/); + assert.match(stdout, /- historical-unknown terminal=unknown reasons=none/); + assert.doesNotMatch(stdout, /Memory historical-unknown:/); } finally { await rm(root, { recursive: true, force: true }); } }); -test("disappearances --explain shows capacity details and render omitted type-cap observations", async () => { +test("missing --verbose shows capacity details and render omitted type-cap observations", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-explain-")); try { await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]); @@ -461,9 +699,11 @@ test("disappearances --explain shows capacity details and render omitted type-ca evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "render-loser", type: "feedback", source: "compaction" }, reasonCodes: ["type_cap"] }), ]); - const stdout = await runMemoryDiag(["disappearances", "--workspace", root, "--explain"]); + const stdout = await runMemoryDiag(["missing", "--workspace", root, "--verbose"]); + assert.match(stdout, /Missing memory summary/); assert.match(stdout, /capacity-loser: historical_absent_with_reason terminal=memory_removed_capacity reasons=type_cap/); + assert.match(stdout, /events:/); assert.match(stdout, /memory_removed_capacity details: .*globalCap=28 .*typeCap=10/); assert.match(stdout, /render-loser: historical_absent_with_reason terminal=render_omitted reasons=type_cap/); assert.match(stdout, /render_omitted type-cap observation: reasons=type_cap/); @@ -472,6 +712,117 @@ test("disappearances --explain shows capacity details and render omitted type-ca } }); +test("missing --json includes disappearances and additive summary", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-missing-json-")); + try { + await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]); + await appendEvidenceEvents(root, [ + evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), + ]); + + const stdout = await runMemoryDiag(["missing", "--workspace", root, "--json"]); + const parsed = JSON.parse(stdout) as { disappearances: Array<{ id: string }>; summary: { total: number; explained: number; needsReview: number } }; + + assert.equal(parsed.summary.total, 1); + assert.equal(parsed.summary.explained, 0); + assert.equal(parsed.summary.needsReview, 1); + assert.equal(parsed.disappearances[0]?.id, "historical-unknown"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("legacy disappearances --explain emits deprecation notice and detailed output", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-legacy-")); + try { + await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]); + await appendEvidenceEvents(root, [ + evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), + ]); + + const result = await runMemoryDiagResult(["disappearances", "--workspace", root, "--explain"]); + + assert.match(result.stderr, /Note: 'disappearances' is now 'missing'\. This alias will be removed in v2\.0\./); + assert.match(result.stdout, /^Memory disappearances/); + assert.match(result.stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/); + assert.match(result.stdout, /events:/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejected default output is concise with top reasons and samples", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-")); + try { + const key = await workspaceKey(root); + const now = new Date().toISOString(); + await writeRejectionRecords([ + { timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Keep memory system boundary stable", reasons: ["bad_decision"] }, + { timestamp: now, workspaceKey: key, type: "feedback", source: "explicit", text: "Remember password: sushi from rejected sample", reasons: ["raw_secret", "bad_feedback"] }, + { timestamp: "2020-01-01T00:00:00.000Z", workspaceKey: key, type: "project", source: "manual", text: "Old rejected sample", reasons: ["bad_project"] }, + ]); + + const stdout = await runMemoryDiag(["rejected", "--workspace", root]); + + assert.match(stdout, /Rejected memory summary/); + assert.match(stdout, /Total rejected: 3/); + assert.match(stdout, /Unique texts: 3/); + assert.match(stdout, /Top reasons:/); + assert.match(stdout, /bad_decision\s+1/); + assert.match(stdout, /False-positive risk: low/); + assert.match(stdout, /Recent samples:/); + assert.match(stdout, /\[decision\] Keep memory system boundary stable/); + assert.ok(!stdout.includes("sushi")); + assert.doesNotMatch(stdout, /By origin:|Reason distribution \(raw records\):|Possible false-positive groups/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejected --verbose includes detailed distributions", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-verbose-")); + try { + const key = await workspaceKey(root); + const now = new Date().toISOString(); + await writeRejectionRecords([ + { timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] }, + { timestamp: now, workspaceKey: key, type: "feedback", source: "explicit", text: "Status update completed", reasons: ["bad_feedback"] }, + ]); + + const stdout = await runMemoryDiag(["rejected", "--workspace", root, "--verbose"]); + + assert.match(stdout, /Rejected memory summary/); + assert.match(stdout, /Reason distribution \(raw records\):/); + assert.match(stdout, /Reason distribution \(unique text\):/); + assert.match(stdout, /By origin:/); + assert.match(stdout, /Possible false-positive groups \(heuristic, not deterministic\):/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejected --json includes quality summary and false-positive risk", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-json-")); + try { + const key = await workspaceKey(root); + const now = new Date().toISOString(); + await writeRejectionRecords([ + { timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] }, + { timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] }, + ]); + + const stdout = await runMemoryDiag(["rejected", "--workspace", root, "--json"]); + const parsed = JSON.parse(stdout) as { totalRecords: number; uniqueTexts: number; falsePositiveRisk: string; possibleFalsePositiveGroups: Record }; + + assert.equal(parsed.totalRecords, 2); + assert.equal(parsed.uniqueTexts, 1); + assert.equal(parsed.falsePositiveRisk, "high"); + assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("rejections --quality --reason bad_decision --unique groups architecture-like samples heuristically", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-quality-")); try { @@ -485,6 +836,7 @@ test("rejections --quality --reason bad_decision --unique groups architecture-li const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--reason", "bad_decision", "--unique"]); + assert.match(stdout, /Extraction rejection quality inspection/); assert.match(stdout, /Possible false-positive grouping is heuristic, not deterministic truth/); assert.match(stdout, /architecture_like_possible_false_positive: 1/); assert.match(stdout, /clearly_garbage: 1/); @@ -509,12 +861,14 @@ test("rejections --quality --json includes scoping, unique reasons, and possible const parsed = JSON.parse(stdout) as { workspaceScopedCount: number; legacyUnscopedCount: number; + falsePositiveRisk: string; uniqueReasonDistribution: Record; possibleFalsePositiveGroups: Record; }; 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 { diff --git a/tsconfig.json b/tsconfig.json index e92ae18..424d34c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,6 @@ "allowImportingTsExtensions": true, "noEmit": true }, - "include": ["index.ts", "src/**/*.ts"], + "include": ["index.ts", "src/**/*.ts", "scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts"], "exclude": ["node_modules", "dist"] }