mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff5c568cb7 |
@@ -51,6 +51,8 @@ pnpm-lock.yaml
|
|||||||
|
|
||||||
# Superpowers local planning artifacts
|
# Superpowers local planning artifacts
|
||||||
docs/superpowers/plans/
|
docs/superpowers/plans/
|
||||||
|
docs/plans/
|
||||||
|
docs/disruptions/
|
||||||
|
|
||||||
# Local dev/admin script inputs
|
# Local dev/admin script inputs
|
||||||
scripts/dev/run-migration-roots.local.txt
|
scripts/dev/run-migration-roots.local.txt
|
||||||
|
|||||||
@@ -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.
|
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
|
## Configuration
|
||||||
|
|
||||||
OpenCode Working Memory works out of the box.
|
OpenCode Working Memory works out of the box.
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-working-memory",
|
"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",
|
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"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",
|
"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",
|
"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"
|
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||||
},
|
},
|
||||||
|
|||||||
+461
-6
@@ -9,7 +9,7 @@ import { existsSync } from "node:fs";
|
|||||||
import { readFile, readdir } from "node:fs/promises";
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
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 { redactCredentials } from "../src/redaction.ts";
|
||||||
import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts";
|
import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts";
|
||||||
import { accountWorkspaceMemoryRender, renderWorkspaceMemory } from "../src/workspace-memory.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 Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
|
||||||
|
|
||||||
type CliOptions = {
|
type CliOptions = {
|
||||||
@@ -82,6 +82,11 @@ type CliOptions = {
|
|||||||
all?: boolean;
|
all?: boolean;
|
||||||
softOnly?: boolean;
|
softOnly?: boolean;
|
||||||
triggerOnly?: boolean;
|
triggerOnly?: boolean;
|
||||||
|
includeHistorical?: boolean;
|
||||||
|
quality?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
unique?: boolean;
|
||||||
|
explain?: boolean;
|
||||||
since?: string;
|
since?: string;
|
||||||
migration?: string;
|
migration?: string;
|
||||||
memory?: string;
|
memory?: string;
|
||||||
@@ -91,6 +96,7 @@ type RejectionLogRecord = {
|
|||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
workspaceKey?: string;
|
workspaceKey?: string;
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
|
workspaceRootHash?: string;
|
||||||
type?: LongTermType;
|
type?: LongTermType;
|
||||||
source?: LongTermSource | string;
|
source?: LongTermSource | string;
|
||||||
origin?: string;
|
origin?: string;
|
||||||
@@ -102,6 +108,7 @@ type RejectionLogRecord = {
|
|||||||
type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
|
type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
|
||||||
workspaceKey?: string;
|
workspaceKey?: string;
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
|
workspaceRootHash?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
origin: Origin;
|
origin: Origin;
|
||||||
fromTrigger: boolean;
|
fromTrigger: boolean;
|
||||||
@@ -142,9 +149,13 @@ const ALLOWED_ORIGINS = new Set<Origin>([
|
|||||||
function usage(): string {
|
function usage(): string {
|
||||||
return `Usage:
|
return `Usage:
|
||||||
bun scripts/memory-diag.ts health [--workspace <path>] [--all] [--raw] [--json]
|
bun scripts/memory-diag.ts health [--workspace <path>] [--all] [--raw] [--json]
|
||||||
|
bun scripts/memory-diag.ts quality [--workspace <path>] [--raw] [--json]
|
||||||
|
bun scripts/memory-diag.ts coverage [--workspace <path>] [--include-historical] [--raw] [--json]
|
||||||
|
bun scripts/memory-diag.ts disappearances [--workspace <path>] [--explain] [--raw] [--json]
|
||||||
|
bun scripts/memory-diag.ts rejections --quality [--workspace <path>] [--reason <reason>] [--unique] [--json] [--raw]
|
||||||
bun scripts/memory-diag.ts explain [--workspace <path>] [--raw]
|
bun scripts/memory-diag.ts explain [--workspace <path>] [--raw]
|
||||||
bun scripts/memory-diag.ts trace --memory <id> [--workspace <path>] [--raw]
|
bun scripts/memory-diag.ts trace --memory <id> [--workspace <path>] [--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 <reason>] [--unique] [--workspace <path>] [--raw]
|
||||||
bun scripts/memory-diag.ts audit [--migration <id>] [--raw]
|
bun scripts/memory-diag.ts audit [--migration <id>] [--raw]
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -161,7 +172,7 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } {
|
|||||||
console.log(usage());
|
console.log(usage());
|
||||||
process.exit(0);
|
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}`);
|
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 === "--all") options.all = true;
|
||||||
else if (arg === "--soft-only") options.softOnly = true;
|
else if (arg === "--soft-only") options.softOnly = true;
|
||||||
else if (arg === "--trigger-only") options.triggerOnly = 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") {
|
else if (arg === "--workspace") {
|
||||||
const value = rest[++i];
|
const value = rest[++i];
|
||||||
if (!value) die("--workspace requires a path");
|
if (!value) die("--workspace requires a path");
|
||||||
@@ -181,6 +196,10 @@ function parseArgs(argv: string[]): { command: Command; options: CliOptions } {
|
|||||||
const value = rest[++i];
|
const value = rest[++i];
|
||||||
if (!value) die("--since requires a duration or ISO timestamp");
|
if (!value) die("--since requires a duration or ISO timestamp");
|
||||||
options.since = value;
|
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") {
|
} else if (arg === "--migration") {
|
||||||
const value = rest[++i];
|
const value = rest[++i];
|
||||||
if (!value) die("--migration requires an id");
|
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");
|
if (options.json && options.all) die("health --json does not support --all");
|
||||||
} else if (command === "explain" || command === "trace") {
|
} else if (command === "explain" || command === "trace") {
|
||||||
if (options.all) die(`${command} does not accept --all`);
|
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 {
|
} else {
|
||||||
if (options.all || options.workspace) die(`${command} does not accept --all or --workspace`);
|
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)) {
|
if (command !== "rejections" && (options.softOnly || options.triggerOnly || options.since)) {
|
||||||
die(`${command} does not accept rejection filters`);
|
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) {
|
if (command !== "audit" && options.migration) {
|
||||||
die(`${command} does not accept --migration`);
|
die(`${command} does not accept --migration`);
|
||||||
}
|
}
|
||||||
@@ -414,6 +441,24 @@ type WorkspaceDiagSnapshot = {
|
|||||||
summary: MemoryDiagJSON["summary"];
|
summary: MemoryDiagJSON["summary"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MemoryInspectionReadModel = {
|
||||||
|
store: WorkspaceMemoryStore;
|
||||||
|
pending: PendingMemoryJournalStore;
|
||||||
|
evidenceEvents: EvidenceEventV1[];
|
||||||
|
rejectionRecords: NormalizedRejection[];
|
||||||
|
currentById: Map<string, LongTermMemoryEntry>;
|
||||||
|
evidenceByMemoryId: Map<string, EvidenceEventV1[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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[] {
|
function uniqueStrings(values: string[]): string[] {
|
||||||
return [...new Set(values.filter(Boolean))];
|
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));
|
?? 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 {
|
function isWithinDays(iso: string, days: number): boolean {
|
||||||
const ms = new Date(iso).getTime();
|
const ms = new Date(iso).getTime();
|
||||||
return Number.isFinite(ms) && ms >= Date.now() - days * 86_400_000;
|
return Number.isFinite(ms) && ms >= Date.now() - days * 86_400_000;
|
||||||
@@ -786,6 +838,7 @@ function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | n
|
|||||||
timestamp: record.timestamp ?? "",
|
timestamp: record.timestamp ?? "",
|
||||||
workspaceKey: record.workspaceKey,
|
workspaceKey: record.workspaceKey,
|
||||||
workspaceRoot: record.workspaceRoot,
|
workspaceRoot: record.workspaceRoot,
|
||||||
|
workspaceRootHash: record.workspaceRootHash,
|
||||||
type: record.type ?? "project",
|
type: record.type ?? "project",
|
||||||
source: record.source,
|
source: record.source,
|
||||||
origin,
|
origin,
|
||||||
@@ -813,11 +866,24 @@ function hasSoftReason(record: NormalizedRejection): boolean {
|
|||||||
return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason));
|
return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRejections(options: CliOptions): Promise<void> {
|
function hasWorkspaceScope(record: NormalizedRejection): boolean {
|
||||||
|
return Boolean(record.workspaceKey || record.workspaceRoot || record.workspaceRootHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workspaceKeyForOption(options: CliOptions): Promise<string | undefined> {
|
||||||
|
return options.workspace ? workspaceKey(options.workspace) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRejectionRecords(options: CliOptions): Promise<{ path: string; invalidLines: number; records: NormalizedRejection[] }> {
|
||||||
const path = extractionRejectionLogPath();
|
const path = extractionRejectionLogPath();
|
||||||
const { records, invalidLines } = await readJSONLFile<RejectionLogRecord>(path);
|
const { records, invalidLines } = await readJSONLFile<RejectionLogRecord>(path);
|
||||||
const cutoff = sinceCutoff(options.since);
|
const cutoff = sinceCutoff(options.since);
|
||||||
|
const requestedWorkspaceKey = await workspaceKeyForOption(options);
|
||||||
let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null);
|
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) {
|
if (cutoff !== null) {
|
||||||
normalized = normalized.filter(record => {
|
normalized = normalized.filter(record => {
|
||||||
const timestamp = new Date(record.timestamp).getTime();
|
const timestamp = new Date(record.timestamp).getTime();
|
||||||
@@ -826,6 +892,392 @@ async function runRejections(options: CliOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (options.softOnly) normalized = normalized.filter(hasSoftReason);
|
if (options.softOnly) normalized = normalized.filter(hasSoftReason);
|
||||||
if (options.triggerOnly) normalized = normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger");
|
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<string, NormalizedRejection>();
|
||||||
|
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<T extends string>(counts: Map<T, number>): Record<string, number> {
|
||||||
|
return Object.fromEntries(sortedCounts(counts));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupEvidenceByMemoryId(events: EvidenceEventV1[]): Map<string, EvidenceEventV1[]> {
|
||||||
|
const grouped = new Map<string, EvidenceEventV1[]>();
|
||||||
|
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<MemoryInspectionReadModel> {
|
||||||
|
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<string, number>; byPhase: Record<string, number> } {
|
||||||
|
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<typeof eventCounts>;
|
||||||
|
}> {
|
||||||
|
const ids = new Set<string>(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<string, number>;
|
||||||
|
uniqueReasonDistribution: Record<string, number>;
|
||||||
|
possibleFalsePositiveGroups: Record<string, { count: number; samples: string[] }>;
|
||||||
|
} {
|
||||||
|
const uniqueRecords = uniqueByCanonicalText(records);
|
||||||
|
const badDecisionUnique = uniqueRecords.filter(record => record.reasons.includes("bad_decision"));
|
||||||
|
const groups: Record<string, { count: number; samples: string[] }> = {
|
||||||
|
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<typeof rejectionQualitySummary>): "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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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("Extraction rejection summary");
|
||||||
console.log("");
|
console.log("");
|
||||||
@@ -1073,6 +1525,9 @@ async function runTrace(options: CliOptions): Promise<void> {
|
|||||||
const { command, options } = parseArgs(process.argv.slice(2));
|
const { command, options } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
if (command === "health") await runHealth(options);
|
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 === "rejections") await runRejections(options);
|
||||||
else if (command === "audit") await runAudit(options);
|
else if (command === "audit") await runAudit(options);
|
||||||
else if (command === "explain") await runExplain(options);
|
else if (command === "explain") await runExplain(options);
|
||||||
|
|||||||
+4
-1
@@ -22,6 +22,7 @@ export type EvidenceEventType =
|
|||||||
| "memory_reinforced"
|
| "memory_reinforced"
|
||||||
| "render_selected"
|
| "render_selected"
|
||||||
| "render_omitted"
|
| "render_omitted"
|
||||||
|
| "memory_removed_capacity"
|
||||||
| "storage_corrupt_json_quarantined"
|
| "storage_corrupt_json_quarantined"
|
||||||
| "storage_stale_lock_recovered"
|
| "storage_stale_lock_recovered"
|
||||||
| "storage_lock_timeout"
|
| "storage_lock_timeout"
|
||||||
@@ -45,6 +46,7 @@ export type EvidenceOutcome =
|
|||||||
| "superseded"
|
| "superseded"
|
||||||
| "rendered"
|
| "rendered"
|
||||||
| "omitted"
|
| "omitted"
|
||||||
|
| "removed"
|
||||||
| "retried"
|
| "retried"
|
||||||
| "exhausted"
|
| "exhausted"
|
||||||
| "reinforced"
|
| "reinforced"
|
||||||
@@ -73,7 +75,8 @@ export type EvidenceRelation = {
|
|||||||
| "reinforced"
|
| "reinforced"
|
||||||
| "reinforced_by"
|
| "reinforced_by"
|
||||||
| "rendered"
|
| "rendered"
|
||||||
| "omitted";
|
| "omitted"
|
||||||
|
| "removed";
|
||||||
memory?: MemoryEvidenceRef;
|
memory?: MemoryEvidenceRef;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+25
-6
@@ -322,6 +322,13 @@ type ExtractionRejectionLogEntry = {
|
|||||||
text: string;
|
text: string;
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
source: "compaction";
|
source: "compaction";
|
||||||
|
workspaceKey?: string;
|
||||||
|
workspaceRootHash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceMemoryCandidateParseOptions = {
|
||||||
|
workspaceKey?: string;
|
||||||
|
workspaceRootHash?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
|
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
|
||||||
@@ -341,7 +348,7 @@ function evaluateWorkspaceMemoryCandidate(
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
fromMemoryTrigger?: boolean;
|
fromMemoryTrigger?: boolean;
|
||||||
} = {},
|
} & WorkspaceMemoryCandidateParseOptions = {},
|
||||||
): { accepted: boolean; reasons: string[] } {
|
): { accepted: boolean; reasons: string[] } {
|
||||||
const text = entry.text.trim();
|
const text = entry.text.trim();
|
||||||
const minLength = options.fromMemoryTrigger ? 6 : 20;
|
const minLength = options.fromMemoryTrigger ? 6 : 20;
|
||||||
@@ -367,6 +374,8 @@ function evaluateWorkspaceMemoryCandidate(
|
|||||||
text: redactCredentials(text),
|
text: redactCredentials(text),
|
||||||
reasons: quality.reasons,
|
reasons: quality.reasons,
|
||||||
source: "compaction",
|
source: "compaction",
|
||||||
|
workspaceKey: options.workspaceKey,
|
||||||
|
workspaceRootHash: options.workspaceRootHash,
|
||||||
});
|
});
|
||||||
return { accepted: false, reasons: quality.reasons };
|
return { accepted: false, reasons: quality.reasons };
|
||||||
}
|
}
|
||||||
@@ -381,7 +390,7 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
fromMemoryTrigger?: boolean;
|
fromMemoryTrigger?: boolean;
|
||||||
} = {},
|
} & WorkspaceMemoryCandidateParseOptions = {},
|
||||||
): boolean {
|
): boolean {
|
||||||
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
|
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
|
||||||
}
|
}
|
||||||
@@ -410,11 +419,17 @@ function extractCandidateBlock(summary: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
|
export function parseWorkspaceMemoryCandidates(
|
||||||
return parseWorkspaceMemoryCandidatesWithEvidence(summary).entries;
|
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);
|
const block = extractCandidateBlock(summary);
|
||||||
if (!block) return { entries: [], evidence: [] };
|
if (!block) return { entries: [], evidence: [] };
|
||||||
|
|
||||||
@@ -459,7 +474,11 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): Wor
|
|||||||
// Apply quality gate
|
// Apply quality gate
|
||||||
const quality = evaluateWorkspaceMemoryCandidate(
|
const quality = evaluateWorkspaceMemoryCandidate(
|
||||||
{ type, text: normalizedBody.text },
|
{ type, text: normalizedBody.text },
|
||||||
{ fromMemoryTrigger: normalizedBody.hadTrigger },
|
{
|
||||||
|
fromMemoryTrigger: normalizedBody.hadTrigger,
|
||||||
|
workspaceKey: options.workspaceKey,
|
||||||
|
workspaceRootHash: options.workspaceRootHash,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (!quality.accepted) {
|
if (!quality.accepted) {
|
||||||
evidence.push(extractionEvidence({
|
evidence.push(extractionEvidence({
|
||||||
|
|||||||
+27
-3
@@ -78,15 +78,39 @@ export function isFeedbackQualityViolation(text: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDecisionQualityViolation(text: string): boolean {
|
export function hasFutureRule(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)
|
return /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|
||||||
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.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(?: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;
|
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;
|
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 {
|
function isRawErrorViolation(text: string): boolean {
|
||||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
|
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
|
||||||
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
|
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
|
||||||
|
|||||||
+19
-3
@@ -23,7 +23,8 @@
|
|||||||
* - Parses compaction summaries for memory candidates and merges them
|
* - 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 type { Plugin } from "@opencode-ai/plugin";
|
||||||
import {
|
import {
|
||||||
extractExplicitMemoriesWithEvidence,
|
extractExplicitMemoriesWithEvidence,
|
||||||
@@ -57,7 +58,7 @@ import {
|
|||||||
addRecentDecision,
|
addRecentDecision,
|
||||||
renderHotSessionState,
|
renderHotSessionState,
|
||||||
} from "./session-state.ts";
|
} from "./session-state.ts";
|
||||||
import { sessionStatePath } from "./paths.ts";
|
import { sessionStatePath, workspaceKey } from "./paths.ts";
|
||||||
import {
|
import {
|
||||||
latestUserText,
|
latestUserText,
|
||||||
latestCompactionSummary,
|
latestCompactionSummary,
|
||||||
@@ -183,6 +184,19 @@ async function warnMemoryHook(scope: string, error: unknown, root?: string): Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function workspaceRootHash(root: string): Promise<string> {
|
||||||
|
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) => {
|
export const MemoryV2Plugin: Plugin = async (input) => {
|
||||||
const { directory, client } = 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
|
// Parse latest compaction summary for memory candidates, stage them into
|
||||||
// durable pending journal, then promote pending memories.
|
// durable pending journal, then promote pending memories.
|
||||||
const summary = await latestCompactionSummary(client, sessionID);
|
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 => ({
|
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
|
||||||
...event,
|
...event,
|
||||||
sessionHash: sessionID,
|
sessionHash: sessionID,
|
||||||
|
|||||||
+64
-2
@@ -17,6 +17,7 @@ import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
|||||||
const MIN_ENVELOPE_LENGTH = 80;
|
const MIN_ENVELOPE_LENGTH = 80;
|
||||||
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
||||||
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-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 =
|
export type MemoryConsolidationReason =
|
||||||
| "promoted"
|
| "promoted"
|
||||||
@@ -129,6 +130,7 @@ function hasSecurityOrMigrationChange(
|
|||||||
if (beforeEntry.text !== afterEntry.text) return true;
|
if (beforeEntry.text !== afterEntry.text) return true;
|
||||||
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
|
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
|
||||||
if (beforeEntry.status !== afterEntry.status) return true;
|
if (beforeEntry.status !== afterEntry.status) return true;
|
||||||
|
if ((beforeEntry.retentionClock ?? null) !== (afterEntry.retentionClock ?? null)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeMigrations = JSON.stringify(before.migrations ?? []);
|
const beforeMigrations = JSON.stringify(before.migrations ?? []);
|
||||||
@@ -184,7 +186,8 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
root: string,
|
root: string,
|
||||||
store: WorkspaceMemoryStore,
|
store: WorkspaceMemoryStore,
|
||||||
): Promise<WorkspaceMemoryNormalizationResult> {
|
): Promise<WorkspaceMemoryNormalizationResult> {
|
||||||
const nowIso = new Date().toISOString();
|
const nowMs = Date.now();
|
||||||
|
const nowIso = new Date(nowMs).toISOString();
|
||||||
|
|
||||||
let result: WorkspaceMemoryStore = {
|
let result: WorkspaceMemoryStore = {
|
||||||
...store,
|
...store,
|
||||||
@@ -236,6 +239,15 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
result = runMigrationP0Cleanup(result, nowIso);
|
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
|
// P0 accounting only considers active entries. Entries that were already
|
||||||
// superseded before this normalization are preserved in storage; entries that
|
// superseded before this normalization are preserved in storage; entries that
|
||||||
// lose during this enforcement are reported via accounting events but are not
|
// 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(
|
export function runMigrationP0Cleanup(
|
||||||
store: WorkspaceMemoryStore,
|
store: WorkspaceMemoryStore,
|
||||||
nowIso: string,
|
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 */
|
/** Choose better memory when identity/topic keys conflict */
|
||||||
function chooseBetterMemory(
|
function chooseBetterMemory(
|
||||||
a: LongTermMemoryEntry,
|
a: LongTermMemoryEntry,
|
||||||
@@ -525,6 +580,13 @@ export function enforceLongTermLimitsWithAccounting(
|
|||||||
const capped = applyTypeMaxCaps(sorted);
|
const capped = applyTypeMaxCaps(sorted);
|
||||||
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
|
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||||
const keptIds = new Set(kept.map(entry => entry.id));
|
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
|
const capacityDropped = sorted
|
||||||
.filter(entry => !keptIds.has(entry.id))
|
.filter(entry => !keptIds.has(entry.id))
|
||||||
.map(entry => consolidationEvent(entry, "rejected_capacity"));
|
.map(entry => consolidationEvent(entry, "rejected_capacity"));
|
||||||
@@ -534,7 +596,7 @@ export function enforceLongTermLimitsWithAccounting(
|
|||||||
dropped: [...dedupeResult.dropped, ...capacityDropped],
|
dropped: [...dedupeResult.dropped, ...capacityDropped],
|
||||||
absorbed: dedupeResult.absorbed,
|
absorbed: dedupeResult.absorbed,
|
||||||
superseded: dedupeResult.superseded,
|
superseded: dedupeResult.superseded,
|
||||||
evidence: dedupeResult.evidence,
|
evidence: [...dedupeResult.evidence, ...capacityEvidence],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
test("queryEvidenceEvents supports newestFirst and limit", async () => {
|
||||||
const root = await tempRoot();
|
const root = await tempRoot();
|
||||||
try {
|
try {
|
||||||
@@ -239,6 +281,7 @@ test("evidence relation roles reject sublimation placeholders at compile-time su
|
|||||||
"reinforced_by",
|
"reinforced_by",
|
||||||
"rendered",
|
"rendered",
|
||||||
"omitted",
|
"omitted",
|
||||||
|
"removed",
|
||||||
];
|
];
|
||||||
|
|
||||||
assert.equal(allowedRoles.includes("candidate"), true);
|
assert.equal(allowedRoles.includes("candidate"), true);
|
||||||
|
|||||||
@@ -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 () => {
|
test("parseWorkspaceMemoryCandidates redacts secrets in extraction rejection log", async () => {
|
||||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-"));
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-"));
|
||||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
|||||||
+212
-1
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
|
|||||||
import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts";
|
import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts";
|
||||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
||||||
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } 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 execFileAsync = promisify(execFile);
|
||||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
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");
|
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeRejectionRecords(records: unknown[]): Promise<void> {
|
||||||
|
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>): EvidenceEventInput {
|
function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
|
||||||
return {
|
return {
|
||||||
type: "promotion_promoted",
|
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 });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function setupQualityFixture(): Promise<string> {
|
||||||
|
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<string, number> } }> };
|
||||||
|
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<string, number>;
|
||||||
|
possibleFalsePositiveGroups: Record<string, { count: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
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", () => {
|
test("shared quality gate owns extractor low-quality syntax rejections", () => {
|
||||||
const rejected = [
|
const rejected = [
|
||||||
{ type: "project" as const, text: "fix: add new feature" },
|
{ type: "project" as const, text: "fix: add new feature" },
|
||||||
|
|||||||
@@ -1029,6 +1029,143 @@ test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounti
|
|||||||
assert.equal(result.store.entries[0].text, "Admin PIN 是 [REDACTED]");
|
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 () => {
|
test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => {
|
||||||
const root = "/repo";
|
const root = "/repo";
|
||||||
const now = new Date().toISOString();
|
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 });
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user