diff --git a/.gitignore b/.gitignore index 53177b9..524b3e2 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,8 @@ pnpm-lock.yaml # Superpowers local planning artifacts docs/superpowers/plans/ +docs/plans/ +docs/disruptions/ # Local dev/admin script inputs scripts/dev/run-migration-roots.local.txt diff --git a/README.md b/README.md index c7aa15e..5522d00 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,22 @@ npm run cleanup:workspaces -- --quarantine The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces. +### 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. + ## Configuration OpenCode Working Memory works out of the box. diff --git a/package.json b/package.json index a879773..3d66782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.5.1", + "version": "1.5.2", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "index.ts", @@ -15,8 +15,8 @@ ], "scripts": { "build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"", - "typecheck": "tsc --noEmit", - "test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts", + "typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"", + "test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"", "cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts", "check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test" }, diff --git a/scripts/memory-diag.ts b/scripts/memory-diag.ts index c8c021b..5533526 100644 --- a/scripts/memory-diag.ts +++ b/scripts/memory-diag.ts @@ -9,7 +9,7 @@ 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 } from "../src/memory-quality.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"; @@ -72,7 +72,7 @@ export type MemoryDiagJSON = { }>; }; -type Command = "health" | "rejections" | "audit" | "explain" | "trace"; +type Command = "health" | "quality" | "coverage" | "disappearances" | "rejections" | "audit" | "explain" | "trace"; type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown"; type CliOptions = { @@ -82,6 +82,11 @@ type CliOptions = { all?: boolean; softOnly?: boolean; triggerOnly?: boolean; + includeHistorical?: boolean; + quality?: boolean; + reason?: string; + unique?: boolean; + explain?: boolean; since?: string; migration?: string; memory?: string; @@ -91,6 +96,7 @@ type RejectionLogRecord = { timestamp?: string; workspaceKey?: string; workspaceRoot?: string; + workspaceRootHash?: string; type?: LongTermType; source?: LongTermSource | string; origin?: string; @@ -102,6 +108,7 @@ type RejectionLogRecord = { type NormalizedRejection = Required> & { workspaceKey?: string; workspaceRoot?: string; + workspaceRootHash?: string; source?: string; origin: Origin; fromTrigger: boolean; @@ -142,9 +149,13 @@ const ALLOWED_ORIGINS = new Set([ 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] [--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] `; } @@ -161,7 +172,7 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } { console.log(usage()); process.exit(0); } - if (command !== "health" && command !== "rejections" && command !== "audit" && command !== "explain" && command !== "trace") { + if (command !== "health" && command !== "quality" && command !== "coverage" && command !== "disappearances" && command !== "rejections" && command !== "audit" && command !== "explain" && command !== "trace") { die(`Unknown subcommand: ${command}`); } @@ -173,6 +184,10 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } { 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"); @@ -181,6 +196,10 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } { 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"); @@ -199,13 +218,21 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } { 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 (command !== "health" && options.json) die(`${command} does not accept --json`); + 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`); } @@ -414,6 +441,24 @@ type WorkspaceDiagSnapshot = { 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))]; } @@ -423,6 +468,13 @@ function eventMemoryId(event: EvidenceEventV1): string | undefined { ?? 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; @@ -786,6 +838,7 @@ function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | n timestamp: record.timestamp ?? "", workspaceKey: record.workspaceKey, workspaceRoot: record.workspaceRoot, + workspaceRootHash: record.workspaceRootHash, type: record.type ?? "project", source: record.source, origin, @@ -813,11 +866,24 @@ function hasSoftReason(record: NormalizedRejection): boolean { return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason)); } -async function runRejections(options: CliOptions): Promise { +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(); @@ -826,6 +892,392 @@ async function runRejections(options: CliOptions): Promise { } 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(""); @@ -1073,6 +1525,9 @@ async function runTrace(options: CliOptions): Promise { 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); diff --git a/src/evidence-log.ts b/src/evidence-log.ts index 41acd46..e10583f 100644 --- a/src/evidence-log.ts +++ b/src/evidence-log.ts @@ -22,6 +22,7 @@ export type EvidenceEventType = | "memory_reinforced" | "render_selected" | "render_omitted" + | "memory_removed_capacity" | "storage_corrupt_json_quarantined" | "storage_stale_lock_recovered" | "storage_lock_timeout" @@ -45,6 +46,7 @@ export type EvidenceOutcome = | "superseded" | "rendered" | "omitted" + | "removed" | "retried" | "exhausted" | "reinforced" @@ -73,7 +75,8 @@ export type EvidenceRelation = { | "reinforced" | "reinforced_by" | "rendered" - | "omitted"; + | "omitted" + | "removed"; memory?: MemoryEvidenceRef; }; diff --git a/src/extractors.ts b/src/extractors.ts index c1ff71d..0d24e5e 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -322,6 +322,13 @@ type ExtractionRejectionLogEntry = { text: string; reasons: string[]; source: "compaction"; + workspaceKey?: string; + workspaceRootHash?: string; +}; + +type WorkspaceMemoryCandidateParseOptions = { + workspaceKey?: string; + workspaceRootHash?: string; }; async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise { @@ -341,7 +348,7 @@ function evaluateWorkspaceMemoryCandidate( }, options: { fromMemoryTrigger?: boolean; - } = {}, + } & WorkspaceMemoryCandidateParseOptions = {}, ): { accepted: boolean; reasons: string[] } { const text = entry.text.trim(); const minLength = options.fromMemoryTrigger ? 6 : 20; @@ -367,6 +374,8 @@ function evaluateWorkspaceMemoryCandidate( text: redactCredentials(text), reasons: quality.reasons, source: "compaction", + workspaceKey: options.workspaceKey, + workspaceRootHash: options.workspaceRootHash, }); return { accepted: false, reasons: quality.reasons }; } @@ -381,7 +390,7 @@ function shouldAcceptWorkspaceMemoryCandidate( }, options: { fromMemoryTrigger?: boolean; - } = {}, + } & WorkspaceMemoryCandidateParseOptions = {}, ): boolean { return evaluateWorkspaceMemoryCandidate(entry, options).accepted; } @@ -410,11 +419,17 @@ function extractCandidateBlock(summary: string): string | null { return null; } -export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] { - return parseWorkspaceMemoryCandidatesWithEvidence(summary).entries; +export function parseWorkspaceMemoryCandidates( + summary: string, + options?: WorkspaceMemoryCandidateParseOptions, +): LongTermMemoryEntry[] { + return parseWorkspaceMemoryCandidatesWithEvidence(summary, options).entries; } -export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): WorkspaceMemoryParseResult { +export function parseWorkspaceMemoryCandidatesWithEvidence( + summary: string, + options: WorkspaceMemoryCandidateParseOptions = {}, +): WorkspaceMemoryParseResult { const block = extractCandidateBlock(summary); if (!block) return { entries: [], evidence: [] }; @@ -459,7 +474,11 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): Wor // Apply quality gate const quality = evaluateWorkspaceMemoryCandidate( { type, text: normalizedBody.text }, - { fromMemoryTrigger: normalizedBody.hadTrigger }, + { + fromMemoryTrigger: normalizedBody.hadTrigger, + workspaceKey: options.workspaceKey, + workspaceRootHash: options.workspaceRootHash, + }, ); if (!quality.accepted) { evidence.push(extractionEvidence({ diff --git a/src/memory-quality.ts b/src/memory-quality.ts index c473b8b..45a61ce 100644 --- a/src/memory-quality.ts +++ b/src/memory-quality.ts @@ -78,15 +78,39 @@ export function isFeedbackQualityViolation(text: string): boolean { return true; } -export function isDecisionQualityViolation(text: string): boolean { - const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text) +export function hasFutureRule(text: string): boolean { + return /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text) || /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text); - if (!futureRule) return true; +} + +export function isArchitectureLikeDecision(text: string): boolean { + if (/\b(?:[A-Z][A-Z0-9]*_[A-Z0-9_]*|[A-Z][A-Z0-9]{3,})\b/.test(text)) return true; + if (/\b(?:schema|model|scoring|retention|cap|evidence|normalization|root cause|architecture(?!\s+keywords?)|boundary|rule|memory system)\b/i.test(text)) return true; + if (/(?:模型|架構|架构|证据|證據|規則|规则|邊界|边界|記憶系統|记忆系统|原因|採用|采用)/u.test(text)) return true; + return false; +} + +export function isImplementationStatusDecision(text: string): boolean { if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true; if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true; return false; } +export function isDecisionQualityViolation(text: string): boolean { + if (hasFutureRule(text)) { + if (isImplementationStatusDecision(text)) return true; + return false; + } + + if (isArchitectureLikeDecision(text)) { + if (isImplementationStatusDecision(text)) return true; + if (/\b(?:session|wave|task|test|CI|compatibility|commit)\b/i.test(text)) return true; + return false; + } + + return true; +} + function isRawErrorViolation(text: string): boolean { if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true; if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true; diff --git a/src/plugin.ts b/src/plugin.ts index 56db781..1557796 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -23,7 +23,8 @@ * - Parses compaction summaries for memory candidates and merges them */ -import { rm } from "fs/promises"; +import { createHash } from "node:crypto"; +import { realpath, rm } from "fs/promises"; import type { Plugin } from "@opencode-ai/plugin"; import { extractExplicitMemoriesWithEvidence, @@ -57,7 +58,7 @@ import { addRecentDecision, renderHotSessionState, } from "./session-state.ts"; -import { sessionStatePath } from "./paths.ts"; +import { sessionStatePath, workspaceKey } from "./paths.ts"; import { latestUserText, latestCompactionSummary, @@ -183,6 +184,19 @@ async function warnMemoryHook(scope: string, error: unknown, root?: string): Pro } } +async function workspaceRootHash(root: string): Promise { + const resolved = await realpath(root).catch(() => root); + return createHash("sha256").update(resolved).digest("hex").slice(0, 16); +} + +async function workspaceIdentity(root: string): Promise<{ workspaceKey: string; workspaceRootHash: string }> { + const [workspaceKeyValue, workspaceRootHashValue] = await Promise.all([ + workspaceKey(root), + workspaceRootHash(root), + ]); + return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue }; +} + export const MemoryV2Plugin: Plugin = async (input) => { const { directory, client } = input; @@ -728,7 +742,9 @@ export const MemoryV2Plugin: Plugin = async (input) => { // Parse latest compaction summary for memory candidates, stage them into // durable pending journal, then promote pending memories. const summary = await latestCompactionSummary(client, sessionID); - const parseResult = summary ? parseWorkspaceMemoryCandidatesWithEvidence(summary) : { entries: [], evidence: [] }; + const parseResult = summary + ? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory)) + : { entries: [], evidence: [] }; await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({ ...event, sessionHash: sessionID, diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 5c15447..0566fcd 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -17,6 +17,7 @@ import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts"; const MIN_ENVELOPE_LENGTH = 80; const MIGRATION_ID = "2026-04-26-p0-cleanup"; const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup"; +const RETENTION_CLOCK_BACKFILL_MIGRATION_ID = "2026-05-01-retention-clock-backfill"; export type MemoryConsolidationReason = | "promoted" @@ -129,6 +130,7 @@ function hasSecurityOrMigrationChange( if (beforeEntry.text !== afterEntry.text) return true; if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true; if (beforeEntry.status !== afterEntry.status) return true; + if ((beforeEntry.retentionClock ?? null) !== (afterEntry.retentionClock ?? null)) return true; } const beforeMigrations = JSON.stringify(before.migrations ?? []); @@ -184,7 +186,8 @@ export async function normalizeWorkspaceMemoryWithAccounting( root: string, store: WorkspaceMemoryStore, ): Promise { - const nowIso = new Date().toISOString(); + const nowMs = Date.now(); + const nowIso = new Date(nowMs).toISOString(); let result: WorkspaceMemoryStore = { ...store, @@ -236,6 +239,15 @@ export async function normalizeWorkspaceMemoryWithAccounting( result = runMigrationP0Cleanup(result, nowIso); } + result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs)); + if (!result.migrations.includes(RETENTION_CLOCK_BACKFILL_MIGRATION_ID)) { + result = { + ...result, + migrations: [...result.migrations, RETENTION_CLOCK_BACKFILL_MIGRATION_ID], + updatedAt: nowIso, + }; + } + // P0 accounting only considers active entries. Entries that were already // superseded before this normalization are preserved in storage; entries that // lose during this enforcement are reported via accounting events but are not @@ -262,6 +274,24 @@ export async function normalizeWorkspaceMemoryWithAccounting( }; } +function backfillRetentionClock(entry: LongTermMemoryEntry, nowMs: number): LongTermMemoryEntry { + if (Number.isFinite(entry.retentionClock)) { + return entry; + } + + const createdAtMs = new Date(entry.createdAt).getTime(); + if (Number.isFinite(createdAtMs)) { + return { ...entry, retentionClock: createdAtMs }; + } + + const updatedAtMs = new Date(entry.updatedAt).getTime(); + if (Number.isFinite(updatedAtMs)) { + return { ...entry, retentionClock: updatedAtMs }; + } + + return { ...entry, retentionClock: nowMs }; +} + export function runMigrationP0Cleanup( store: WorkspaceMemoryStore, nowIso: string, @@ -470,6 +500,31 @@ function consolidationEvent( }; } +function capacityRemovalEvidence( + memory: LongTermMemoryEntry, + reason: "type_cap" | "global_cap" | "capacity", +): EvidenceEventInput { + return { + type: "memory_removed_capacity", + phase: "storage", + outcome: "removed", + reasonCodes: [reason], + memory: memoryEvidenceRef(memory), + relations: [{ + role: "removed", + memory: memoryEvidenceRef(memory), + }], + details: { + type: memory.type, + globalCap: LONG_TERM_LIMITS.maxEntries, + ...(reason === "type_cap" ? { typeCap: RETENTION_TYPE_MAX[memory.type] } : {}), + ...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}), + ...(memory.createdAt ? { createdAt: memory.createdAt } : {}), + ...(memory.source ? { source: memory.source } : {}), + }, + }; +} + /** Choose better memory when identity/topic keys conflict */ function chooseBetterMemory( a: LongTermMemoryEntry, @@ -525,6 +580,13 @@ export function enforceLongTermLimitsWithAccounting( const capped = applyTypeMaxCaps(sorted); const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries); const keptIds = new Set(kept.map(entry => entry.id)); + const cappedIds = new Set(capped.map(entry => entry.id)); + const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id)); + const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id)); + const capacityEvidence: EvidenceEventInput[] = [ + ...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap")), + ...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap")), + ]; const capacityDropped = sorted .filter(entry => !keptIds.has(entry.id)) .map(entry => consolidationEvent(entry, "rejected_capacity")); @@ -534,7 +596,7 @@ export function enforceLongTermLimitsWithAccounting( dropped: [...dedupeResult.dropped, ...capacityDropped], absorbed: dedupeResult.absorbed, superseded: dedupeResult.superseded, - evidence: dedupeResult.evidence, + evidence: [...dedupeResult.evidence, ...capacityEvidence], }; } diff --git a/tests/evidence-log.test.ts b/tests/evidence-log.test.ts index c8c252c..d83bf00 100644 --- a/tests/evidence-log.test.ts +++ b/tests/evidence-log.test.ts @@ -132,6 +132,48 @@ test("queryEvidenceEvents filters by type outcome and memory id", async () => { } }); +test("memory_removed_capacity event round-trips through append and query", async () => { + const root = await tempRoot(); + try { + await appendEvidenceEvent(root, eventInput({ + type: "memory_removed_capacity", + phase: "storage", + outcome: "removed", + reasonCodes: ["global_cap"], + memory: { memoryId: "removed-memory", type: "reference", source: "compaction", status: "active" }, + relations: [{ role: "removed", memory: { memoryId: "removed-memory", type: "reference", source: "compaction", status: "active" } }], + details: { + type: "reference", + globalCap: 28, + retentionClock: Date.UTC(2026, 4, 1), + createdAt: "2026-05-01T00:00:00.000Z", + source: "compaction", + }, + })); + + const result = await queryEvidenceEvents(root, { + types: ["memory_removed_capacity"], + phases: ["storage"], + outcomes: ["removed"], + memoryId: "removed-memory", + }); + + assert.equal(result.length, 1); + assert.equal(result[0].type, "memory_removed_capacity"); + assert.equal(result[0].phase, "storage"); + assert.equal(result[0].outcome, "removed"); + assert.deepEqual(result[0].details, { + type: "reference", + globalCap: 28, + retentionClock: Date.UTC(2026, 4, 1), + createdAt: "2026-05-01T00:00:00.000Z", + source: "compaction", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("queryEvidenceEvents supports newestFirst and limit", async () => { const root = await tempRoot(); try { @@ -239,6 +281,7 @@ test("evidence relation roles reject sublimation placeholders at compile-time su "reinforced_by", "rendered", "omitted", + "removed", ]; assert.equal(allowedRoles.includes("candidate"), true); diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index f22cdcd..083ddc0 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -387,6 +387,36 @@ Memory candidates: } }); +test("new rejection records include workspaceKey and workspaceRootHash when provided", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-scope-data-")); + const previousXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = dataHome; + + try { + const summary = ` +Memory candidates: +- feedback Wave 1 completed successfully and all tests passed +`; + + const result = parseWorkspaceMemoryCandidatesWithEvidence(summary, { + workspaceKey: "testkey1234567", + workspaceRootHash: "abcdef123456", + }); + + assert.equal(result.entries.length, 0); + const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl"); + const lines = (await waitForFile(logPath)).trim().split("\n"); + assert.equal(lines.length, 1); + const event = JSON.parse(lines[0]); + assert.equal(event.workspaceKey, "testkey1234567"); + assert.equal(event.workspaceRootHash, "abcdef123456"); + } finally { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + await rm(dataHome, { recursive: true, force: true }); + } +}); + test("parseWorkspaceMemoryCandidates redacts secrets in extraction rejection log", async () => { const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-")); const previousXdgDataHome = process.env.XDG_DATA_HOME; diff --git a/tests/memory-diag.test.ts b/tests/memory-diag.test.ts index a594ed1..686bdf9 100644 --- a/tests/memory-diag.test.ts +++ b/tests/memory-diag.test.ts @@ -9,7 +9,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 { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; +import { extractionRejectionLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; const execFileAsync = promisify(execFile); const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -73,6 +73,12 @@ async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]) await writeFile(path, JSON.stringify(store, null, 2), "utf8"); } +async function writeRejectionRecords(records: unknown[]): Promise { + const path = extractionRejectionLogPath(); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, records.map(record => JSON.stringify(record)).join("\n") + "\n", "utf8"); +} + function evidence(overrides: Partial): EvidenceEventInput { return { type: "promotion_promoted", @@ -310,3 +316,208 @@ test("memory-diag trace requires --memory and reports unknown IDs", async () => await rm(root, { recursive: true, force: true }); } }); + +async function setupQualityFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-quality-")); + const key = await workspaceKey(root); + const now = new Date().toISOString(); + const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries }, (_, i) => ({ + ...entry(`quality-${i}`, `Quality fixture memory ${i}`, i % 2 === 0 ? "feedback" as const : "decision" as const), + retentionClock: i === 0 ? -1 : Date.now(), + })); + await writeWorkspaceStore(root, entries); + await appendEvidenceEvents(root, [ + evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "quality-1", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), + evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "quality-historical", type: "reference", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), + ]); + await writeRejectionRecords([ + { timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] }, + { timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] }, + ]); + return root; +} + +test("quality human output includes summary and aggregate inspection counts", async () => { + const root = await setupQualityFixture(); + try { + const stdout = await runMemoryDiag(["quality", "--workspace", root]); + + assert.match(stdout, /Memory quality inspection/); + assert.match(stdout, /Summary: Workspace memory quality is degraded:/); + assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/); + assert.match(stdout, /decision: 14 \/ 10 FULL/); + assert.match(stdout, /feedback: 14 \/ 10 FULL/); + assert.match(stdout, /Retention clocks:\n\s+present: 27\n\s+missing: 0\n\s+invalid: 1/); + assert.match(stdout, /Evidence:\n\s+current with evidence: 1\n\s+current without evidence: 27/); + assert.match(stdout, /Rejection scoping:\n\s+total records: 2\n\s+workspace scoped: 1\n\s+legacy unscoped: 1/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality --json includes summaryText, caps, retention, evidence, and rejections", async () => { + const root = await setupQualityFixture(); + try { + const stdout = await runMemoryDiag(["quality", "--workspace", root, "--json"]); + const parsed = JSON.parse(stdout) as { + summaryText: string; + caps: { active: number; capsFull: boolean }; + retention: { invalid: number }; + evidence: { currentWithEvidence: number; unknownDisappearances: number }; + rejections: { workspaceScopedCount: number; legacyUnscopedCount: number }; + }; + + assert.match(parsed.summaryText, /Workspace memory quality is degraded/); + assert.equal(parsed.caps.active, LONG_TERM_LIMITS.maxEntries); + assert.equal(parsed.caps.capsFull, true); + assert.equal(parsed.retention.invalid, 1); + assert.equal(parsed.evidence.currentWithEvidence, 1); + assert.equal(parsed.evidence.unknownDisappearances, 1); + assert.equal(parsed.rejections.workspaceScopedCount, 1); + assert.equal(parsed.rejections.legacyUnscopedCount, 1); + } 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 { + await writeWorkspaceStore(root, [ + entry("mem-full", "Full lifecycle memory", "feedback"), + entry("mem-render-only", "Render only memory", "decision"), + entry("mem-no-evidence", "No evidence memory", "project"), + ]); + await appendEvidenceEvents(root, [ + evidence({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" }, reasonCodes: ["quality_gate_passed"] }), + evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), + evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-render-only", type: "decision", source: "compaction" }, reasonCodes: ["within_caps"] }), + ]); + + const stdout = await runMemoryDiag(["coverage", "--workspace", root]); + + assert.match(stdout, /Class counts:/); + assert.match(stdout, /full_lifecycle: 1/); + assert.match(stdout, /render_only: 1/); + assert.match(stdout, /no_evidence: 1/); + assert.match(stdout, /Per-memory rows:/); + assert.match(stdout, /mem-full full_lifecycle .*extraction=1 promotion=1/); + assert.match(stdout, /mem-render-only render_only .*render=1/); + assert.match(stdout, /mem-no-evidence no_evidence .*total=0/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("coverage --json includes event counts", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-coverage-json-")); + try { + await writeWorkspaceStore(root, [entry("mem-full", "Full lifecycle memory", "feedback")]); + await appendEvidenceEvents(root, [ + evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), + ]); + + const stdout = await runMemoryDiag(["coverage", "--workspace", root, "--json"]); + const parsed = JSON.parse(stdout) as { memories: Array<{ id: string; eventCounts: { total: number; byType: Record } }> }; + const row = parsed.memories.find(memory => memory.id === "mem-full"); + + assert.equal(row?.eventCounts.total, 1); + assert.equal(row?.eventCounts.byType.promotion_promoted, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("disappearances labels historical evidence-only memory unknown without terminal event", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-")); + 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(["disappearances", "--workspace", root]); + + assert.match(stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("disappearances --explain 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")]); + await appendEvidenceEvents(root, [ + evidence({ + type: "memory_removed_capacity", + phase: "storage", + outcome: "removed", + memory: { memoryId: "capacity-loser", type: "decision", source: "compaction", status: "active" }, + relations: [{ role: "removed", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction", status: "active" } }], + reasonCodes: ["type_cap"], + details: { type: "decision", globalCap: 28, typeCap: 10, source: "compaction" }, + }), + 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"]); + + assert.match(stdout, /capacity-loser: historical_absent_with_reason terminal=memory_removed_capacity reasons=type_cap/); + 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/); + } 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 { + const key = await workspaceKey(root); + const now = new Date().toISOString(); + await writeRejectionRecords([ + { timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] }, + { timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] }, + { timestamp: now, type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] }, + ]); + + const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--reason", "bad_decision", "--unique"]); + + assert.match(stdout, /Possible false-positive grouping is heuristic, not deterministic truth/); + assert.match(stdout, /architecture_like_possible_false_positive: 1/); + assert.match(stdout, /clearly_garbage: 1/); + assert.match(stdout, /ambiguous: 1/); + assert.doesNotMatch(stdout, /deterministic truth\s*:/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejections --quality --json includes scoping, unique reasons, and possible false-positive groups", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-json-")); + try { + const key = await workspaceKey(root); + const now = new Date().toISOString(); + await writeRejectionRecords([ + { timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] }, + { timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] }, + ]); + + const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--json"]); + const parsed = JSON.parse(stdout) as { + workspaceScopedCount: number; + legacyUnscopedCount: number; + uniqueReasonDistribution: Record; + possibleFalsePositiveGroups: Record; + }; + + assert.equal(parsed.workspaceScopedCount, 1); + assert.equal(parsed.legacyUnscopedCount, 1); + assert.equal(parsed.uniqueReasonDistribution.bad_decision, 1); + assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/memory-quality-eval.test.ts b/tests/memory-quality-eval.test.ts index c51c0a7..70f4697 100644 --- a/tests/memory-quality-eval.test.ts +++ b/tests/memory-quality-eval.test.ts @@ -136,6 +136,33 @@ test("decision must be future-facing rule, not completed implementation note", ( assert.equal(assessMemoryQuality({ type: "decision", text: "Added semantic merge tests in the previous wave", source: "compaction" }).accepted, false); }); +test("bad_decision 3-tier gate: architecture-like decisions accepted without future-rule imperative", () => { + const architectureLikeCases = [ + { text: "Rule 不在記憶系統 schema 內,歸用戶(agent.md / claude.md),系統最多到 Preference + Suggestion", type: "decision" as const }, + { text: "Ghost memory root cause: normalization 把 capacity losers 從 store 移除時沒有 emit terminal evidence", type: "decision" as const }, + { text: "BASE_HALF_LIFE_DAYS 應從 60 降低到 45", type: "decision" as const }, + { text: "採用 decay-rate 模型取代 priority+penalty 模型", type: "decision" as const }, + { text: "從 scoring 移除 confidence,目前是固定值無意義", type: "decision" as const }, + ]; + + const stillRejectedCases = [ + { text: "Implemented phase 2 and updated tests", type: "decision" as const }, + { text: "Implemented CI_SCHEMA_UPDATE for compatibility run 42", type: "decision" as const }, + { text: "Session reviewed the architecture model changes", type: "decision" as const }, + { text: "Some random text with no architecture keywords or future rules", type: "decision" as const }, + ]; + + for (const entry of architectureLikeCases) { + const result = assessMemoryQuality({ ...entry, source: "compaction" }); + assert.equal(result.reasons.includes("bad_decision"), false, `${entry.text} -> ${result.reasons.join(",")}`); + } + + for (const entry of stillRejectedCases) { + const result = assessMemoryQuality({ ...entry, source: "compaction" }); + assert.equal(result.reasons.includes("bad_decision"), true, `${entry.text} -> ${result.reasons.join(",")}`); + } +}); + test("shared quality gate owns extractor low-quality syntax rejections", () => { const rejected = [ { type: "project" as const, text: "fix: add new feature" }, diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index ebf9b23..fc9c179 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -1029,6 +1029,143 @@ test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounti assert.equal(result.store.entries[0].text, "Admin PIN 是 [REDACTED]"); }); +test("retentionClock backfill: missing clock becomes createdAt timestamp", async () => { + const root = "/repo"; + const createdAt = "2026-01-02T03:04:05.000Z"; + const memory = { + ...entry("backfill-created", "Use durable createdAt for retention backfill", "decision"), + createdAt, + updatedAt: "2026-04-01T03:04:05.000Z", + }; + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"], + entries: [memory], + updatedAt: createdAt, + }; + + const result = await normalizeWorkspaceMemoryWithAccounting(root, store); + + assert.equal(result.store.entries[0].retentionClock, new Date(createdAt).getTime()); +}); + +test("retentionClock backfill: invalid createdAt uses updatedAt", async () => { + const root = "/repo"; + const updatedAt = "2026-02-03T04:05:06.000Z"; + const memory = { + ...entry("backfill-updated", "Use updatedAt only when createdAt is invalid", "decision"), + createdAt: "not-a-date", + updatedAt, + }; + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"], + entries: [memory], + updatedAt, + }; + + const result = await normalizeWorkspaceMemoryWithAccounting(root, store); + + assert.equal(result.store.entries[0].retentionClock, new Date(updatedAt).getTime()); +}); + +test("retentionClock backfill: both invalid uses Date.now()", async () => { + const root = "/repo"; + const before = Date.now(); + const memory = { + ...entry("backfill-now", "Use current wall clock when stored timestamps are invalid", "decision"), + createdAt: "not-a-date", + updatedAt: "also-not-a-date", + }; + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"], + entries: [memory], + updatedAt: new Date(before).toISOString(), + }; + + const result = await normalizeWorkspaceMemoryWithAccounting(root, store); + const after = Date.now(); + const retentionClock = result.store.entries[0].retentionClock; + + assert.equal(typeof retentionClock, "number"); + assert.ok(Number.isFinite(retentionClock)); + assert.ok((retentionClock ?? 0) >= before); + assert.ok((retentionClock ?? 0) <= after); +}); + +test("retentionClock backfill: valid clock is unchanged", async () => { + const root = "/repo"; + const createdAt = "2026-01-02T03:04:05.000Z"; + const retentionClock = new Date("2025-12-31T00:00:00.000Z").getTime(); + const memory = { + ...entry("backfill-valid", "Preserve existing valid retention clocks", "decision"), + createdAt, + updatedAt: "2026-04-01T03:04:05.000Z", + retentionClock, + }; + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"], + entries: [memory], + updatedAt: createdAt, + }; + + const result = await normalizeWorkspaceMemoryWithAccounting(root, store); + + assert.equal(result.store.entries[0].retentionClock, retentionClock); +}); + +test("retentionClock backfill: rendered IDs unchanged before and after", async () => { + const root = "/repo"; + const oldCreatedAt = "2026-01-01T00:00:00.000Z"; + const newCreatedAt = "2026-02-01T00:00:00.000Z"; + const entries = [ + { + ...entry("with-clock", "Decision with a pre-existing retention clock", "decision"), + createdAt: newCreatedAt, + updatedAt: newCreatedAt, + retentionClock: new Date(newCreatedAt).getTime(), + }, + { + ...entry("missing-clock", "Decision missing a retention clock but with createdAt", "decision"), + createdAt: oldCreatedAt, + updatedAt: "2026-04-01T00:00:00.000Z", + }, + ]; + const baseStore: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"], + entries, + updatedAt: newCreatedAt, + }; + const prefilledStore: WorkspaceMemoryStore = { + ...baseStore, + entries: entries.map(memory => ({ + ...memory, + retentionClock: memory.retentionClock ?? new Date(memory.createdAt).getTime(), + })), + }; + + const missingClockResult = await normalizeWorkspaceMemoryWithAccounting(root, baseStore); + const prefilledResult = await normalizeWorkspaceMemoryWithAccounting(root, prefilledStore); + + assert.deepEqual( + missingClockResult.kept.map(memory => memory.id), + prefilledResult.kept.map(memory => memory.id), + ); +}); + test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => { const root = "/repo"; const now = new Date().toISOString(); @@ -2179,3 +2316,108 @@ test("loadWorkspaceMemory normalizes and persists credentials from legacy unreda await rm(sandbox, { recursive: true, force: true }); } }); + +function decisionEntry(id: string, text: string, timestampMs: number): LongTermMemoryEntry { + const timestamp = new Date(timestampMs).toISOString(); + return { + id, + type: "decision", + text, + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: timestamp, + updatedAt: timestamp, + retentionClock: timestampMs, + }; +} + +test("enforceLongTermLimitsWithAccounting capacity drops return 3 lower-ranked decisions in dropped", () => { + const now = Date.UTC(2026, 4, 1, 6, 24, 0); + const thirtyDaysAgo = now - 30 * DAY_MS; + const existingDecisions = Array.from({ length: 10 }, (_, i) => + decisionEntry( + `existing-decision-${i}`, + `Existing durable architecture decision ${i}`, + thirtyDaysAgo, + ) + ); + const newDecisions = Array.from({ length: 3 }, (_, i) => + decisionEntry( + `new-decision-${i}`, + `Newer durable architecture decision ${i}`, + now, + ) + ); + + const result = enforceLongTermLimitsWithAccounting([...existingDecisions, ...newDecisions]); + const capacityDrops = result.dropped.filter(event => event.reason === "rejected_capacity"); + const droppedIds = new Set(capacityDrops.map(event => event.memory.id)); + const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity"); + + assert.equal(capacityDrops.length, 3); + assert.equal(capacityEvidence.length, 3); + for (const event of capacityEvidence) { + assert.equal(event.phase, "storage"); + assert.equal(event.outcome, "removed"); + assert.ok(event.memory?.memoryId, "capacity evidence should include removed memory id"); + assert.ok(event.memory.memoryKeyHash, "capacity evidence should include removed memory key hash"); + assert.ok(event.memory.identityKeyHash, "capacity evidence should include removed identity key hash"); + assert.equal(droppedIds.has(event.memory.memoryId), true); + assert.ok(event.relations?.[0]?.memory.memoryKeyHash, "removed relation should include memory key hash"); + assert.ok(event.relations?.[0]?.memory.identityKeyHash, "removed relation should include identity key hash"); + assert.deepEqual(event.reasonCodes, ["type_cap"]); + } +}); + +test("enforceLongTermLimitsWithAccounting emits global_cap evidence for global cap losers", () => { + const now = Date.UTC(2026, 4, 1, 6, 24, 0); + const entries: LongTermMemoryEntry[] = [ + ...Array.from({ length: RETENTION_TYPE_MAX.feedback }, (_, i) => + decisionEntry(`global-feedback-${i}`, `Global cap feedback ${i}`, now) + ).map(memory => ({ ...memory, type: "feedback" as const })), + ...Array.from({ length: RETENTION_TYPE_MAX.decision }, (_, i) => + decisionEntry(`global-decision-${i}`, `Global cap decision ${i}`, now) + ), + ...Array.from({ length: RETENTION_TYPE_MAX.project }, (_, i) => + decisionEntry(`global-project-${i}`, `Global cap project ${i}`, now) + ).map(memory => ({ ...memory, type: "project" as const })), + ...Array.from({ length: RETENTION_TYPE_MAX.reference }, (_, i) => + decisionEntry(`global-reference-${i}`, `Global cap reference ${i}`, now) + ).map(memory => ({ ...memory, type: "reference" as const })), + ]; + + const result = enforceLongTermLimitsWithAccounting(entries); + const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity"); + const globalCapEvidence = capacityEvidence.filter(event => event.reasonCodes.includes("global_cap")); + + assert.equal(entries.length, 34); + assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries); + assert.equal(globalCapEvidence.length, entries.length - LONG_TERM_LIMITS.maxEntries); + assert.equal(capacityEvidence.some(event => event.reasonCodes.includes("type_cap")), false); + assert.ok(globalCapEvidence.every(event => event.phase === "storage" && event.outcome === "removed")); +}); + +test("accountWorkspaceMemoryRender emits render_omitted for type_cap with 11 decisions", () => { + const now = Date.UTC(2026, 4, 1, 6, 24, 0); + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: Array.from({ length: 11 }, (_, i) => + decisionEntry(`render-decision-${i}`, `Render durable decision ${i}`, now) + ), + updatedAt: new Date(now).toISOString(), + lastActivityAt: new Date(now).toISOString(), + }; + + const accounting = accountWorkspaceMemoryRender(store); + const typeCapOmissions = accounting.omitted.filter(item => item.reason === "type_cap"); + const typeCapEvidence = accounting.evidence.filter(event => + event.type === "render_omitted" && event.reasonCodes.includes("type_cap") + ); + + assert.equal(accounting.rendered.length, RETENTION_TYPE_MAX.decision); + assert.equal(typeCapOmissions.length, 1); + assert.equal(typeCapEvidence.length, 1); +});