mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9bdcdd785 | |||
| cf05b9fa69 | |||
| aaa4016ae8 | |||
| 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
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.5.3] - 2026-05-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Published the read-only `memory-diag` package binary for package-qualified `npx --package opencode-working-memory memory-diag` usage.
|
||||||
|
- Added user-facing diagnostics commands for concise status, rejected memory review, missing memory review, and per-memory explanation.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reworked the diagnostics CLI into focused command, model, formatter, and utility modules while preserving legacy aliases and maintainer diagnostics.
|
||||||
|
- Updated README and configuration docs to document the supported `memory-diag` CLI workflow and package-qualified `npx` usage.
|
||||||
|
- Raised the Node.js requirement to `>=22.6.0` for the TypeScript diagnostics CLI runtime.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Avoided repeated workspace snapshot construction in status diagnostics.
|
||||||
|
- Replaced per-memory evidence summary lookups with grouped evidence processing to avoid N+1 diagnostics work.
|
||||||
|
- Added top-level CLI error handling for cleaner user-facing failures.
|
||||||
|
- Removed a diagnostics model circular dependency by extracting evidence grouping helpers to a neutral module.
|
||||||
|
|
||||||
## [1.5.1] - 2026-04-30
|
## [1.5.1] - 2026-04-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -200,14 +200,35 @@ The goal is to remember durable facts, not every detail.
|
|||||||
|
|
||||||
Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y".
|
Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y".
|
||||||
|
|
||||||
For local development cleanup, use:
|
### Memory Diagnostics CLI
|
||||||
|
|
||||||
|
Use the read-only diagnostics CLI when you want to understand what memory is doing for the current workspace.
|
||||||
|
|
||||||
|
| Question | Command |
|
||||||
|
|---|---|
|
||||||
|
| Is memory healthy? | `npx --package opencode-working-memory memory-diag` or `npx --package opencode-working-memory memory-diag status` |
|
||||||
|
| Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` |
|
||||||
|
| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` |
|
||||||
|
| Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain <memory-id>` |
|
||||||
|
|
||||||
|
Global options:
|
||||||
|
|
||||||
|
- `--workspace <path>` — inspect another workspace; defaults to the current directory.
|
||||||
|
- `--verbose` — show detailed diagnostics.
|
||||||
|
- `--json` — print machine-readable output where supported.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run cleanup:workspaces -- --dry-run
|
npx --package opencode-working-memory memory-diag status
|
||||||
npm run cleanup:workspaces -- --quarantine
|
npx --package opencode-working-memory memory-diag rejected --verbose
|
||||||
|
npx --package opencode-working-memory memory-diag missing --workspace /path/to/project
|
||||||
|
npx --package opencode-working-memory memory-diag status --json
|
||||||
```
|
```
|
||||||
|
|
||||||
The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
|
The npm package is opencode-working-memory; the installed bin is memory-diag, so package-qualified npx avoids resolving a different package named memory-diag.
|
||||||
|
|
||||||
|
Maintainer-only diagnostics and cleanup commands are intentionally not documented here. Future work: move those internal commands to `docs/development.md`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -245,13 +266,12 @@ cd opencode-working-memory
|
|||||||
npm install
|
npm install
|
||||||
npm test
|
npm test
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run cleanup:workspaces -- --dry-run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- OpenCode plugin API `>=1.2.0 <2.0.0`
|
- OpenCode plugin API `>=1.2.0 <2.0.0`
|
||||||
- Node.js >= 18.0.0
|
- Node.js >= 22.6.0 (for `memory-diag` CLI, which runs TypeScript with `--experimental-strip-types`)
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## 1.5.3 (2026-05-02)
|
||||||
|
|
||||||
|
### Published Memory Diagnostics CLI
|
||||||
|
|
||||||
|
This release makes the read-only workspace memory diagnostics CLI available as the package binary `memory-diag`, with user-facing commands for checking memory health and understanding why memories are rejected, missing, shown, or hidden.
|
||||||
|
|
||||||
|
> Good memory is selective memory — and diagnostics should explain the selection.
|
||||||
|
|
||||||
|
### What Changed
|
||||||
|
|
||||||
|
- **Published CLI bin**: run diagnostics with `npx --package opencode-working-memory memory-diag` or `memory-diag status`.
|
||||||
|
- **User-facing commands**: `status`, `rejected`, `missing`, and `explain <memory-id>` are documented as the supported public workflow.
|
||||||
|
- **Legacy compatibility**: existing maintainer and legacy commands remain accepted with deprecation notices where applicable.
|
||||||
|
- **Cleaner CLI architecture**: the former monolithic diagnostics script is now split into focused command, model, formatter, and utility modules.
|
||||||
|
- **Faster diagnostics**: evidence grouping avoids repeated per-memory evidence queries in snapshot diagnostics.
|
||||||
|
- **Cleaner failures**: top-level CLI error handling now reports usage and unexpected command errors without noisy stack traces.
|
||||||
|
- **Docs alignment**: README and configuration docs now use package-qualified `npx` commands to avoid resolving an unrelated package named `memory-diag`.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Node.js `>=22.6.0` is now required because the published diagnostics binary runs TypeScript through Node's `--experimental-strip-types` support.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||||
|
- `npm test` — 358 tests passing, `TEST_PASS`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1.5.1 (2026-04-30)
|
## 1.5.1 (2026-04-30)
|
||||||
|
|
||||||
### Evidence Loop and Explainability
|
### Evidence Loop and Explainability
|
||||||
|
|||||||
+11
-3
@@ -204,13 +204,21 @@ cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
|
|||||||
|
|
||||||
### Inspect Retention Health
|
### Inspect Retention Health
|
||||||
|
|
||||||
From a source checkout, maintainers can inspect stored vs rendered memory behavior:
|
Use the diagnostics CLI to check memory health for the current workspace:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun scripts/memory-diag.ts health
|
npx --package opencode-working-memory memory-diag status
|
||||||
|
# or from a source checkout:
|
||||||
|
npm run diag -- status
|
||||||
```
|
```
|
||||||
|
|
||||||
The health output includes stored active memories, rendered candidates, type caps, global cap overflow, dormancy status, retention monitoring alerts, and strength-ranked top/weakest entries.
|
For detailed diagnostics, use `--verbose`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx --package opencode-working-memory memory-diag status --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
The status output includes overall health (OK/WARNING/DEGRADED), key metrics, and suggested next steps when attention is needed.
|
||||||
|
|
||||||
### Clear Workspace Memory
|
### Clear Workspace Memory
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -1,22 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-working-memory",
|
"name": "opencode-working-memory",
|
||||||
"version": "1.5.1",
|
"version": "1.5.3",
|
||||||
"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",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts"
|
".": "./index.ts"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"memory-diag": "./scripts/memory-diag-bin.cjs"
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"index.ts",
|
"index.ts",
|
||||||
"src/",
|
"src/",
|
||||||
|
"scripts/memory-diag.ts",
|
||||||
|
"scripts/memory-diag/",
|
||||||
|
"scripts/memory-diag-bin.cjs",
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"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",
|
"diag": "node --experimental-strip-types scripts/memory-diag.ts",
|
||||||
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts",
|
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
|
||||||
|
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
|
||||||
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
|
"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"
|
||||||
},
|
},
|
||||||
@@ -47,6 +54,6 @@
|
|||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=22.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Read-only baseline report for workspace-memory exact/identity key collisions.
|
||||||
|
*
|
||||||
|
* This script reads workspace-memory.json files from the local workspace memory
|
||||||
|
* data directory. It never writes to workspace memory stores; the only optional
|
||||||
|
* write is the explicit --json report path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
|
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../../src/workspace-memory.ts";
|
||||||
|
import type { LongTermMemoryEntry, LongTermSource, LongTermType } from "../../src/types.ts";
|
||||||
|
|
||||||
|
type CliOptions = {
|
||||||
|
workspacesDir: string;
|
||||||
|
jsonPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReinforcementCountStatistics = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
mean: number;
|
||||||
|
gtZero: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemorySummary = {
|
||||||
|
id: string;
|
||||||
|
type: LongTermType;
|
||||||
|
source: LongTermSource;
|
||||||
|
text: string;
|
||||||
|
reinforcementCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceSummary = {
|
||||||
|
dirName: string;
|
||||||
|
dirPath: string;
|
||||||
|
key: string;
|
||||||
|
root: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollisionGroup = {
|
||||||
|
kind: "exact" | "identity";
|
||||||
|
workspace: WorkspaceSummary;
|
||||||
|
key: string;
|
||||||
|
classification: "unclassified";
|
||||||
|
memories: MemorySummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ValidationReport = {
|
||||||
|
summary: {
|
||||||
|
workspaces: number;
|
||||||
|
activeMemories: number;
|
||||||
|
exactCollisionGroups: number;
|
||||||
|
identityCollisionGroups: number;
|
||||||
|
reinforcementCountStatistics: ReinforcementCountStatistics;
|
||||||
|
};
|
||||||
|
groups: CollisionGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScannedMemory = {
|
||||||
|
entry: LongTermMemoryEntry;
|
||||||
|
exactKey: string;
|
||||||
|
identityKey: string;
|
||||||
|
reinforcementCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_WORKSPACES_DIR = join(homedir(), ".local", "share", "opencode-working-memory", "workspaces");
|
||||||
|
const WORKSPACES_DIR_ENV_KEYS = [
|
||||||
|
"OPENCODE_WORKING_MEMORY_WORKSPACES_DIR",
|
||||||
|
"WORKSPACE_MEMORY_WORKSPACES_DIR",
|
||||||
|
];
|
||||||
|
|
||||||
|
function usage(): string {
|
||||||
|
return `Usage:
|
||||||
|
node --experimental-strip-types scripts/dev/validate-identity-keys.ts [workspaces-dir] [--json <path>]
|
||||||
|
node --experimental-strip-types scripts/dev/validate-identity-keys.ts --data-path <workspaces-dir> [--json <path>]
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
workspaces-dir defaults to ${DEFAULT_WORKSPACES_DIR}
|
||||||
|
env override: ${WORKSPACES_DIR_ENV_KEYS.join(" or ")}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function die(message: string): never {
|
||||||
|
console.error(message);
|
||||||
|
console.error(usage());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function envWorkspacesDir(): string | undefined {
|
||||||
|
for (const key of WORKSPACES_DIR_ENV_KEYS) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandHome(path: string): string {
|
||||||
|
if (path === "~") return homedir();
|
||||||
|
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliOptions {
|
||||||
|
let workspacesDir: string | undefined;
|
||||||
|
let jsonPath: string | undefined;
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === "--help" || arg === "-h") {
|
||||||
|
console.log(usage());
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (arg === "--json") {
|
||||||
|
const value = argv[++i];
|
||||||
|
if (!value) die("--json requires a path");
|
||||||
|
jsonPath = expandHome(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--data-path" || arg === "--workspaces-dir") {
|
||||||
|
const value = argv[++i];
|
||||||
|
if (!value) die(`${arg} requires a path`);
|
||||||
|
if (workspacesDir) die("Only one workspaces directory may be provided");
|
||||||
|
workspacesDir = expandHome(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg.startsWith("--")) die(`Unknown option: ${arg}`);
|
||||||
|
if (workspacesDir) die("Only one workspaces directory may be provided");
|
||||||
|
workspacesDir = expandHome(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspacesDir: resolve(workspacesDir ?? expandHome(envWorkspacesDir() ?? DEFAULT_WORKSPACES_DIR)),
|
||||||
|
jsonPath: jsonPath ? resolve(jsonPath) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLongTermType(value: unknown): value is LongTermType {
|
||||||
|
return value === "feedback" || value === "project" || value === "decision" || value === "reference";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLongTermSource(value: unknown): value is LongTermSource {
|
||||||
|
return value === "explicit" || value === "compaction" || value === "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMemoryEntry(value: unknown): value is LongTermMemoryEntry {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const entry = value as Partial<LongTermMemoryEntry>;
|
||||||
|
return typeof entry.id === "string"
|
||||||
|
&& isLongTermType(entry.type)
|
||||||
|
&& typeof entry.text === "string"
|
||||||
|
&& isLongTermSource(entry.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reinforcementCount(entry: LongTermMemoryEntry): number {
|
||||||
|
const count = entry.reinforcementCount ?? 0;
|
||||||
|
return Number.isFinite(count) && count > 0 ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeMemory(memory: ScannedMemory): MemorySummary {
|
||||||
|
return {
|
||||||
|
id: memory.entry.id,
|
||||||
|
type: memory.entry.type,
|
||||||
|
source: memory.entry.source,
|
||||||
|
text: memory.entry.text,
|
||||||
|
reinforcementCount: memory.reinforcementCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToGroupMap(map: Map<string, ScannedMemory[]>, key: string, memory: ScannedMemory): void {
|
||||||
|
const group = map.get(key);
|
||||||
|
if (group) {
|
||||||
|
group.push(memory);
|
||||||
|
} else {
|
||||||
|
map.set(key, [memory]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeReinforcementStatistics(counts: number[]): ReinforcementCountStatistics {
|
||||||
|
if (counts.length === 0) {
|
||||||
|
return { min: 0, max: 0, mean: 0, gtZero: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = counts.reduce((total, count) => total + count, 0);
|
||||||
|
return {
|
||||||
|
min: Math.min(...counts),
|
||||||
|
max: Math.max(...counts),
|
||||||
|
mean: sum / counts.length,
|
||||||
|
gtZero: counts.filter(count => count > 0).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkspaceMemory(workspaceDir: string): Promise<unknown | undefined> {
|
||||||
|
const path = join(workspaceDir, "workspace-memory.json");
|
||||||
|
if (!existsSync(path)) return undefined;
|
||||||
|
try {
|
||||||
|
const raw = await readFile(path, "utf8");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
||||||
|
if (code === "ENOENT" || code === "EISDIR") return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanWorkspaces(workspacesDir: string): Promise<ValidationReport> {
|
||||||
|
const dirents = await readdir(workspacesDir, { withFileTypes: true });
|
||||||
|
const workspaceDirs = dirents
|
||||||
|
.filter(dirent => dirent.isDirectory())
|
||||||
|
.map(dirent => dirent.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const collisionGroups: CollisionGroup[] = [];
|
||||||
|
const reinforcementCounts: number[] = [];
|
||||||
|
let workspaces = 0;
|
||||||
|
let activeMemories = 0;
|
||||||
|
|
||||||
|
for (const dirName of workspaceDirs) {
|
||||||
|
const dirPath = join(workspacesDir, dirName);
|
||||||
|
const store = await readWorkspaceMemory(dirPath);
|
||||||
|
if (!store || typeof store !== "object") continue;
|
||||||
|
|
||||||
|
const storeObject = store as {
|
||||||
|
workspace?: { key?: unknown; root?: unknown };
|
||||||
|
entries?: unknown;
|
||||||
|
};
|
||||||
|
const rawEntries = Array.isArray(storeObject.entries) ? storeObject.entries : [];
|
||||||
|
const workspace: WorkspaceSummary = {
|
||||||
|
dirName,
|
||||||
|
dirPath,
|
||||||
|
key: typeof storeObject.workspace?.key === "string" ? storeObject.workspace.key : dirName,
|
||||||
|
root: typeof storeObject.workspace?.root === "string" ? storeObject.workspace.root : "<unknown>",
|
||||||
|
};
|
||||||
|
|
||||||
|
workspaces += 1;
|
||||||
|
|
||||||
|
const exactGroups = new Map<string, ScannedMemory[]>();
|
||||||
|
const identityGroups = new Map<string, ScannedMemory[]>();
|
||||||
|
|
||||||
|
for (const rawEntry of rawEntries) {
|
||||||
|
if (!isMemoryEntry(rawEntry)) continue;
|
||||||
|
if (rawEntry.status === "superseded") continue;
|
||||||
|
|
||||||
|
const scanned: ScannedMemory = {
|
||||||
|
entry: rawEntry,
|
||||||
|
exactKey: workspaceMemoryExactKey(rawEntry),
|
||||||
|
identityKey: workspaceMemoryIdentityKey(rawEntry),
|
||||||
|
reinforcementCount: reinforcementCount(rawEntry),
|
||||||
|
};
|
||||||
|
activeMemories += 1;
|
||||||
|
reinforcementCounts.push(scanned.reinforcementCount);
|
||||||
|
addToGroupMap(exactGroups, scanned.exactKey, scanned);
|
||||||
|
addToGroupMap(identityGroups, scanned.identityKey, scanned);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, memories] of exactGroups.entries()) {
|
||||||
|
if (memories.length < 2) continue;
|
||||||
|
collisionGroups.push({
|
||||||
|
kind: "exact",
|
||||||
|
workspace,
|
||||||
|
key,
|
||||||
|
classification: "unclassified",
|
||||||
|
memories: memories.map(summarizeMemory),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, memories] of identityGroups.entries()) {
|
||||||
|
if (memories.length < 2) continue;
|
||||||
|
collisionGroups.push({
|
||||||
|
kind: "identity",
|
||||||
|
workspace,
|
||||||
|
key,
|
||||||
|
classification: "unclassified",
|
||||||
|
memories: memories.map(summarizeMemory),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactCollisionGroups = collisionGroups.filter(group => group.kind === "exact").length;
|
||||||
|
const identityCollisionGroups = collisionGroups.filter(group => group.kind === "identity").length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
workspaces,
|
||||||
|
activeMemories,
|
||||||
|
exactCollisionGroups,
|
||||||
|
identityCollisionGroups,
|
||||||
|
reinforcementCountStatistics: computeReinforcementStatistics(reinforcementCounts),
|
||||||
|
},
|
||||||
|
groups: collisionGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneLine(text: string): string {
|
||||||
|
return text.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHumanReport(report: ValidationReport, workspacesDir: string): string {
|
||||||
|
const stats = report.summary.reinforcementCountStatistics;
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Workspace memory identity key validation baseline");
|
||||||
|
lines.push(`Data path: ${workspacesDir}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Summary:");
|
||||||
|
lines.push(`- Workspaces scanned: ${report.summary.workspaces}`);
|
||||||
|
lines.push(`- Active memories: ${report.summary.activeMemories}`);
|
||||||
|
lines.push(`- Exact collision groups: ${report.summary.exactCollisionGroups}`);
|
||||||
|
lines.push(`- Identity collision groups: ${report.summary.identityCollisionGroups}`);
|
||||||
|
lines.push(`- Reinforcement counts: min=${stats.min} max=${stats.max} mean=${stats.mean.toFixed(2)} gtZero=${stats.gtZero}`);
|
||||||
|
|
||||||
|
if (report.groups.length === 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Collision breakdown: none");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsByWorkspace = new Map<string, CollisionGroup[]>();
|
||||||
|
for (const group of report.groups) {
|
||||||
|
const key = group.workspace.key;
|
||||||
|
const existing = groupsByWorkspace.get(key);
|
||||||
|
if (existing) existing.push(group);
|
||||||
|
else groupsByWorkspace.set(key, [group]);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Collision breakdown:");
|
||||||
|
for (const [workspaceKey, groups] of [...groupsByWorkspace.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||||
|
const workspace = groups[0].workspace;
|
||||||
|
const exactCount = groups.filter(group => group.kind === "exact").length;
|
||||||
|
const identityCount = groups.filter(group => group.kind === "identity").length;
|
||||||
|
lines.push(`- Workspace ${workspaceKey}`);
|
||||||
|
lines.push(` root: ${workspace.root}`);
|
||||||
|
lines.push(` dir: ${workspace.dirPath}`);
|
||||||
|
lines.push(` exactCollisionGroups=${exactCount} identityCollisionGroups=${identityCount}`);
|
||||||
|
|
||||||
|
for (const group of groups.sort((a, b) => a.kind.localeCompare(b.kind) || a.key.localeCompare(b.key))) {
|
||||||
|
lines.push(` - ${group.kind} key: ${group.key}`);
|
||||||
|
lines.push(` classification: ${group.classification}`);
|
||||||
|
for (const memory of group.memories) {
|
||||||
|
lines.push(` - id=${memory.id} type=${memory.type} source=${memory.source} reinforcementCount=${memory.reinforcementCount}`);
|
||||||
|
lines.push(` text: ${oneLine(memory.text)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonReport(path: string, report: ValidationReport): Promise<void> {
|
||||||
|
if (basename(path) === "workspace-memory.json") {
|
||||||
|
die("Refusing to write JSON output to a workspace-memory.json file");
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const report = await scanWorkspaces(options.workspacesDir);
|
||||||
|
console.log(renderHumanReport(report, options.workspacesDir));
|
||||||
|
|
||||||
|
if (options.jsonPath) {
|
||||||
|
await writeJsonReport(options.jsonPath, report);
|
||||||
|
console.log(`\nJSON report written to: ${options.jsonPath}`);
|
||||||
|
}
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const { execFileSync } = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
function isSupportedNodeVersion(version) {
|
||||||
|
const [major = 0, minor = 0, patch = 0] = version.split(".").map(part => Number(part));
|
||||||
|
void patch;
|
||||||
|
return major > 22 || (major === 22 && minor >= 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupportedNodeVersion(process.versions.node)) {
|
||||||
|
process.stderr.write(`memory-diag requires Node >=22.6.0 because it runs TypeScript with --experimental-strip-types. Current Node: v${process.versions.node}.\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binDir = __dirname;
|
||||||
|
const tsScript = path.join(binDir, "memory-diag.ts");
|
||||||
|
const args = ["--experimental-strip-types", tsScript, ...process.argv.slice(2)];
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, args, { stdio: "inherit" });
|
||||||
|
process.exit(0);
|
||||||
|
} catch (e) {
|
||||||
|
process.exit(e.status || 1);
|
||||||
|
}
|
||||||
+33
-1058
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
|||||||
|
import type { CliOptions, Command, LegacyCommand, ParsedArgs } from "./types.ts";
|
||||||
|
|
||||||
|
export type { ParsedArgs } from "./types.ts";
|
||||||
|
|
||||||
|
export function usage(): string {
|
||||||
|
return `Usage:
|
||||||
|
memory-diag [status] [--workspace <path>] [--verbose] [--json]
|
||||||
|
memory-diag rejected [--workspace <path>] [--verbose] [--json]
|
||||||
|
memory-diag missing [--workspace <path>] [--verbose] [--json]
|
||||||
|
memory-diag explain [memory-id] [--workspace <path>] [--raw]
|
||||||
|
|
||||||
|
Global options:
|
||||||
|
--workspace <path> Workspace path (default: current directory)
|
||||||
|
--verbose Show detailed diagnostics
|
||||||
|
--json Print machine-readable JSON where supported
|
||||||
|
--no-emoji Disable emoji in human output
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(message: string): ParsedArgs {
|
||||||
|
return { ok: false, message, usage: usage(), exitCode: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommand(value: string | undefined): value is Command {
|
||||||
|
return value === "status"
|
||||||
|
|| value === "rejected"
|
||||||
|
|| value === "missing"
|
||||||
|
|| value === "explain"
|
||||||
|
|| value === "coverage"
|
||||||
|
|| value === "audit";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLegacyCommand(value: string | undefined): value is LegacyCommand {
|
||||||
|
return value === "health"
|
||||||
|
|| value === "quality"
|
||||||
|
|| value === "rejections"
|
||||||
|
|| value === "disappearances"
|
||||||
|
|| value === "trace";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSince(rawSince: string): boolean {
|
||||||
|
if (/^(\d+)([dhm])$/i.test(rawSince)) return true;
|
||||||
|
return !Number.isNaN(new Date(rawSince).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArgs(argv: string[]): ParsedArgs {
|
||||||
|
const [first, ...tail] = argv;
|
||||||
|
if (first === "--help" || first === "-h") {
|
||||||
|
return { ok: true, help: true, usage: usage() };
|
||||||
|
}
|
||||||
|
|
||||||
|
let command: Command = "status";
|
||||||
|
let legacyCommand: LegacyCommand | undefined;
|
||||||
|
let deprecationNotice: string | undefined;
|
||||||
|
let rest = argv;
|
||||||
|
if (first && !first.startsWith("--")) {
|
||||||
|
rest = tail;
|
||||||
|
if (isCommand(first)) {
|
||||||
|
command = first;
|
||||||
|
if (first === "coverage") {
|
||||||
|
deprecationNotice = "Note: 'coverage' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit evidence'.";
|
||||||
|
} else if (first === "audit") {
|
||||||
|
deprecationNotice = "Note: 'audit' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit migrations'.";
|
||||||
|
}
|
||||||
|
} else if (isLegacyCommand(first)) {
|
||||||
|
legacyCommand = first;
|
||||||
|
if (first === "health") {
|
||||||
|
command = "status";
|
||||||
|
deprecationNotice = "Note: 'health' is now 'status'. This alias will be removed in v2.0.";
|
||||||
|
} else if (first === "quality") {
|
||||||
|
command = "status";
|
||||||
|
deprecationNotice = "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0.";
|
||||||
|
} else if (first === "rejections") {
|
||||||
|
command = "rejected";
|
||||||
|
deprecationNotice = "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0.";
|
||||||
|
} else if (first === "disappearances") {
|
||||||
|
command = "missing";
|
||||||
|
deprecationNotice = "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0.";
|
||||||
|
} else {
|
||||||
|
command = "explain";
|
||||||
|
deprecationNotice = "Note: 'trace --memory <id>' is now 'explain <memory-id>'. This alias will be removed in v2.0.";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return error(`Unknown subcommand: ${first}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: CliOptions = { raw: false, positional: [] };
|
||||||
|
if (legacyCommand) options.legacyCommand = legacyCommand;
|
||||||
|
if (legacyCommand === "quality") options.verbose = true;
|
||||||
|
for (let i = 0; i < rest.length; i += 1) {
|
||||||
|
const arg = rest[i];
|
||||||
|
if (arg === "--raw") options.raw = true;
|
||||||
|
else if (arg === "--json") options.json = true;
|
||||||
|
else if (arg === "--verbose") options.verbose = true;
|
||||||
|
else if (arg === "--no-emoji") options.noEmoji = true;
|
||||||
|
else if (arg === "--all") options.all = true;
|
||||||
|
else if (arg === "--soft-only") options.softOnly = true;
|
||||||
|
else if (arg === "--trigger-only") options.triggerOnly = true;
|
||||||
|
else if (arg === "--include-historical") options.includeHistorical = true;
|
||||||
|
else if (arg === "--quality") options.quality = true;
|
||||||
|
else if (arg === "--unique") options.unique = true;
|
||||||
|
else if (arg === "--explain") options.explain = true;
|
||||||
|
else if (arg === "--workspace") {
|
||||||
|
const value = rest[++i];
|
||||||
|
if (!value) return error("--workspace requires a path");
|
||||||
|
options.workspace = value;
|
||||||
|
} else if (arg === "--since") {
|
||||||
|
const value = rest[++i];
|
||||||
|
if (!value) return error("--since requires a duration or ISO timestamp");
|
||||||
|
options.since = value;
|
||||||
|
} else if (arg === "--reason") {
|
||||||
|
const value = rest[++i];
|
||||||
|
if (!value) return error("--reason requires a reason code");
|
||||||
|
options.reason = value;
|
||||||
|
} else if (arg === "--migration") {
|
||||||
|
const value = rest[++i];
|
||||||
|
if (!value) return error("--migration requires an id");
|
||||||
|
options.migration = value;
|
||||||
|
} else if (arg === "--memory") {
|
||||||
|
const value = rest[++i];
|
||||||
|
if (!value) return error("--memory requires an id");
|
||||||
|
options.memory = value;
|
||||||
|
} else if (!arg.startsWith("--") && command === "explain" && legacyCommand !== "trace") {
|
||||||
|
options.positional?.push(arg);
|
||||||
|
} else {
|
||||||
|
return error(`Unknown option: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayCommand = legacyCommand ?? command;
|
||||||
|
if (command === "explain") {
|
||||||
|
const positional = options.positional ?? [];
|
||||||
|
if (positional.length > 1) return error("explain accepts at most one memory id");
|
||||||
|
if (positional.length === 1 && options.memory) return error("Use either explain <memory-id> or --memory, not both");
|
||||||
|
if (positional.length === 1) options.memory = positional[0];
|
||||||
|
} else if ((options.positional ?? []).length > 0) {
|
||||||
|
return error(`Unknown option: ${options.positional?.[0]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyCommand === "health") {
|
||||||
|
if (options.all && options.workspace) return error("Use either --all or --workspace, not both");
|
||||||
|
if (options.json && options.all) return error("health --json does not support --all");
|
||||||
|
} else if (command === "status") {
|
||||||
|
if (options.all) return error(`${displayCommand} does not accept --all`);
|
||||||
|
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain") {
|
||||||
|
if (options.all) return error(`${displayCommand} does not accept --all`);
|
||||||
|
} else {
|
||||||
|
if (options.all || options.workspace) return error(`${displayCommand} does not accept --all or --workspace`);
|
||||||
|
}
|
||||||
|
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage") {
|
||||||
|
return error(`${displayCommand} does not accept --json`);
|
||||||
|
}
|
||||||
|
if (legacyCommand === "rejections" && options.json && !options.quality) return error("rejections --json requires --quality");
|
||||||
|
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
|
||||||
|
return error(`${displayCommand} does not accept rejection filters`);
|
||||||
|
}
|
||||||
|
if (command !== "coverage" && options.includeHistorical) return error(`${displayCommand} does not accept --include-historical`);
|
||||||
|
if (command !== "rejected" && (options.quality || options.reason || options.unique)) return error(`${displayCommand} does not accept rejection quality filters`);
|
||||||
|
if (command !== "missing" && options.explain) return error(`${displayCommand} does not accept --explain`);
|
||||||
|
if (command !== "audit" && options.migration) {
|
||||||
|
return error(`${displayCommand} does not accept --migration`);
|
||||||
|
}
|
||||||
|
if (legacyCommand === "trace" && !options.memory) {
|
||||||
|
return error("--memory requires an id");
|
||||||
|
}
|
||||||
|
if (command !== "explain" && options.memory) {
|
||||||
|
return error(`${displayCommand} does not accept --memory`);
|
||||||
|
}
|
||||||
|
if (command === "rejected" && options.since && !isValidSince(options.since)) {
|
||||||
|
return error(`Invalid --since value: ${options.since}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, command, options, deprecationNotice };
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { runAudit } from "./commands/audit.ts";
|
||||||
|
import { runCoverage } from "./commands/coverage.ts";
|
||||||
|
import { runDisappearances } from "./commands/disappearances.ts";
|
||||||
|
import { runExplain } from "./commands/explain.ts";
|
||||||
|
import { runHealth } from "./commands/health.ts";
|
||||||
|
import { runMissing } from "./commands/missing.ts";
|
||||||
|
import { runQuality } from "./commands/quality.ts";
|
||||||
|
import { runRejected } from "./commands/rejected.ts";
|
||||||
|
import { runRejections } from "./commands/rejections.ts";
|
||||||
|
import { runStatus } from "./commands/status.ts";
|
||||||
|
import { runTrace } from "./commands/trace.ts";
|
||||||
|
import type { CliOptions, Command, CommandResult } from "./types.ts";
|
||||||
|
|
||||||
|
export async function dispatch(command: Command, options: CliOptions): Promise<CommandResult> {
|
||||||
|
if (options.legacyCommand === "health") return runHealth(options);
|
||||||
|
if (options.legacyCommand === "quality") return runQuality(options);
|
||||||
|
if (options.legacyCommand === "rejections") return runRejections(options);
|
||||||
|
if (options.legacyCommand === "disappearances") return runDisappearances(options);
|
||||||
|
if (options.legacyCommand === "trace") return runTrace(options);
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case "status": return runStatus(options);
|
||||||
|
case "rejected": return runRejected(options);
|
||||||
|
case "missing": return runMissing(options);
|
||||||
|
case "coverage": return runCoverage(options);
|
||||||
|
case "audit": return runAudit(options);
|
||||||
|
case "explain": return runExplain(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { HARD_QUALITY_REASONS } from "../../../src/memory-quality.ts";
|
||||||
|
import { formatMigrationAudit, type MigrationAuditReport } from "../formatters/audit.ts";
|
||||||
|
import { readJSONLFile } from "../io.ts";
|
||||||
|
import { migrationIdFromPath, migrationLogPaths, riskySupersedeReasons } from "../migrations-model.ts";
|
||||||
|
import type { CliOptions, CommandResult, MigrationLogRecord } from "../types.ts";
|
||||||
|
|
||||||
|
export async function runAudit(options: CliOptions): Promise<CommandResult> {
|
||||||
|
const paths = await migrationLogPaths(options);
|
||||||
|
const reports: MigrationAuditReport[] = [];
|
||||||
|
for (const path of paths) {
|
||||||
|
const migrationId = options.migration ?? migrationIdFromPath(path);
|
||||||
|
const { records, invalidLines } = await readJSONLFile<MigrationLogRecord>(path);
|
||||||
|
const superseded = records.filter(record => !record.afterStatus || record.afterStatus === "superseded");
|
||||||
|
const hardReasons = superseded.flatMap(record => {
|
||||||
|
if (Array.isArray(record.hardReasons)) return record.hardReasons;
|
||||||
|
return Array.isArray(record.reasons) ? record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)) : [];
|
||||||
|
});
|
||||||
|
const risky = superseded
|
||||||
|
.map(record => ({ record, reasons: riskySupersedeReasons(record) }))
|
||||||
|
.filter(item => item.reasons.length > 0);
|
||||||
|
reports.push({ migrationId, path, invalidLines, superseded, hardReasons, risky });
|
||||||
|
}
|
||||||
|
return { stdout: formatMigrationAudit(reports, { raw: options.raw }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { buildInspectionReadModel, coverageRows } from "../inspection-model.ts";
|
||||||
|
import { buildCoverageJSON, formatCoverage } from "../formatters/coverage.ts";
|
||||||
|
import type { CliOptions, CommandResult } from "../types.ts";
|
||||||
|
|
||||||
|
export async function runCoverage(options: CliOptions): Promise<CommandResult> {
|
||||||
|
const model = await buildInspectionReadModel(options);
|
||||||
|
const rows = coverageRows(model, options.includeHistorical === true);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
return { stdout: JSON.stringify(buildCoverageJSON(rows), null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stdout: formatCoverage(rows) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
|
||||||
|
import { buildDisappearancesJSON, formatDisappearances } from "../formatters/disappearances.ts";
|
||||||
|
import type { CliOptions, CommandResult } from "../types.ts";
|
||||||
|
|
||||||
|
export async function runDisappearances(options: CliOptions): Promise<CommandResult> {
|
||||||
|
const model = await buildInspectionReadModel(options);
|
||||||
|
const rows = disappearanceRows(model);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
return { stdout: JSON.stringify(buildDisappearancesJSON(rows, { explain: options.explain }), null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stdout: formatDisappearances(rows, { explain: options.explain }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { formatExplain } from "../formatters/explain.ts";
|
||||||
|
import { snapshotForOptions } from "../workspace-snapshot.ts";
|
||||||
|
import type { CliOptions, CommandResult } from "../types.ts";
|
||||||
|
import { runTrace } from "./trace.ts";
|
||||||
|
|
||||||
|
export async function runExplain(options: CliOptions): Promise<CommandResult> {
|
||||||
|
if (options.memory) return runTrace(options);
|
||||||
|
|
||||||
|
const snapshot = await snapshotForOptions(options);
|
||||||
|
return { stdout: formatExplain(snapshot) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../../../src/paths.ts";
|
||||||
|
import { scanWorkspaceResidues } from "../../../src/workspace-cleanup.ts";
|
||||||
|
import type { PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts";
|
||||||
|
import { formatWorkspaceHealth, type WorkspaceHealthInput } from "../formatters/health.ts";
|
||||||
|
import { pathExists, readJSONFile } from "../io.ts";
|
||||||
|
import { buildMemoryDiagJSON } from "../workspace-snapshot.ts";
|
||||||
|
import type { CliOptions, CommandResult } from "../types.ts";
|
||||||
|
|
||||||
|
type WorkspaceHealthCommandInput = Omit<WorkspaceHealthInput, "now">;
|
||||||
|
|
||||||
|
async function workspaceHealthOutput(input: WorkspaceHealthCommandInput): Promise<string> {
|
||||||
|
return formatWorkspaceHealth({ ...input, now: Date.now() }, {
|
||||||
|
rawStore: await readJSONFile<WorkspaceMemoryStore>(input.memoryPath),
|
||||||
|
rawJournal: await readJSONFile<PendingMemoryJournalStore>(input.pendingPath),
|
||||||
|
pendingExists: pathExists(input.pendingPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHealth(options: CliOptions): Promise<CommandResult> {
|
||||||
|
if (options.json) {
|
||||||
|
const root = options.workspace ?? process.cwd();
|
||||||
|
return { stdout: JSON.stringify(await buildMemoryDiagJSON(root), null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.all) {
|
||||||
|
const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 });
|
||||||
|
const lines: string[] = ["Workspace memory health", ""];
|
||||||
|
if (scan.results.length === 0) {
|
||||||
|
lines.push("No workspace stores found.");
|
||||||
|
return { stdout: lines.join("\n") };
|
||||||
|
}
|
||||||
|
for (let i = 0; i < scan.results.length; i += 1) {
|
||||||
|
const result = scan.results[i];
|
||||||
|
if (i > 0) lines.push("");
|
||||||
|
lines.push(await workspaceHealthOutput({
|
||||||
|
root: result.root,
|
||||||
|
key: result.workspaceKey,
|
||||||
|
memoryPath: join(result.workspaceDir, "workspace-memory.json"),
|
||||||
|
pendingPath: join(result.workspaceDir, "workspace-pending-journal.json"),
|
||||||
|
raw: options.raw,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return { stdout: lines.join("\n") };
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = options.workspace ?? process.cwd();
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
return {
|
||||||
|
stdout: await workspaceHealthOutput({
|
||||||
|
root,
|
||||||
|
key,
|
||||||
|
memoryPath: await workspaceMemoryPath(root),
|
||||||
|
pendingPath: await workspacePendingJournalPath(root),
|
||||||
|
raw: options.raw,
|
||||||
|
includeTitle: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
|
||||||
|
import { buildMissingJSON, formatMissing } from "../formatters/missing.ts";
|
||||||
|
import type { CliOptions, CommandResult } from "../types.ts";
|
||||||
|
|
||||||
|
export async function runMissing(options: CliOptions): Promise<CommandResult> {
|
||||||
|
const model = await buildInspectionReadModel(options);
|
||||||
|
const rows = disappearanceRows(model);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
return { stdout: JSON.stringify(buildMissingJSON(rows, { explain: options.explain }), null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stdout: formatMissing(rows, { verbose: options.verbose, explain: options.explain }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { buildInspectionReadModel } from "../inspection-model.ts";
|
||||||
|
import { buildQualityJSON, formatQuality } from "../formatters/quality.ts";
|
||||||
|
import type { CliOptions, CommandResult } from "../types.ts";
|
||||||
|
|
||||||
|
export async function runQuality(options: CliOptions): Promise<CommandResult> {
|
||||||
|
const model = await buildInspectionReadModel(options);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
return { stdout: JSON.stringify(buildQualityJSON(model, new Date(now).toISOString(), now), null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stdout: formatQuality(model, now) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { buildRejectedJSON, formatRejected } from "../formatters/rejected.ts";
|
||||||
|
import { loadRejectionRecords, rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
|
||||||
|
import type { CliOptions, CommandResult } from "../types.ts";
|
||||||
|
|
||||||
|
export async function runRejected(options: CliOptions): Promise<CommandResult> {
|
||||||
|
const { path, invalidLines, records } = await loadRejectionRecords(options);
|
||||||
|
const summary = rejectionQualitySummary(records);
|
||||||
|
const falsePositiveRisk = rejectionFalsePositiveRisk(summary);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
return { stdout: JSON.stringify(buildRejectedJSON({ summary, falsePositiveRisk }), null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: formatRejected({
|
||||||
|
path,
|
||||||
|
invalidLines,
|
||||||
|
records,
|
||||||
|
summary,
|
||||||
|
falsePositiveRisk,
|
||||||
|
raw: options.raw,
|
||||||
|
verbose: options.verbose,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { formatRejections, formatRejectionQuality, buildRejectionQualityJSON } from "../formatters/rejections.ts";
|
||||||
|
import { loadRejectionRecords, rejectionFalsePositiveRisk, rejectionQualitySummary, uniqueByCanonicalText } from "../rejections-model.ts";
|
||||||
|
import type { CliOptions, CommandResult } from "../types.ts";
|
||||||
|
|
||||||
|
export async function runRejections(options: CliOptions): Promise<CommandResult> {
|
||||||
|
const { path, invalidLines, records } = await loadRejectionRecords(options);
|
||||||
|
const normalized = options.unique ? uniqueByCanonicalText(records) : records;
|
||||||
|
|
||||||
|
if (options.quality) {
|
||||||
|
const summary = rejectionQualitySummary(records);
|
||||||
|
if (options.json) {
|
||||||
|
return { stdout: JSON.stringify({ ...buildRejectionQualityJSON(summary), falsePositiveRisk: rejectionFalsePositiveRisk(summary) }, null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stdout: formatRejectionQuality({ path, invalidLines, summary, raw: options.raw }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stdout: formatRejections({ path, invalidLines, records: normalized, raw: options.raw }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { RETENTION_TYPE_MAX, WORKSPACE_DORMANT_AFTER_DAYS } from "../../../src/retention.ts";
|
||||||
|
import { TYPES } from "../constants.ts";
|
||||||
|
import { formatStatus, type StatusReadout } from "../formatters/status.ts";
|
||||||
|
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
|
||||||
|
import { retentionCandidatesForDiag, retentionClockSummary, daysSinceIso } from "../retention-model.ts";
|
||||||
|
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
|
||||||
|
import { isWithinDays, memoryDiagJSONFromSnapshot } from "../workspace-snapshot.ts";
|
||||||
|
import type { CliOptions, CommandResult, MemoryInspectionReadModel } from "../types.ts";
|
||||||
|
import { runHealth } from "./health.ts";
|
||||||
|
|
||||||
|
export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.now()): StatusReadout {
|
||||||
|
const active = model.store.entries.filter(entry => entry.status !== "superseded");
|
||||||
|
const retention = retentionCandidatesForDiag(model.store, now);
|
||||||
|
const clocks = retentionClockSummary(active);
|
||||||
|
const disappearances = disappearanceRows(model);
|
||||||
|
const evidenceCovered = active.filter(entry => (model.evidenceByMemoryId.get(entry.id) ?? []).length > 0).length;
|
||||||
|
const rejectionSummary = rejectionQualitySummary(model.rejectionRecords);
|
||||||
|
const falsePositiveRisk = rejectionFalsePositiveRisk(rejectionSummary);
|
||||||
|
const typeCounts = Object.fromEntries(TYPES.map(type => [type, active.filter(entry => entry.type === type).length]));
|
||||||
|
const capsFull = active.length >= model.store.limits.maxEntries || TYPES.some(type => (typeCounts[type] ?? 0) >= RETENTION_TYPE_MAX[type]);
|
||||||
|
const unknownDisappearances = disappearances.filter(row => row.classification === "historical_absent_unknown_reason").length;
|
||||||
|
const status = unknownDisappearances > 0 || clocks.invalid > 0
|
||||||
|
? "degraded"
|
||||||
|
: capsFull || rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high"
|
||||||
|
? "warning"
|
||||||
|
: "ok";
|
||||||
|
const rejectedLast7Days = model.evidenceEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7, now)).length;
|
||||||
|
const evidenceCoveragePercent = active.length === 0 ? 100 : Math.round((evidenceCovered / active.length) * 100);
|
||||||
|
const needsAttention: string[] = [];
|
||||||
|
if (clocks.invalid > 0) needsAttention.push(`${clocks.invalid} invalid retention clocks`);
|
||||||
|
if (unknownDisappearances > 0) needsAttention.push(`${unknownDisappearances} memories disappeared without terminal evidence`);
|
||||||
|
if (capsFull) needsAttention.push("memory store is at or over retention caps");
|
||||||
|
if (rejectionSummary.legacyUnscopedCount > 0) needsAttention.push(`${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records`);
|
||||||
|
if (falsePositiveRisk === "high") needsAttention.push("possible rejection false-positive risk is high");
|
||||||
|
const suggestedNextSteps: string[] = [];
|
||||||
|
if (clocks.invalid > 0 || unknownDisappearances > 0) suggestedNextSteps.push("Run memory-diag missing --verbose to inspect missing-memory evidence.");
|
||||||
|
if (capsFull) suggestedNextSteps.push("Run memory-diag explain to see which memories are hidden by caps.");
|
||||||
|
if (rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high") suggestedNextSteps.push("Run memory-diag rejected --verbose to inspect rejection quality.");
|
||||||
|
const wallDaysSinceActivity = daysSinceIso(model.store.lastActivityAt, now);
|
||||||
|
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS;
|
||||||
|
const dormantDaysPastGrace = wallDaysSinceActivity === null ? 0 : Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
summaryText: `Summary: Workspace memory quality is ${status}: ${active.length} active memories, ${evidenceCovered}/${active.length} with evidence, ${disappearances.length} evidence-only disappearances (${unknownDisappearances} unknown), ${clocks.invalid} invalid retention clocks, and ${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records.`,
|
||||||
|
active: active.length,
|
||||||
|
rendered: retention.rendered.length,
|
||||||
|
pending: model.pending.entries.length,
|
||||||
|
rejectedLast7Days,
|
||||||
|
evidenceCoveragePercent,
|
||||||
|
needsAttention,
|
||||||
|
suggestedNextSteps,
|
||||||
|
caps: {
|
||||||
|
active: active.length,
|
||||||
|
maxEntries: model.store.limits.maxEntries,
|
||||||
|
rendered: retention.rendered.length,
|
||||||
|
typeCapped: retention.typeCapped.length,
|
||||||
|
globalCapped: retention.globalCapped.length,
|
||||||
|
typeCounts,
|
||||||
|
capsFull,
|
||||||
|
},
|
||||||
|
retention: clocks,
|
||||||
|
evidence: {
|
||||||
|
currentWithEvidence: evidenceCovered,
|
||||||
|
currentWithoutEvidence: active.length - evidenceCovered,
|
||||||
|
evidenceMemoryIds: model.evidenceByMemoryId.size,
|
||||||
|
disappearances: disappearances.length,
|
||||||
|
unknownDisappearances,
|
||||||
|
withTerminalReason: disappearances.length - unknownDisappearances,
|
||||||
|
},
|
||||||
|
rejections: { ...rejectionSummary, falsePositiveRisk },
|
||||||
|
dormant: {
|
||||||
|
lastActivityAt: model.store.lastActivityAt,
|
||||||
|
wallDaysSinceActivity,
|
||||||
|
dormantDiscountActive,
|
||||||
|
dormantDaysPastGrace,
|
||||||
|
},
|
||||||
|
topRendered: retention.rendered.slice(0, 5),
|
||||||
|
weakestActive: retention.sorted.slice(-5).reverse(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStatus(options: CliOptions): Promise<CommandResult> {
|
||||||
|
if (options.legacyCommand === "health" && options.all) return runHealth(options);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const model = await buildInspectionReadModel(options);
|
||||||
|
const readout = buildStatusReadout(model, now);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
const root = options.workspace ?? process.cwd();
|
||||||
|
const diag = memoryDiagJSONFromSnapshot(root, model.snapshot);
|
||||||
|
return {
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
...diag,
|
||||||
|
summary: {
|
||||||
|
...diag.summary,
|
||||||
|
status: readout.status,
|
||||||
|
evidenceCoveragePercent: readout.evidenceCoveragePercent,
|
||||||
|
needsAttention: readout.needsAttention,
|
||||||
|
suggestedNextSteps: readout.suggestedNextSteps,
|
||||||
|
},
|
||||||
|
}, null, 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: formatStatus(readout, model, {
|
||||||
|
verbose: options.verbose,
|
||||||
|
noEmoji: options.noEmoji,
|
||||||
|
isTty: process.stdout.isTTY === true,
|
||||||
|
raw: options.raw,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { traceMemoryLifecycle } from "../../../src/evidence-log.ts";
|
||||||
|
import { formatTrace } from "../formatters/trace.ts";
|
||||||
|
import { snapshotForOptions } from "../workspace-snapshot.ts";
|
||||||
|
import type { CliOptions, CommandResult } from "../types.ts";
|
||||||
|
|
||||||
|
export async function runTrace(options: CliOptions): Promise<CommandResult> {
|
||||||
|
const root = options.workspace ?? process.cwd();
|
||||||
|
const memoryId = options.memory;
|
||||||
|
if (!memoryId) return { stdout: "", stderr: "--memory requires an id", exitCode: 1 };
|
||||||
|
|
||||||
|
const [snapshot, trace] = await Promise.all([
|
||||||
|
snapshotForOptions(options),
|
||||||
|
traceMemoryLifecycle(root, { memoryId }),
|
||||||
|
]);
|
||||||
|
return { stdout: formatTrace(memoryId, snapshot, trace) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { LongTermType } from "../../src/types.ts";
|
||||||
|
import type { Origin } from "./types.ts";
|
||||||
|
|
||||||
|
export const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
|
||||||
|
|
||||||
|
export const SUSPICIOUS_REASONS = [
|
||||||
|
"progress_snapshot",
|
||||||
|
"active_file_snapshot",
|
||||||
|
"commit_or_ci_snapshot",
|
||||||
|
"temporary_status",
|
||||||
|
"raw_error",
|
||||||
|
"code_or_api_signature",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ALLOWED_ORIGINS = new Set<Origin>([
|
||||||
|
"explicit_trigger",
|
||||||
|
"compaction_candidate",
|
||||||
|
"manual",
|
||||||
|
"migration_check",
|
||||||
|
"unknown",
|
||||||
|
]);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
|
||||||
|
import { uniqueStrings } from "./text.ts";
|
||||||
|
|
||||||
|
export function eventMemoryIds(event: EvidenceEventV1): string[] {
|
||||||
|
return uniqueStrings([
|
||||||
|
event.memory?.memoryId ?? "",
|
||||||
|
...(event.relations ?? []).map(relation => relation.memory?.memoryId ?? ""),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupEvidenceByMemoryId(events: EvidenceEventV1[]): Map<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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { cleanPath, cleanText, countBy, formatWorkspaceIdentity, sortedCounts, truncate } from "../text.ts";
|
||||||
|
import type { MigrationLogRecord } from "../types.ts";
|
||||||
|
|
||||||
|
export type MigrationAuditReport = {
|
||||||
|
migrationId: string;
|
||||||
|
path: string;
|
||||||
|
invalidLines: number;
|
||||||
|
superseded: MigrationLogRecord[];
|
||||||
|
hardReasons: string[];
|
||||||
|
risky: Array<{ record: MigrationLogRecord; reasons: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatMigrationAudit(reports: MigrationAuditReport[], options: { raw: boolean }): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Migration audit report");
|
||||||
|
lines.push("");
|
||||||
|
if (reports.length === 0) {
|
||||||
|
lines.push("No migration logs found.");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < reports.length; i += 1) {
|
||||||
|
const report = reports[i];
|
||||||
|
if (i > 0) lines.push("");
|
||||||
|
lines.push(`Migration: ${report.migrationId}`);
|
||||||
|
lines.push(`logPath=${cleanPath(report.path, options.raw)}`);
|
||||||
|
if (report.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${report.invalidLines}`);
|
||||||
|
lines.push(`Superseded entries: ${report.superseded.length}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("By hard reason:");
|
||||||
|
const byHardReason = sortedCounts(countBy(report.hardReasons));
|
||||||
|
if (byHardReason.length === 0) lines.push(" (none)");
|
||||||
|
else for (const [reason, count] of byHardReason) lines.push(` ${reason.padEnd(24)} ${count}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Potentially risky supersedes:");
|
||||||
|
lines.push(` ${report.risky.length}`);
|
||||||
|
for (const item of report.risky.slice(0, 10)) {
|
||||||
|
const record = item.record;
|
||||||
|
const hard = Array.isArray(record.hardReasons) ? record.hardReasons : [];
|
||||||
|
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
|
||||||
|
lines.push(` - [${record.type ?? "unknown"}] hardReasons=${JSON.stringify(hard)} risk=${item.reasons.join(",")} ${JSON.stringify(truncate(cleanText(record.text ?? "", options.raw)))}`);
|
||||||
|
if (identity) lines.push(` ${identity}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { coverageRows } from "../inspection-model.ts";
|
||||||
|
import { countBy, objectFromCounts } from "../text.ts";
|
||||||
|
import type { CoverageClass } from "../types.ts";
|
||||||
|
|
||||||
|
export type CoverageRows = ReturnType<typeof coverageRows>;
|
||||||
|
|
||||||
|
export function buildCoverageJSON(rows: CoverageRows, generatedAt = new Date().toISOString()): Record<string, unknown> {
|
||||||
|
const classCounts = objectFromCounts(countBy(rows.map(row => row.class)));
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generatedAt,
|
||||||
|
classCounts,
|
||||||
|
memories: rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCoverage(rows: CoverageRows): string {
|
||||||
|
const classCounts = objectFromCounts(countBy(rows.map(row => row.class)));
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Memory evidence coverage");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Class counts:");
|
||||||
|
for (const cls of ["full_lifecycle", "render_only", "no_evidence", "historical_absent_with_reason", "historical_absent_unknown_reason"] as CoverageClass[]) {
|
||||||
|
lines.push(` ${cls}: ${classCounts[cls] ?? 0}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Per-memory rows:");
|
||||||
|
if (rows.length === 0) lines.push(" (none)");
|
||||||
|
for (const row of rows) {
|
||||||
|
const phases = row.eventCounts.byPhase;
|
||||||
|
lines.push(` ${row.id} ${row.class} current=${row.current ? "yes" : "no"} total=${row.eventCounts.total} extraction=${phases.extraction ?? 0} promotion=${phases.promotion ?? 0} render=${phases.render ?? 0} storage=${phases.storage ?? 0}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { disappearanceRows } from "../inspection-model.ts";
|
||||||
|
import { eventCounts } from "../inspection-model.ts";
|
||||||
|
import { formatDetails } from "../text.ts";
|
||||||
|
|
||||||
|
export type DisappearanceRows = ReturnType<typeof disappearanceRows>;
|
||||||
|
|
||||||
|
export function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
||||||
|
disappearances: rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
classification: row.classification,
|
||||||
|
terminalType: row.terminalType,
|
||||||
|
reasonCodes: row.reasonCodes,
|
||||||
|
eventCounts: eventCounts(row.events),
|
||||||
|
details: options.explain ? row.event?.details : undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Memory disappearances");
|
||||||
|
lines.push("");
|
||||||
|
if (rows.length === 0) {
|
||||||
|
lines.push("No evidence-only memories found.");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
|
||||||
|
lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`);
|
||||||
|
if (options.explain) {
|
||||||
|
lines.push(` events: ${row.events.map(event => event.type).join(", ")}`);
|
||||||
|
if (row.event?.type === "memory_removed_capacity") {
|
||||||
|
lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`);
|
||||||
|
}
|
||||||
|
const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"));
|
||||||
|
if (renderTypeCap) {
|
||||||
|
lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { EvidenceEventV1 } from "../../../src/evidence-log.ts";
|
||||||
|
import { formatStrength } from "../retention-model.ts";
|
||||||
|
import type { WorkspaceDiagSnapshot } from "../types.ts";
|
||||||
|
|
||||||
|
export function formatEvidenceRefs(eventIds: string[], allEvents: EvidenceEventV1[]): string {
|
||||||
|
if (eventIds.length === 0) return "(none)";
|
||||||
|
const byId = new Map(allEvents.map(event => [event.eventId, event]));
|
||||||
|
return eventIds
|
||||||
|
.map(id => {
|
||||||
|
const event = byId.get(id);
|
||||||
|
return event ? `${event.eventId} ${event.type}` : id;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatExplain(snapshot: WorkspaceDiagSnapshot): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Workspace memory explainability");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (snapshot.memories.length === 0) {
|
||||||
|
lines.push("No memories found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const memory of snapshot.memories) {
|
||||||
|
lines.push(`Memory ${memory.id}: ${memory.status}`);
|
||||||
|
const strength = typeof memory.strength === "number" ? formatStrength(memory.strength) : "n/a";
|
||||||
|
lines.push(`- strength=${strength}, type=${memory.type}, source=${memory.source}`);
|
||||||
|
lines.push(`- reasons: ${memory.reasonCodes.length > 0 ? memory.reasonCodes.join(", ") : "(none)"}`);
|
||||||
|
lines.push(`- evidence: ${formatEvidenceRefs(memory.evidenceEventIds, snapshot.allEvents)}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const quarantines = snapshot.recentEvents.filter(event => event.type === "storage_corrupt_json_quarantined");
|
||||||
|
if (quarantines.length > 0) {
|
||||||
|
lines.push("Quarantined stores:");
|
||||||
|
for (const event of quarantines) {
|
||||||
|
lines.push(`- quarantined_corrupt_store: ${event.eventId} ${event.type}; reasons=${event.reasonCodes.join(",")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { assessMemoryQuality } from "../../../src/memory-quality.ts";
|
||||||
|
import {
|
||||||
|
DORMANT_DECAY_MULTIPLIER,
|
||||||
|
RETENTION_TYPE_MAX,
|
||||||
|
WORKSPACE_DORMANT_AFTER_DAYS,
|
||||||
|
} from "../../../src/retention.ts";
|
||||||
|
import { renderWorkspaceMemory } from "../../../src/workspace-memory.ts";
|
||||||
|
import type { LongTermSource, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts";
|
||||||
|
import { LONG_TERM_LIMITS } from "../../../src/types.ts";
|
||||||
|
import { SUSPICIOUS_REASONS, TYPES } from "../constants.ts";
|
||||||
|
import {
|
||||||
|
ageDays,
|
||||||
|
daysSinceIso,
|
||||||
|
formatStrength,
|
||||||
|
isSafetyCriticalForDiag,
|
||||||
|
promotionLimit,
|
||||||
|
retentionCandidatesForDiag,
|
||||||
|
} from "../retention-model.ts";
|
||||||
|
import {
|
||||||
|
canonicalMemoryText,
|
||||||
|
cleanPath,
|
||||||
|
cleanText,
|
||||||
|
countBy,
|
||||||
|
formatPercent,
|
||||||
|
formatWorkspaceIdentity,
|
||||||
|
truncate,
|
||||||
|
} from "../text.ts";
|
||||||
|
import { normalizedJournal, normalizedStore } from "../workspace-snapshot.ts";
|
||||||
|
|
||||||
|
export type WorkspaceHealthInput = {
|
||||||
|
root?: string;
|
||||||
|
key: string;
|
||||||
|
memoryPath: string;
|
||||||
|
pendingPath: string;
|
||||||
|
raw: boolean;
|
||||||
|
now: number;
|
||||||
|
includeTitle?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceHealthLoadedData = {
|
||||||
|
rawStore: WorkspaceMemoryStore | null;
|
||||||
|
rawJournal: PendingMemoryJournalStore | null;
|
||||||
|
pendingExists: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatWorkspaceHealth(input: WorkspaceHealthInput, loadedData: WorkspaceHealthLoadedData): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (input.includeTitle) {
|
||||||
|
lines.push("Workspace memory health");
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawStore = loadedData.rawStore;
|
||||||
|
const storeRoot = rawStore?.workspace?.root ?? input.root ?? "";
|
||||||
|
const storeKey = rawStore?.workspace?.key ?? input.key;
|
||||||
|
const store = normalizedStore(rawStore, storeRoot, storeKey);
|
||||||
|
const journal = normalizedJournal(loadedData.rawJournal);
|
||||||
|
|
||||||
|
const identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw);
|
||||||
|
if (identity) lines.push(identity);
|
||||||
|
lines.push(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`);
|
||||||
|
lines.push(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`);
|
||||||
|
if (!rawStore) lines.push("memory store: missing or unreadable (treated as empty)");
|
||||||
|
if (!loadedData.pendingExists) lines.push("pending journal: missing (treated as empty)");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const active = store.entries.filter(entry => entry.status !== "superseded");
|
||||||
|
const superseded = store.entries.filter(entry => entry.status === "superseded");
|
||||||
|
const retention = retentionCandidatesForDiag(store, input.now);
|
||||||
|
const renderedEntries = retention.rendered.map(item => item.entry);
|
||||||
|
const renderedEstimate = renderWorkspaceMemory(store).length;
|
||||||
|
|
||||||
|
lines.push(`Stored active memories: ${active.length}`);
|
||||||
|
lines.push(`Superseded memories: ${superseded.length}`);
|
||||||
|
lines.push(`Rendered candidates: ${renderedEntries.length}`);
|
||||||
|
lines.push(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const pendingEntries = journal.entries;
|
||||||
|
const retryable = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) < promotionLimit(entry.source)).length;
|
||||||
|
const nearRetryLimit = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) >= promotionLimit(entry.source) - 1).length;
|
||||||
|
const pendingBySource = countBy(pendingEntries.map(entry => entry.source));
|
||||||
|
lines.push("Pending journal:");
|
||||||
|
lines.push(` total: ${pendingEntries.length}`);
|
||||||
|
lines.push(` retryable: ${retryable}`);
|
||||||
|
lines.push(` near retry limit: ${nearRetryLimit}`);
|
||||||
|
lines.push(" by source:");
|
||||||
|
for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) {
|
||||||
|
lines.push(` ${source}: ${pendingBySource.get(source) ?? 0}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("By type:");
|
||||||
|
for (const type of TYPES) {
|
||||||
|
const storedCount = active.filter(entry => entry.type === type).length;
|
||||||
|
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
|
||||||
|
const supersededCount = superseded.filter(entry => entry.type === type).length;
|
||||||
|
lines.push(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${RETENTION_TYPE_MAX[type]} superseded=${supersededCount}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Retention caps:");
|
||||||
|
lines.push(` type-capped entries: ${retention.typeCapped.length}`);
|
||||||
|
lines.push(` global-cap overflow: ${retention.globalCapped.length}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const olderThan30 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 30).length;
|
||||||
|
const olderThan90 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 90).length;
|
||||||
|
const staleMarked = active.filter(entry => {
|
||||||
|
const days = ageDays(entry, input.now);
|
||||||
|
return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays);
|
||||||
|
}).length;
|
||||||
|
lines.push("Age:");
|
||||||
|
lines.push(` stale-marked: ${staleMarked}`);
|
||||||
|
lines.push(` older than 30d: ${olderThan30}`);
|
||||||
|
lines.push(` older than 90d: ${olderThan90}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt, input.now);
|
||||||
|
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS;
|
||||||
|
const dormantDaysPastGrace = wallDaysSinceActivity === null
|
||||||
|
? 0
|
||||||
|
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
|
||||||
|
lines.push("Dormancy:");
|
||||||
|
lines.push(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
|
||||||
|
lines.push(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
|
||||||
|
lines.push(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
|
||||||
|
lines.push(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
|
||||||
|
lines.push(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
|
||||||
|
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
|
||||||
|
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
|
||||||
|
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
|
||||||
|
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
|
||||||
|
const highImportanceAlert = highImportanceRatio > 0.3;
|
||||||
|
const safetyCriticalWarning = safetyCriticalCount > 0;
|
||||||
|
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
|
||||||
|
lines.push("Retention monitoring:");
|
||||||
|
lines.push(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
|
||||||
|
lines.push(` safety_critical_count: ${safetyCriticalCount} (deprecated field)${safetyCriticalWarning ? " WARNING" : ""}`);
|
||||||
|
lines.push(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
|
||||||
|
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
|
||||||
|
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
|
||||||
|
lines.push("Quality warnings:");
|
||||||
|
lines.push(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`);
|
||||||
|
lines.push(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`);
|
||||||
|
lines.push(` duplicate-ish exact canonical text: ${duplicateExtras}`);
|
||||||
|
lines.push(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Suspicious active memories:");
|
||||||
|
for (const reason of SUSPICIOUS_REASONS) {
|
||||||
|
lines.push(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failingQuality = qualityByEntry.filter(item => !item.quality.accepted);
|
||||||
|
if (failingQuality.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Active memories failing offline quality checks:");
|
||||||
|
for (const item of failingQuality.slice(0, 8)) {
|
||||||
|
lines.push(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Top rendered candidates:");
|
||||||
|
const top = retention.rendered.slice(0, 5);
|
||||||
|
if (top.length === 0) {
|
||||||
|
lines.push(" (none)");
|
||||||
|
} else {
|
||||||
|
for (const item of top) {
|
||||||
|
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Weakest active memories:");
|
||||||
|
const weakest = retention.sorted.slice(-5).reverse();
|
||||||
|
if (weakest.length === 0) {
|
||||||
|
lines.push(" (none)");
|
||||||
|
} else {
|
||||||
|
for (const item of weakest) {
|
||||||
|
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { buildDisappearancesJSON, formatDisappearances, type DisappearanceRows } from "./disappearances.ts";
|
||||||
|
|
||||||
|
export type MissingSummary = {
|
||||||
|
total: number;
|
||||||
|
explained: number;
|
||||||
|
needsReview: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function missingSummary(rows: DisappearanceRows): MissingSummary {
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
explained: rows.filter(row => row.classification === "historical_absent_with_reason").length,
|
||||||
|
needsReview: rows.filter(row => row.classification === "historical_absent_unknown_reason").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMissingJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...buildDisappearancesJSON(rows, options),
|
||||||
|
summary: missingSummary(rows),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMissing(rows: DisappearanceRows, options: { verbose?: boolean; explain?: boolean } = {}): string {
|
||||||
|
const summary = missingSummary(rows);
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Missing memory summary");
|
||||||
|
lines.push("");
|
||||||
|
if (rows.length === 0) {
|
||||||
|
lines.push("No missing memories found.");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`Total missing: ${summary.total}`);
|
||||||
|
lines.push(`Explained: ${summary.explained}`);
|
||||||
|
lines.push(`Needs review: ${summary.needsReview}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const unknownRows = rows
|
||||||
|
.filter(row => row.classification === "historical_absent_unknown_reason")
|
||||||
|
.slice(0, 5);
|
||||||
|
lines.push("Unknown disappearance samples:");
|
||||||
|
if (unknownRows.length === 0) {
|
||||||
|
lines.push(" (none)");
|
||||||
|
} else {
|
||||||
|
for (const row of unknownRows) {
|
||||||
|
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
|
||||||
|
lines.push(` - ${row.id} terminal=${row.terminalType} reasons=${reasons}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.verbose || options.explain) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(formatDisappearances(rows, { explain: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { RETENTION_TYPE_MAX } from "../../../src/retention.ts";
|
||||||
|
import { TYPES } from "../constants.ts";
|
||||||
|
import { disappearanceRows } from "../inspection-model.ts";
|
||||||
|
import { retentionCandidatesForDiag, retentionClockSummary } from "../retention-model.ts";
|
||||||
|
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
|
||||||
|
import type { MemoryInspectionReadModel } from "../types.ts";
|
||||||
|
|
||||||
|
export function buildQualityJSON(model: MemoryInspectionReadModel, generatedAt = new Date().toISOString(), now = new Date(generatedAt).getTime()): Record<string, unknown> {
|
||||||
|
const active = model.store.entries.filter(entry => entry.status !== "superseded");
|
||||||
|
const retention = retentionCandidatesForDiag(model.store, now);
|
||||||
|
const clocks = retentionClockSummary(active);
|
||||||
|
const disappearances = disappearanceRows(model);
|
||||||
|
const evidenceCovered = active.filter(entry => (model.evidenceByMemoryId.get(entry.id) ?? []).length > 0).length;
|
||||||
|
const rejectionSummary = rejectionQualitySummary(model.rejectionRecords);
|
||||||
|
const falsePositiveRisk = rejectionFalsePositiveRisk(rejectionSummary);
|
||||||
|
const typeCounts = Object.fromEntries(TYPES.map(type => [type, active.filter(entry => entry.type === type).length]));
|
||||||
|
const capsFull = active.length >= model.store.limits.maxEntries || TYPES.some(type => (typeCounts[type] ?? 0) >= RETENTION_TYPE_MAX[type]);
|
||||||
|
const unknownDisappearances = disappearances.filter(row => row.classification === "historical_absent_unknown_reason").length;
|
||||||
|
const status = unknownDisappearances > 0 || clocks.invalid > 0
|
||||||
|
? "degraded"
|
||||||
|
: capsFull || rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high"
|
||||||
|
? "warning"
|
||||||
|
: "ok";
|
||||||
|
const summaryText = `Summary: Workspace memory quality is ${status}: ${active.length} active memories, ${evidenceCovered}/${active.length} with evidence, ${disappearances.length} evidence-only disappearances (${unknownDisappearances} unknown), ${clocks.invalid} invalid retention clocks, and ${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records.`;
|
||||||
|
const caps = {
|
||||||
|
active: active.length,
|
||||||
|
maxEntries: model.store.limits.maxEntries,
|
||||||
|
rendered: retention.rendered.length,
|
||||||
|
typeCapped: retention.typeCapped.length,
|
||||||
|
globalCapped: retention.globalCapped.length,
|
||||||
|
typeCounts,
|
||||||
|
capsFull,
|
||||||
|
};
|
||||||
|
const evidence = {
|
||||||
|
currentWithEvidence: evidenceCovered,
|
||||||
|
currentWithoutEvidence: active.length - evidenceCovered,
|
||||||
|
evidenceMemoryIds: model.evidenceByMemoryId.size,
|
||||||
|
disappearances: disappearances.length,
|
||||||
|
unknownDisappearances,
|
||||||
|
withTerminalReason: disappearances.length - unknownDisappearances,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generatedAt,
|
||||||
|
status,
|
||||||
|
summaryText,
|
||||||
|
store: { active: active.length, pending: model.pending.entries.length, superseded: model.store.entries.length - active.length },
|
||||||
|
caps,
|
||||||
|
retention: clocks,
|
||||||
|
evidence,
|
||||||
|
rejections: { ...rejectionSummary, falsePositiveRisk },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQuality(model: MemoryInspectionReadModel, now: number): string {
|
||||||
|
const data = buildQualityJSON(model, new Date(now).toISOString(), now) as {
|
||||||
|
summaryText: string;
|
||||||
|
caps: {
|
||||||
|
active: number;
|
||||||
|
maxEntries: number;
|
||||||
|
rendered: number;
|
||||||
|
typeCapped: number;
|
||||||
|
globalCapped: number;
|
||||||
|
typeCounts: Record<string, number>;
|
||||||
|
capsFull: boolean;
|
||||||
|
};
|
||||||
|
retention: { present: number; missing: number; invalid: number };
|
||||||
|
evidence: {
|
||||||
|
currentWithEvidence: number;
|
||||||
|
currentWithoutEvidence: number;
|
||||||
|
evidenceMemoryIds: number;
|
||||||
|
disappearances: number;
|
||||||
|
unknownDisappearances: number;
|
||||||
|
};
|
||||||
|
rejections: {
|
||||||
|
totalRecords: number;
|
||||||
|
workspaceScopedCount: number;
|
||||||
|
legacyUnscopedCount: number;
|
||||||
|
falsePositiveRisk: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Memory quality inspection");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(data.summaryText);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Caps:");
|
||||||
|
lines.push(` active: ${data.caps.active} / ${data.caps.maxEntries}`);
|
||||||
|
for (const type of TYPES) {
|
||||||
|
const count = data.caps.typeCounts[type] ?? 0;
|
||||||
|
const limit = RETENTION_TYPE_MAX[type];
|
||||||
|
const marker = count >= limit ? " FULL" : "";
|
||||||
|
lines.push(` ${type}: ${count} / ${limit}${marker}`);
|
||||||
|
}
|
||||||
|
lines.push(` rendered: ${data.caps.rendered}`);
|
||||||
|
lines.push(` type-capped entries: ${data.caps.typeCapped}`);
|
||||||
|
lines.push(` global-cap overflow: ${data.caps.globalCapped}`);
|
||||||
|
lines.push(` caps full: ${data.caps.capsFull ? "yes" : "no"}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Retention clocks:");
|
||||||
|
lines.push(` present: ${data.retention.present}`);
|
||||||
|
lines.push(` missing: ${data.retention.missing}`);
|
||||||
|
lines.push(` invalid: ${data.retention.invalid}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Evidence:");
|
||||||
|
lines.push(` current with evidence: ${data.evidence.currentWithEvidence}`);
|
||||||
|
lines.push(` current without evidence: ${data.evidence.currentWithoutEvidence}`);
|
||||||
|
lines.push(` evidence memory ids: ${data.evidence.evidenceMemoryIds}`);
|
||||||
|
lines.push(` disappearances: ${data.evidence.disappearances}`);
|
||||||
|
lines.push(` unknown disappearances: ${data.evidence.unknownDisappearances}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Rejection scoping:");
|
||||||
|
lines.push(` total records: ${data.rejections.totalRecords}`);
|
||||||
|
lines.push(` workspace scoped: ${data.rejections.workspaceScopedCount}`);
|
||||||
|
lines.push(` legacy unscoped: ${data.rejections.legacyUnscopedCount}`);
|
||||||
|
lines.push(` false-positive risk: ${data.rejections.falsePositiveRisk}`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
|
||||||
|
import { cleanPath, cleanText, countBy, sortedCounts, truncate } from "../text.ts";
|
||||||
|
import type { NormalizedRejection } from "../types.ts";
|
||||||
|
|
||||||
|
export type RejectedSummary = ReturnType<typeof rejectionQualitySummary>;
|
||||||
|
export type RejectedFalsePositiveRisk = ReturnType<typeof rejectionFalsePositiveRisk>;
|
||||||
|
|
||||||
|
export function buildRejectedJSON(input: {
|
||||||
|
summary: RejectedSummary;
|
||||||
|
falsePositiveRisk: RejectedFalsePositiveRisk;
|
||||||
|
generatedAt?: string;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generatedAt: input.generatedAt ?? new Date().toISOString(),
|
||||||
|
...input.summary,
|
||||||
|
falsePositiveRisk: input.falsePositiveRisk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRejected(input: {
|
||||||
|
path: string;
|
||||||
|
invalidLines: number;
|
||||||
|
records: NormalizedRejection[];
|
||||||
|
summary: RejectedSummary;
|
||||||
|
falsePositiveRisk: RejectedFalsePositiveRisk;
|
||||||
|
raw: boolean;
|
||||||
|
verbose?: boolean;
|
||||||
|
}): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Rejected memory summary");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Total rejected: ${input.summary.totalRecords}`);
|
||||||
|
lines.push(`Unique texts: ${input.summary.uniqueTexts}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Top reasons:");
|
||||||
|
const byReason = sortedCounts(countBy(input.records.flatMap(record => record.reasons))).slice(0, 5);
|
||||||
|
if (byReason.length === 0) lines.push(" (none)");
|
||||||
|
else for (const [reason, count] of byReason) lines.push(` ${reason.padEnd(36)} ${count}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push(`False-positive risk: ${input.falsePositiveRisk}`);
|
||||||
|
if (input.falsePositiveRisk === "high") {
|
||||||
|
lines.push("⚠️ Possible false positives detected. Use --verbose to inspect samples.");
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Recent samples:");
|
||||||
|
const samples = [...input.records]
|
||||||
|
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
|
||||||
|
.slice(0, 5);
|
||||||
|
if (samples.length === 0) {
|
||||||
|
lines.push(" (none)");
|
||||||
|
} else {
|
||||||
|
for (const record of samples) {
|
||||||
|
lines.push(` - [${record.type}] ${truncate(cleanText(record.text, input.raw))}`);
|
||||||
|
lines.push(` reasons: ${record.reasons.length > 0 ? record.reasons.join(",") : "none"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.verbose) return lines.join("\n");
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Possible false-positive grouping is heuristic, not deterministic truth.");
|
||||||
|
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
|
||||||
|
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Reason distribution (raw records):");
|
||||||
|
for (const [reason, count] of Object.entries(input.summary.reasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
|
||||||
|
if (Object.keys(input.summary.reasonDistribution).length === 0) lines.push(" (none)");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Reason distribution (unique text):");
|
||||||
|
for (const [reason, count] of Object.entries(input.summary.uniqueReasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
|
||||||
|
if (Object.keys(input.summary.uniqueReasonDistribution).length === 0) lines.push(" (none)");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("By origin:");
|
||||||
|
const byOrigin = sortedCounts(countBy(input.records.map(record => record.origin)));
|
||||||
|
if (byOrigin.length === 0) lines.push(" (none)");
|
||||||
|
else for (const [origin, count] of byOrigin) lines.push(` ${origin.padEnd(36)} ${count}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Possible false-positive groups (heuristic, not deterministic):");
|
||||||
|
for (const [group, data] of Object.entries(input.summary.possibleFalsePositiveGroups)) {
|
||||||
|
lines.push(` ${group}: ${data.count}`);
|
||||||
|
for (const sample of data.samples) lines.push(` - ${JSON.stringify(cleanText(sample, input.raw))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { hasSoftReason, rejectionQualitySummary } from "../rejections-model.ts";
|
||||||
|
import { cleanPath, cleanText, countBy, formatWorkspaceIdentity, sortedCounts, truncate } from "../text.ts";
|
||||||
|
import type { NormalizedRejection } from "../types.ts";
|
||||||
|
|
||||||
|
export type RejectionQualitySummary = ReturnType<typeof rejectionQualitySummary>;
|
||||||
|
|
||||||
|
export function buildRejectionQualityJSON(summary: RejectionQualitySummary, generatedAt = new Date().toISOString()): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generatedAt,
|
||||||
|
...summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRejections(input: {
|
||||||
|
path: string;
|
||||||
|
invalidLines: number;
|
||||||
|
records: NormalizedRejection[];
|
||||||
|
raw: boolean;
|
||||||
|
}): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Extraction rejection summary");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
|
||||||
|
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Total rejected: ${input.records.length}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("By reason:");
|
||||||
|
const byReason = sortedCounts(countBy(input.records.flatMap(record => record.reasons)));
|
||||||
|
if (byReason.length === 0) lines.push(" (none)");
|
||||||
|
else for (const [reason, count] of byReason) lines.push(` ${reason.padEnd(24)} ${count}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("By origin:");
|
||||||
|
const byOrigin = sortedCounts(countBy(input.records.map(record => record.origin)));
|
||||||
|
if (byOrigin.length === 0) lines.push(" (none)");
|
||||||
|
else for (const [origin, count] of byOrigin) lines.push(` ${origin.padEnd(24)} ${count}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Trigger-origin rejections (high priority for v1.5):");
|
||||||
|
const triggerReasons = sortedCounts(countBy(input.records.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons)));
|
||||||
|
if (triggerReasons.length === 0) lines.push(" (none)");
|
||||||
|
else for (const [reason, count] of triggerReasons) lines.push(` ${reason.padEnd(24)} ${count}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Recent suspicious soft rejects:");
|
||||||
|
const suspicious = input.records
|
||||||
|
.filter(hasSoftReason)
|
||||||
|
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
|
||||||
|
.slice(0, 8);
|
||||||
|
if (suspicious.length === 0) {
|
||||||
|
lines.push(" (none)");
|
||||||
|
} else {
|
||||||
|
for (const record of suspicious) {
|
||||||
|
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, input.raw);
|
||||||
|
lines.push(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, input.raw)))}`);
|
||||||
|
lines.push(` reasons: ${record.reasons.join(",")}`);
|
||||||
|
lines.push(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRejectionQuality(input: {
|
||||||
|
path: string;
|
||||||
|
invalidLines: number;
|
||||||
|
summary: RejectionQualitySummary;
|
||||||
|
raw: boolean;
|
||||||
|
}): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Extraction rejection quality inspection");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Possible false-positive grouping is heuristic, not deterministic truth.");
|
||||||
|
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
|
||||||
|
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Total records: ${input.summary.totalRecords}`);
|
||||||
|
lines.push(`Unique texts: ${input.summary.uniqueTexts}`);
|
||||||
|
lines.push(`Workspace scoped: ${input.summary.workspaceScopedCount}`);
|
||||||
|
lines.push(`Legacy unscoped: ${input.summary.legacyUnscopedCount}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Reason distribution (raw records):");
|
||||||
|
for (const [reason, count] of Object.entries(input.summary.reasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
|
||||||
|
if (Object.keys(input.summary.reasonDistribution).length === 0) lines.push(" (none)");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Reason distribution (unique text):");
|
||||||
|
for (const [reason, count] of Object.entries(input.summary.uniqueReasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
|
||||||
|
if (Object.keys(input.summary.uniqueReasonDistribution).length === 0) lines.push(" (none)");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Possible false-positive groups (heuristic, not deterministic):");
|
||||||
|
for (const [group, data] of Object.entries(input.summary.possibleFalsePositiveGroups)) {
|
||||||
|
lines.push(` ${group}: ${data.count}`);
|
||||||
|
for (const sample of data.samples) lines.push(` - ${JSON.stringify(cleanText(sample, input.raw))}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
DORMANT_DECAY_MULTIPLIER,
|
||||||
|
RETENTION_TYPE_MAX,
|
||||||
|
} from "../../../src/retention.ts";
|
||||||
|
import { TYPES } from "../constants.ts";
|
||||||
|
import { daysSinceIso, formatStrength } from "../retention-model.ts";
|
||||||
|
import { cleanText, truncate } from "../text.ts";
|
||||||
|
import type { MemoryInspectionReadModel, RetentionDiagItem } from "../types.ts";
|
||||||
|
|
||||||
|
export type MemoryStatusLevel = "ok" | "warning" | "degraded";
|
||||||
|
|
||||||
|
export type StatusReadout = {
|
||||||
|
status: MemoryStatusLevel;
|
||||||
|
summaryText: string;
|
||||||
|
active: number;
|
||||||
|
rendered: number;
|
||||||
|
pending: number;
|
||||||
|
rejectedLast7Days: number;
|
||||||
|
evidenceCoveragePercent: number;
|
||||||
|
needsAttention: string[];
|
||||||
|
suggestedNextSteps: string[];
|
||||||
|
caps: {
|
||||||
|
active: number;
|
||||||
|
maxEntries: number;
|
||||||
|
rendered: number;
|
||||||
|
typeCapped: number;
|
||||||
|
globalCapped: number;
|
||||||
|
typeCounts: Record<string, number>;
|
||||||
|
capsFull: boolean;
|
||||||
|
};
|
||||||
|
retention: { present: number; missing: number; invalid: number };
|
||||||
|
evidence: {
|
||||||
|
currentWithEvidence: number;
|
||||||
|
currentWithoutEvidence: number;
|
||||||
|
evidenceMemoryIds: number;
|
||||||
|
disappearances: number;
|
||||||
|
unknownDisappearances: number;
|
||||||
|
withTerminalReason: number;
|
||||||
|
};
|
||||||
|
rejections: {
|
||||||
|
totalRecords: number;
|
||||||
|
workspaceScopedCount: number;
|
||||||
|
legacyUnscopedCount: number;
|
||||||
|
falsePositiveRisk: string;
|
||||||
|
};
|
||||||
|
dormant: {
|
||||||
|
lastActivityAt?: string;
|
||||||
|
wallDaysSinceActivity: number | null;
|
||||||
|
dormantDiscountActive: boolean;
|
||||||
|
dormantDaysPastGrace: number;
|
||||||
|
};
|
||||||
|
topRendered: RetentionDiagItem[];
|
||||||
|
weakestActive: RetentionDiagItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatStatus(readout: StatusReadout, model: MemoryInspectionReadModel, options: { verbose?: boolean; noEmoji?: boolean; isTty?: boolean; raw?: boolean } = {}): string {
|
||||||
|
if (options.verbose) return formatStatusVerbose(readout, model, options);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const label = statusLabel(readout.status, options);
|
||||||
|
lines.push(`${label} Memory status`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Key metrics:");
|
||||||
|
lines.push(` active memories: ${readout.active}`);
|
||||||
|
lines.push(` rendered: ${readout.rendered}`);
|
||||||
|
lines.push(` pending: ${readout.pending}`);
|
||||||
|
lines.push(` rejected 7d: ${readout.rejectedLast7Days}`);
|
||||||
|
lines.push(` evidence coverage: ${readout.evidenceCoveragePercent}%`);
|
||||||
|
|
||||||
|
if (readout.needsAttention.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Needs attention:");
|
||||||
|
for (const item of readout.needsAttention) lines.push(` - ${item}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readout.status !== "ok" && readout.suggestedNextSteps.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Suggested next steps:");
|
||||||
|
for (const item of readout.suggestedNextSteps) lines.push(` - ${item}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: MemoryStatusLevel, options: { noEmoji?: boolean; isTty?: boolean }): string {
|
||||||
|
const text = status === "ok" ? "OK" : status === "warning" ? "WARNING" : "DEGRADED";
|
||||||
|
if (options.noEmoji || !options.isTty) return text;
|
||||||
|
const emoji = status === "ok" ? "🧠" : status === "warning" ? "⚠️" : "✖️";
|
||||||
|
return `${emoji} ${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatusVerbose(readout: StatusReadout, model: MemoryInspectionReadModel, options: { raw?: boolean } = {}): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Memory status inspection");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(readout.summaryText);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Caps:");
|
||||||
|
lines.push(` active: ${readout.caps.active} / ${readout.caps.maxEntries}`);
|
||||||
|
for (const type of TYPES) {
|
||||||
|
const count = readout.caps.typeCounts[type] ?? 0;
|
||||||
|
const limit = RETENTION_TYPE_MAX[type];
|
||||||
|
const marker = count >= limit ? " FULL" : "";
|
||||||
|
lines.push(` ${type}: ${count} / ${limit}${marker}`);
|
||||||
|
}
|
||||||
|
lines.push(` rendered: ${readout.caps.rendered}`);
|
||||||
|
lines.push(` type-capped entries: ${readout.caps.typeCapped}`);
|
||||||
|
lines.push(` global-cap overflow: ${readout.caps.globalCapped}`);
|
||||||
|
lines.push(` caps full: ${readout.caps.capsFull ? "yes" : "no"}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Retention clocks:");
|
||||||
|
lines.push(` present: ${readout.retention.present}`);
|
||||||
|
lines.push(` missing: ${readout.retention.missing}`);
|
||||||
|
lines.push(` invalid: ${readout.retention.invalid}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Evidence:");
|
||||||
|
lines.push(` current with evidence: ${readout.evidence.currentWithEvidence}`);
|
||||||
|
lines.push(` current without evidence: ${readout.evidence.currentWithoutEvidence}`);
|
||||||
|
lines.push(` evidence memory ids: ${readout.evidence.evidenceMemoryIds}`);
|
||||||
|
lines.push(` disappearances: ${readout.evidence.disappearances}`);
|
||||||
|
lines.push(` unknown disappearances: ${readout.evidence.unknownDisappearances}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Rejection scoping:");
|
||||||
|
lines.push(` total records: ${readout.rejections.totalRecords}`);
|
||||||
|
lines.push(` workspace scoped: ${readout.rejections.workspaceScopedCount}`);
|
||||||
|
lines.push(` legacy unscoped: ${readout.rejections.legacyUnscopedCount}`);
|
||||||
|
lines.push(` false-positive risk: ${readout.rejections.falsePositiveRisk}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Dormancy:");
|
||||||
|
lines.push(` lastActivityAt: ${readout.dormant.lastActivityAt ?? "(missing)"}`);
|
||||||
|
lines.push(` wall days since activity: ${readout.dormant.wallDaysSinceActivity === null ? "unknown" : readout.dormant.wallDaysSinceActivity.toFixed(1)}`);
|
||||||
|
lines.push(` dormant discount active: ${readout.dormant.dormantDiscountActive ? "yes" : "no"}`);
|
||||||
|
lines.push(` dormant days past grace: ${readout.dormant.dormantDaysPastGrace.toFixed(1)}`);
|
||||||
|
lines.push(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Top rendered candidates:");
|
||||||
|
pushMemoryItems(lines, readout.topRendered, options.raw === true);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Weakest active memories:");
|
||||||
|
pushMemoryItems(lines, readout.weakestActive, options.raw === true);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Store:");
|
||||||
|
lines.push(` superseded: ${model.store.entries.length - readout.active}`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushMemoryItems(lines: string[], items: RetentionDiagItem[], raw: boolean): void {
|
||||||
|
if (items.length === 0) {
|
||||||
|
lines.push(" (none)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, raw))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { EvidenceEventV1 } from "../../../src/evidence-log.ts";
|
||||||
|
import { formatTraceEvent, relationMemoryIds, statusFromTraceEvent } from "../trace-model.ts";
|
||||||
|
import type { WorkspaceDiagSnapshot } from "../types.ts";
|
||||||
|
|
||||||
|
export function formatTrace(memoryId: string, snapshot: WorkspaceDiagSnapshot, trace: { events: EvidenceEventV1[] }): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const memoryRow = snapshot.memories.find(memory => memory.id === memoryId);
|
||||||
|
const status = memoryRow?.status ?? statusFromTraceEvent(trace.events.at(-1));
|
||||||
|
|
||||||
|
lines.push(`Memory ${memoryId}: ${status}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Lifecycle:");
|
||||||
|
if (trace.events.length === 0) {
|
||||||
|
lines.push("(none)");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of trace.events) {
|
||||||
|
lines.push(formatTraceEvent(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
const supersededBy = relationMemoryIds(trace.events, "superseded_by");
|
||||||
|
if (supersededBy.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Superseded by:");
|
||||||
|
for (const id of supersededBy) lines.push(`- ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reinforcedBy = relationMemoryIds(trace.events, "reinforced_by");
|
||||||
|
if (reinforcedBy.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Reinforced by:");
|
||||||
|
for (const id of reinforcedBy) lines.push(`- ${id}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
|
||||||
|
import type { LongTermType } from "../../src/types.ts";
|
||||||
|
import { countBy, objectFromCounts, uniqueStrings } from "./text.ts";
|
||||||
|
import { groupEvidenceByMemoryId } from "./evidence-model.ts";
|
||||||
|
import { loadRejectionRecords } from "./rejections-model.ts";
|
||||||
|
import { snapshotForOptions } from "./workspace-snapshot.ts";
|
||||||
|
import type { CliOptions, CoverageClass, DisappearanceReason, MemoryInspectionReadModel } from "./types.ts";
|
||||||
|
|
||||||
|
export async function buildInspectionReadModel(options: CliOptions): Promise<MemoryInspectionReadModel> {
|
||||||
|
const snapshot = await snapshotForOptions(options);
|
||||||
|
const rejections = await loadRejectionRecords(options);
|
||||||
|
return {
|
||||||
|
snapshot,
|
||||||
|
store: snapshot.store,
|
||||||
|
pending: snapshot.journal,
|
||||||
|
evidenceEvents: snapshot.allEvents,
|
||||||
|
rejectionRecords: rejections.records,
|
||||||
|
currentById: new Map(snapshot.store.entries.map(entry => [entry.id, entry])),
|
||||||
|
evidenceByMemoryId: groupEvidenceByMemoryId(snapshot.allEvents),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminalDisappearanceReason(events: EvidenceEventV1[]): DisappearanceReason {
|
||||||
|
const terminal = [...events].reverse().find(event =>
|
||||||
|
event.type === "memory_removed_capacity"
|
||||||
|
|| event.type === "promotion_absorbed_exact"
|
||||||
|
|| event.type === "promotion_absorbed_identity"
|
||||||
|
|| event.type === "promotion_superseded"
|
||||||
|
);
|
||||||
|
const renderOmission = [...events].reverse().find(event => event.type === "render_omitted");
|
||||||
|
const event = terminal ?? renderOmission;
|
||||||
|
if (!event) {
|
||||||
|
return { classification: "historical_absent_unknown_reason", terminalType: "unknown", reasonCodes: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
classification: "historical_absent_with_reason",
|
||||||
|
terminalType: event.type,
|
||||||
|
reasonCodes: event.reasonCodes,
|
||||||
|
event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverageClassForMemory(id: string, model: MemoryInspectionReadModel): CoverageClass {
|
||||||
|
const events = model.evidenceByMemoryId.get(id) ?? [];
|
||||||
|
if (!model.currentById.has(id)) return terminalDisappearanceReason(events).classification;
|
||||||
|
if (events.length === 0) return "no_evidence";
|
||||||
|
if (events.every(event => event.phase === "render")) return "render_only";
|
||||||
|
return "full_lifecycle";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventCounts(events: EvidenceEventV1[]): { total: number; byType: Record<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))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disappearanceRows(model: MemoryInspectionReadModel): Array<{
|
||||||
|
id: string;
|
||||||
|
classification: DisappearanceReason["classification"];
|
||||||
|
terminalType: DisappearanceReason["terminalType"];
|
||||||
|
reasonCodes: string[];
|
||||||
|
event?: EvidenceEventV1;
|
||||||
|
events: EvidenceEventV1[];
|
||||||
|
}> {
|
||||||
|
const rows = [...model.evidenceByMemoryId.entries()]
|
||||||
|
.filter(([id]) => !model.currentById.has(id))
|
||||||
|
.map(([id, events]) => {
|
||||||
|
const reason = terminalDisappearanceReason(events);
|
||||||
|
return { id, ...reason, events };
|
||||||
|
});
|
||||||
|
return rows.sort((a, b) => a.classification.localeCompare(b.classification) || a.id.localeCompare(b.id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
export async function readJSONFile<T>(path: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJSONLFile<T>(path: string): Promise<{ records: T[]; invalidLines: number }> {
|
||||||
|
let content = "";
|
||||||
|
try {
|
||||||
|
content = await readFile(path, "utf8");
|
||||||
|
} catch {
|
||||||
|
return { records: [], invalidLines: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const records: T[] = [];
|
||||||
|
let invalidLines = 0;
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
records.push(JSON.parse(trimmed) as T);
|
||||||
|
} catch {
|
||||||
|
invalidLines += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { records, invalidLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathExists(path: string): boolean {
|
||||||
|
return existsSync(path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { dataHome, migrationLogPath } from "../../src/paths.ts";
|
||||||
|
import type { CliOptions, MigrationLogRecord } from "./types.ts";
|
||||||
|
|
||||||
|
export function migrationLogsRoot(): string {
|
||||||
|
return join(dataHome(), "opencode-working-memory", "migration-logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrationLogPaths(options: CliOptions): Promise<string[]> {
|
||||||
|
if (options.migration) return [migrationLogPath(options.migration)];
|
||||||
|
const root = migrationLogsRoot();
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = await readdir(root);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return entries.filter(entry => entry.endsWith(".jsonl")).sort().map(entry => join(root, entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrationIdFromPath(path: string): string {
|
||||||
|
return path.split("/").pop()?.replace(/\.jsonl$/, "") ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function riskySupersedeReasons(record: MigrationLogRecord): string[] {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
const hardReasonsMissing = !Array.isArray(record.hardReasons);
|
||||||
|
const hardReasons = Array.isArray(record.hardReasons) ? record.hardReasons : [];
|
||||||
|
const qualityReasons = Array.isArray(record.reasons) ? record.reasons : [];
|
||||||
|
const text = record.text ?? "";
|
||||||
|
|
||||||
|
if (hardReasonsMissing || hardReasons.length === 0) reasons.push("missing_or_empty_hardReasons");
|
||||||
|
if (qualityReasons.length > 0 && hardReasons.length === 0) reasons.push("soft_reasons_without_hardReasons");
|
||||||
|
if (/\b(?:User|user|prefers|requires|wants|insists)\b|用戶|使用者|偏好|要求|不要|不刪除/u.test(text)) reasons.push("user_preference_marker");
|
||||||
|
if (/\b(?:must|should|do not|never|is|are|follows)\b|必須|應該|採用|維持|需支援/iu.test(text)) reasons.push("durable_rule_marker");
|
||||||
|
if ((record.type === "feedback" || record.type === "decision") && hardReasons.length === 1 && hardReasons[0] === "path_heavy") {
|
||||||
|
reasons.push("feedback_or_decision_path_heavy_only");
|
||||||
|
}
|
||||||
|
|
||||||
|
return reasons;
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { extractionRejectionLogPath, workspaceKey } from "../../src/paths.ts";
|
||||||
|
import { HARD_QUALITY_REASONS, isArchitectureLikeDecision } from "../../src/memory-quality.ts";
|
||||||
|
import { ALLOWED_ORIGINS } from "./constants.ts";
|
||||||
|
import { readJSONLFile } from "./io.ts";
|
||||||
|
import { canonicalMemoryText, cleanText, countBy, objectFromCounts, truncate } from "./text.ts";
|
||||||
|
import { CliInputError, type CliOptions, type NormalizedRejection, type Origin, type RejectionLogRecord } from "./types.ts";
|
||||||
|
|
||||||
|
export { CliInputError } from "./types.ts";
|
||||||
|
|
||||||
|
export function inferOrigin(record: RejectionLogRecord): Origin {
|
||||||
|
if (record.origin && ALLOWED_ORIGINS.has(record.origin as Origin)) return record.origin as Origin;
|
||||||
|
if (record.source === "compaction") return "compaction_candidate";
|
||||||
|
if (record.source === "explicit") return "explicit_trigger";
|
||||||
|
if (record.source === "manual") return "manual";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | null {
|
||||||
|
if (!record.text || !Array.isArray(record.reasons)) return null;
|
||||||
|
const origin = inferOrigin(record);
|
||||||
|
return {
|
||||||
|
timestamp: record.timestamp ?? "",
|
||||||
|
workspaceKey: record.workspaceKey,
|
||||||
|
workspaceRoot: record.workspaceRoot,
|
||||||
|
workspaceRootHash: record.workspaceRootHash,
|
||||||
|
type: record.type ?? "project",
|
||||||
|
source: record.source,
|
||||||
|
origin,
|
||||||
|
fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger",
|
||||||
|
text: record.text,
|
||||||
|
reasons: record.reasons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sinceCutoff(rawSince: string | undefined, now = Date.now()): number | null {
|
||||||
|
if (!rawSince) return null;
|
||||||
|
const relative = rawSince.match(/^(\d+)([dhm])$/i);
|
||||||
|
if (relative) {
|
||||||
|
const amount = Number(relative[1]);
|
||||||
|
const unit = relative[2].toLowerCase();
|
||||||
|
const multiplier = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : 60_000;
|
||||||
|
return now - amount * multiplier;
|
||||||
|
}
|
||||||
|
const timestamp = new Date(rawSince).getTime();
|
||||||
|
if (Number.isNaN(timestamp)) throw new CliInputError(`Invalid --since value: ${rawSince}`);
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSoftReason(record: NormalizedRejection): boolean {
|
||||||
|
return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWorkspaceScope(record: NormalizedRejection): boolean {
|
||||||
|
return Boolean(record.workspaceKey || record.workspaceRoot || record.workspaceRootHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function workspaceKeyForOption(options: CliOptions): Promise<string | undefined> {
|
||||||
|
return options.workspace ? workspaceKey(options.workspace) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRejectionRecords(options: CliOptions): Promise<{ path: string; invalidLines: number; records: NormalizedRejection[] }> {
|
||||||
|
const path = extractionRejectionLogPath();
|
||||||
|
const { records, invalidLines } = await readJSONLFile<RejectionLogRecord>(path);
|
||||||
|
const cutoff = sinceCutoff(options.since);
|
||||||
|
const requestedWorkspaceKey = await workspaceKeyForOption(options);
|
||||||
|
let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null);
|
||||||
|
|
||||||
|
if (requestedWorkspaceKey) {
|
||||||
|
normalized = normalized.filter(record => !record.workspaceKey || record.workspaceKey === requestedWorkspaceKey);
|
||||||
|
}
|
||||||
|
if (cutoff !== null) {
|
||||||
|
normalized = normalized.filter(record => {
|
||||||
|
const timestamp = new Date(record.timestamp).getTime();
|
||||||
|
return !Number.isNaN(timestamp) && timestamp >= cutoff;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.softOnly) normalized = normalized.filter(hasSoftReason);
|
||||||
|
if (options.triggerOnly) normalized = normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger");
|
||||||
|
if (options.reason) normalized = normalized.filter(record => record.reasons.includes(options.reason ?? ""));
|
||||||
|
|
||||||
|
return { path, invalidLines, records: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueByCanonicalText(records: NormalizedRejection[]): NormalizedRejection[] {
|
||||||
|
const byText = new Map<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()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
RETENTION_TYPE_MAX,
|
||||||
|
calculateRetentionStrength,
|
||||||
|
} from "../../src/retention.ts";
|
||||||
|
import type { LongTermMemoryEntry, LongTermSource, LongTermType, WorkspaceMemoryStore } from "../../src/types.ts";
|
||||||
|
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../../src/types.ts";
|
||||||
|
import type { RetentionDiagItem } from "./types.ts";
|
||||||
|
|
||||||
|
export function ageDays(entry: LongTermMemoryEntry, now = Date.now()): number | null {
|
||||||
|
const time = new Date(entry.createdAt).getTime();
|
||||||
|
if (Number.isNaN(time)) return null;
|
||||||
|
return Math.floor((now - time) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStrength(value: number): string {
|
||||||
|
return Number.isFinite(value) ? value.toFixed(3) : "0.000";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysSinceIso(value: string | undefined, now = Date.now()): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const ms = new Date(value).getTime();
|
||||||
|
if (!Number.isFinite(ms)) return null;
|
||||||
|
return Math.max(0, (now - ms) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean {
|
||||||
|
return entry.safetyCritical === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retentionCandidatesForDiag(store: WorkspaceMemoryStore, now = Date.now()): {
|
||||||
|
sorted: RetentionDiagItem[];
|
||||||
|
rendered: RetentionDiagItem[];
|
||||||
|
typeCapped: RetentionDiagItem[];
|
||||||
|
globalCapped: RetentionDiagItem[];
|
||||||
|
} {
|
||||||
|
const active = store.entries.filter(entry => entry.status !== "superseded");
|
||||||
|
const sorted = active
|
||||||
|
.map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) }))
|
||||||
|
.sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id));
|
||||||
|
|
||||||
|
const rendered: RetentionDiagItem[] = [];
|
||||||
|
const typeCapped: RetentionDiagItem[] = [];
|
||||||
|
const globalCapped: RetentionDiagItem[] = [];
|
||||||
|
const typeCounts: Partial<Record<LongTermType, number>> = {};
|
||||||
|
|
||||||
|
for (const item of sorted) {
|
||||||
|
const count = typeCounts[item.entry.type] ?? 0;
|
||||||
|
const max = RETENTION_TYPE_MAX[item.entry.type] ?? Infinity;
|
||||||
|
if (count >= max) {
|
||||||
|
typeCapped.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
typeCounts[item.entry.type] = count + 1;
|
||||||
|
|
||||||
|
if (rendered.length < LONG_TERM_LIMITS.maxEntries) {
|
||||||
|
rendered.push(item);
|
||||||
|
} else {
|
||||||
|
globalCapped.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sorted, rendered, typeCapped, globalCapped };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promotionLimit(source: LongTermSource): number {
|
||||||
|
if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts;
|
||||||
|
return PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retentionClockSummary(entries: LongTermMemoryEntry[]): { present: number; missing: number; invalid: number } {
|
||||||
|
let present = 0;
|
||||||
|
let missing = 0;
|
||||||
|
let invalid = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.retentionClock === undefined) {
|
||||||
|
missing += 1;
|
||||||
|
} else if (!Number.isFinite(entry.retentionClock) || entry.retentionClock <= 0) {
|
||||||
|
invalid += 1;
|
||||||
|
} else {
|
||||||
|
present += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { present, missing, invalid };
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
|
||||||
|
import { redactCredentials } from "../../src/redaction.ts";
|
||||||
|
|
||||||
|
export function countBy<T extends string>(items: T[]): Map<T, number> {
|
||||||
|
const counts = new Map<T, number>();
|
||||||
|
for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1);
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortedCounts<T extends string>(counts: Map<T, number>): Array<[T, number]> {
|
||||||
|
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceRootHash(root: string): string {
|
||||||
|
return createHash("sha256").update(root).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactAbsolutePaths(text: string): string {
|
||||||
|
return text.replace(/(?:^|[\s"'`(=:\[])(\/(?:Users|home|private|tmp|var|opt|Volumes|[^\s"'`)\],;:]+)\/[^\s"'`)\],;:]*)/g, (match, path) => match.replace(path, "<path>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanText(text: string, raw: boolean): string {
|
||||||
|
if (raw) return text;
|
||||||
|
return redactAbsolutePaths(redactCredentials(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanPath(path: string, raw: boolean): string {
|
||||||
|
return raw ? path : "<path>";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWorkspaceIdentity(workspaceKeyValue: string | undefined, workspaceRoot: string | undefined, raw: boolean): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (workspaceKeyValue) parts.push(`workspaceKey=${workspaceKeyValue}`);
|
||||||
|
if (workspaceRoot) {
|
||||||
|
parts.push(raw ? `workspaceRoot=${workspaceRoot}` : `workspaceRootHash=${workspaceRootHash(workspaceRoot)}`);
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(text: string, max = 120): string {
|
||||||
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||||
|
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalMemoryText(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize("NFKC")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s\p{P}]+/gu, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(ratio: number): string {
|
||||||
|
return `${(ratio * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueStrings(values: string[]): string[] {
|
||||||
|
return [...new Set(values.filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectFromCounts<T extends string>(counts: Map<T, number>): Record<string, number> {
|
||||||
|
return Object.fromEntries(sortedCounts(counts));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDetails(details: EvidenceEventV1["details"]): string {
|
||||||
|
if (!details || Object.keys(details).length === 0) return "none";
|
||||||
|
return Object.entries(details).map(([key, value]) => `${key}=${Array.isArray(value) ? value.join("|") : String(value)}`).join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
|
||||||
|
import { uniqueStrings } from "./text.ts";
|
||||||
|
import { statusFromOmissionReason } from "./workspace-snapshot.ts";
|
||||||
|
|
||||||
|
export function statusFromTraceEvent(event: EvidenceEventV1 | undefined): string {
|
||||||
|
if (!event) return "unknown";
|
||||||
|
if (event.type === "render_selected") return "rendered";
|
||||||
|
if (event.type === "render_omitted") return statusFromOmissionReason(event.reasonCodes[0]);
|
||||||
|
if (event.type === "promotion_absorbed_exact" || event.type === "promotion_absorbed_identity") return "omitted_absorbed_duplicate";
|
||||||
|
if (event.type === "promotion_retry_scheduled") return "pending_retry";
|
||||||
|
if (event.type === "promotion_rejected_capacity" || event.type === "promotion_retry_exhausted") return "pending_rejected_capacity";
|
||||||
|
if (event.type === "storage_corrupt_json_quarantined") return "quarantined_corrupt_store";
|
||||||
|
if (event.outcome === "superseded") return "omitted_superseded";
|
||||||
|
return event.outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTraceEvent(event: EvidenceEventV1): string {
|
||||||
|
const reasons = event.reasonCodes.length > 0 ? event.reasonCodes.join(",") : "none";
|
||||||
|
const relations = (event.relations ?? [])
|
||||||
|
.map(relation => relation.memory?.memoryId ? `${relation.role}=${relation.memory.memoryId}` : undefined)
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
const relationText = relations.length > 0 ? `; ${relations.join(", ")}` : "";
|
||||||
|
return `- ${event.eventId} ${event.type}: ${event.outcome}; reasons=${reasons}${relationText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationMemoryIds(events: EvidenceEventV1[], role: string): string[] {
|
||||||
|
return uniqueStrings(events.flatMap(event => (event.relations ?? [])
|
||||||
|
.filter(relation => relation.role === role)
|
||||||
|
.map(relation => relation.memory?.memoryId ?? "")));
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome } from "../../src/evidence-log.ts";
|
||||||
|
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts";
|
||||||
|
|
||||||
|
export type MemoryRenderStatus =
|
||||||
|
| "rendered"
|
||||||
|
| "omitted_superseded"
|
||||||
|
| "omitted_type_cap"
|
||||||
|
| "omitted_global_cap"
|
||||||
|
| "omitted_char_budget"
|
||||||
|
| "omitted_absorbed_duplicate"
|
||||||
|
| "pending_retry"
|
||||||
|
| "pending_rejected_capacity"
|
||||||
|
| "quarantined_corrupt_store";
|
||||||
|
|
||||||
|
export type MemoryDiagJSON = {
|
||||||
|
version: 1;
|
||||||
|
workspace: { rootHash: string; key: string };
|
||||||
|
generatedAt: string;
|
||||||
|
summary: {
|
||||||
|
storedActive: number;
|
||||||
|
rendered: number;
|
||||||
|
pending: number;
|
||||||
|
rejectedLast7Days: number;
|
||||||
|
corruptStoresQuarantinedLast30Days: number;
|
||||||
|
status?: "ok" | "warning" | "degraded";
|
||||||
|
evidenceCoveragePercent?: number;
|
||||||
|
needsAttention?: string[];
|
||||||
|
suggestedNextSteps?: string[];
|
||||||
|
};
|
||||||
|
memories: Array<{
|
||||||
|
id: string;
|
||||||
|
type: "feedback" | "project" | "decision" | "reference";
|
||||||
|
source: "explicit" | "compaction" | "manual";
|
||||||
|
status: MemoryRenderStatus;
|
||||||
|
strength?: number;
|
||||||
|
reasonCodes: string[];
|
||||||
|
textPreview?: string;
|
||||||
|
evidenceEventIds: string[];
|
||||||
|
}>;
|
||||||
|
recentEvents: Array<{
|
||||||
|
eventId: string;
|
||||||
|
type: EvidenceEventType;
|
||||||
|
outcome: EvidenceOutcome;
|
||||||
|
createdAt: string;
|
||||||
|
memoryId?: string;
|
||||||
|
reasonCodes: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Command = "status" | "rejected" | "missing" | "explain" | "coverage" | "audit";
|
||||||
|
export type LegacyCommand = "health" | "quality" | "rejections" | "disappearances" | "trace";
|
||||||
|
export type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
|
||||||
|
|
||||||
|
export type CliOptions = {
|
||||||
|
raw: boolean;
|
||||||
|
json?: boolean;
|
||||||
|
verbose?: boolean;
|
||||||
|
noEmoji?: boolean;
|
||||||
|
workspace?: string;
|
||||||
|
all?: boolean;
|
||||||
|
softOnly?: boolean;
|
||||||
|
triggerOnly?: boolean;
|
||||||
|
includeHistorical?: boolean;
|
||||||
|
quality?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
unique?: boolean;
|
||||||
|
explain?: boolean;
|
||||||
|
since?: string;
|
||||||
|
migration?: string;
|
||||||
|
memory?: string;
|
||||||
|
legacyCommand?: LegacyCommand;
|
||||||
|
positional?: string[];
|
||||||
|
auditMode?: "coverage" | "migrations";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedArgs =
|
||||||
|
| { ok: true; command: Command; options: CliOptions; deprecationNotice?: string }
|
||||||
|
| { ok: true; help: true; usage: string }
|
||||||
|
| { ok: false; message: string; usage: string; exitCode: number };
|
||||||
|
|
||||||
|
export type CommandResult = { stdout: string; stderr?: string; exitCode?: number };
|
||||||
|
|
||||||
|
export class CliInputError extends Error {}
|
||||||
|
|
||||||
|
export type RejectionLogRecord = {
|
||||||
|
timestamp?: string;
|
||||||
|
workspaceKey?: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
workspaceRootHash?: string;
|
||||||
|
type?: LongTermType;
|
||||||
|
source?: LongTermSource | string;
|
||||||
|
origin?: string;
|
||||||
|
fromTrigger?: boolean;
|
||||||
|
text?: string;
|
||||||
|
reasons?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
|
||||||
|
workspaceKey?: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
workspaceRootHash?: string;
|
||||||
|
source?: string;
|
||||||
|
origin: Origin;
|
||||||
|
fromTrigger: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MigrationLogRecord = {
|
||||||
|
migrationId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
workspaceKey?: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
entryId?: string;
|
||||||
|
type?: LongTermType;
|
||||||
|
source?: LongTermSource | string;
|
||||||
|
text?: string;
|
||||||
|
reasons?: string[];
|
||||||
|
hardReasons?: string[];
|
||||||
|
beforeStatus?: string;
|
||||||
|
afterStatus?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RetentionDiagItem = {
|
||||||
|
entry: LongTermMemoryEntry;
|
||||||
|
strength: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceDiagSnapshot = {
|
||||||
|
store: WorkspaceMemoryStore;
|
||||||
|
journal: PendingMemoryJournalStore;
|
||||||
|
retention: {
|
||||||
|
sorted: RetentionDiagItem[];
|
||||||
|
rendered: RetentionDiagItem[];
|
||||||
|
typeCapped: RetentionDiagItem[];
|
||||||
|
globalCapped: RetentionDiagItem[];
|
||||||
|
};
|
||||||
|
memories: MemoryDiagJSON["memories"];
|
||||||
|
recentEvents: MemoryDiagJSON["recentEvents"];
|
||||||
|
allEvents: EvidenceEventV1[];
|
||||||
|
summary: MemoryDiagJSON["summary"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemoryInspectionReadModel = {
|
||||||
|
snapshot: WorkspaceDiagSnapshot;
|
||||||
|
store: WorkspaceMemoryStore;
|
||||||
|
pending: PendingMemoryJournalStore;
|
||||||
|
evidenceEvents: EvidenceEventV1[];
|
||||||
|
rejectionRecords: NormalizedRejection[];
|
||||||
|
currentById: Map<string, LongTermMemoryEntry>;
|
||||||
|
evidenceByMemoryId: Map<string, EvidenceEventV1[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CoverageClass = "full_lifecycle" | "render_only" | "no_evidence" | "historical_absent_with_reason" | "historical_absent_unknown_reason";
|
||||||
|
|
||||||
|
export type DisappearanceReason = {
|
||||||
|
classification: "historical_absent_with_reason" | "historical_absent_unknown_reason";
|
||||||
|
terminalType: EvidenceEventType | "unknown";
|
||||||
|
reasonCodes: string[];
|
||||||
|
event?: EvidenceEventV1;
|
||||||
|
};
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { calculateRetentionStrength } from "../../src/retention.ts";
|
||||||
|
import {
|
||||||
|
queryEvidenceEvents,
|
||||||
|
type EvidenceEventV1,
|
||||||
|
} from "../../src/evidence-log.ts";
|
||||||
|
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../../src/paths.ts";
|
||||||
|
import { accountWorkspaceMemoryRender } from "../../src/workspace-memory.ts";
|
||||||
|
import type { LongTermMemoryEntry, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts";
|
||||||
|
import { LONG_TERM_LIMITS } from "../../src/types.ts";
|
||||||
|
import { readJSONFile } from "./io.ts";
|
||||||
|
import { groupEvidenceByMemoryId } from "./evidence-model.ts";
|
||||||
|
import { promotionLimit, retentionCandidatesForDiag } from "./retention-model.ts";
|
||||||
|
import { cleanText, truncate, uniqueStrings, workspaceRootHash } from "./text.ts";
|
||||||
|
import type { CliOptions, MemoryDiagJSON, MemoryRenderStatus, WorkspaceDiagSnapshot } from "./types.ts";
|
||||||
|
|
||||||
|
export function emptyStore(root: string, key: string): WorkspaceMemoryStore {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizedStore(store: WorkspaceMemoryStore | null, root: string, key: string): WorkspaceMemoryStore {
|
||||||
|
const fallback = emptyStore(root, key);
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
...(store ?? {}),
|
||||||
|
workspace: store?.workspace ?? fallback.workspace,
|
||||||
|
limits: {
|
||||||
|
maxRenderedChars: store?.limits?.maxRenderedChars ?? fallback.limits.maxRenderedChars,
|
||||||
|
maxEntries: store?.limits?.maxEntries ?? fallback.limits.maxEntries,
|
||||||
|
},
|
||||||
|
entries: Array.isArray(store?.entries) ? store.entries : [],
|
||||||
|
migrations: Array.isArray(store?.migrations) ? store.migrations : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizedJournal(journal: PendingMemoryJournalStore | null): PendingMemoryJournalStore {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
workspace: journal?.workspace ?? { root: "", key: "" },
|
||||||
|
entries: Array.isArray(journal?.entries) ? journal.entries : [],
|
||||||
|
updatedAt: journal?.updatedAt ?? new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventMemoryId(event: EvidenceEventV1): string | undefined {
|
||||||
|
return event.memory?.memoryId
|
||||||
|
?? event.relations?.map(relation => relation.memory?.memoryId).find((id): id is string => Boolean(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWithinDays(iso: string, days: number, now = Date.now()): boolean {
|
||||||
|
const ms = new Date(iso).getTime();
|
||||||
|
return Number.isFinite(ms) && ms >= now - days * 86_400_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderStatusReason(status: MemoryRenderStatus, fallback?: string): string[] {
|
||||||
|
switch (status) {
|
||||||
|
case "rendered": return ["within_caps", "within_char_budget"];
|
||||||
|
case "omitted_superseded": return ["superseded"];
|
||||||
|
case "omitted_type_cap": return ["type_cap"];
|
||||||
|
case "omitted_global_cap": return ["global_cap"];
|
||||||
|
case "omitted_char_budget": return [fallback === "empty_render_budget" ? "empty_render_budget" : "char_budget"];
|
||||||
|
case "omitted_absorbed_duplicate": return ["absorbed_duplicate"];
|
||||||
|
case "pending_retry": return ["retryable_capacity_rejection"];
|
||||||
|
case "pending_rejected_capacity": return ["capacity_rejected", "max_attempts_reached"];
|
||||||
|
case "quarantined_corrupt_store": return ["invalid_json"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusFromOmissionReason(reason: string | undefined): MemoryRenderStatus {
|
||||||
|
if (reason === "superseded") return "omitted_superseded";
|
||||||
|
if (reason === "type_cap") return "omitted_type_cap";
|
||||||
|
if (reason === "global_cap") return "omitted_global_cap";
|
||||||
|
return "omitted_char_budget";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pendingStatus(entry: LongTermMemoryEntry): MemoryRenderStatus {
|
||||||
|
const attempts = entry.promotionAttempts ?? 0;
|
||||||
|
return attempts >= promotionLimit(entry.source) ? "pending_rejected_capacity" : "pending_retry";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeTextPreview(text: string): string {
|
||||||
|
return truncate(cleanText(text, false), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceSummaryForMemory(grouped: Map<string, EvidenceEventV1[]>, memoryId: string): { eventIds: string[]; reasonCodes: string[] } {
|
||||||
|
const events = grouped.get(memoryId) ?? [];
|
||||||
|
const reasonCodes = new Set<string>();
|
||||||
|
for (const event of events) {
|
||||||
|
for (const reason of event.reasonCodes) reasonCodes.add(reason);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
eventIds: events.map(event => event.eventId),
|
||||||
|
reasonCodes: [...reasonCodes],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildWorkspaceDiagSnapshot(input: {
|
||||||
|
root: string;
|
||||||
|
key: string;
|
||||||
|
memoryPath: string;
|
||||||
|
pendingPath: string;
|
||||||
|
}, now = Date.now()): Promise<WorkspaceDiagSnapshot> {
|
||||||
|
const rawStore = await readJSONFile<WorkspaceMemoryStore>(input.memoryPath);
|
||||||
|
const storeRoot = rawStore?.workspace?.root ?? input.root;
|
||||||
|
const storeKey = rawStore?.workspace?.key ?? input.key;
|
||||||
|
const store = normalizedStore(rawStore, storeRoot, storeKey);
|
||||||
|
const journal = normalizedJournal(await readJSONFile<PendingMemoryJournalStore>(input.pendingPath));
|
||||||
|
const retention = retentionCandidatesForDiag(store, now);
|
||||||
|
const renderAccounting = accountWorkspaceMemoryRender(store);
|
||||||
|
const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id));
|
||||||
|
const omittedById = new Map(renderAccounting.omitted.map(item => [item.memory.id, item.reason]));
|
||||||
|
const allEvents = await queryEvidenceEvents(input.root);
|
||||||
|
const evidenceByMemoryId = groupEvidenceByMemoryId(allEvents);
|
||||||
|
const recentEvidence = await queryEvidenceEvents(input.root, { newestFirst: true, limit: 50 });
|
||||||
|
const memoryRows: MemoryDiagJSON["memories"] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const entry of store.entries) {
|
||||||
|
const omissionReason = omittedById.get(entry.id);
|
||||||
|
const status: MemoryRenderStatus = renderedIds.has(entry.id)
|
||||||
|
? "rendered"
|
||||||
|
: statusFromOmissionReason(omissionReason ?? (entry.status === "superseded" ? "superseded" : undefined));
|
||||||
|
const summary = evidenceSummaryForMemory(evidenceByMemoryId, entry.id);
|
||||||
|
memoryRows.push({
|
||||||
|
id: entry.id,
|
||||||
|
type: entry.type,
|
||||||
|
source: entry.source,
|
||||||
|
status,
|
||||||
|
strength: calculateRetentionStrength(entry, now, store.lastActivityAt),
|
||||||
|
reasonCodes: uniqueStrings([...renderStatusReason(status, omissionReason), ...summary.reasonCodes]),
|
||||||
|
textPreview: safeTextPreview(entry.text),
|
||||||
|
evidenceEventIds: summary.eventIds,
|
||||||
|
});
|
||||||
|
seenIds.add(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const status = pendingStatus(entry);
|
||||||
|
const summary = evidenceSummaryForMemory(evidenceByMemoryId, entry.id);
|
||||||
|
memoryRows.push({
|
||||||
|
id: entry.id,
|
||||||
|
type: entry.type,
|
||||||
|
source: entry.source,
|
||||||
|
status,
|
||||||
|
strength: calculateRetentionStrength(entry, now, store.lastActivityAt),
|
||||||
|
reasonCodes: uniqueStrings([...renderStatusReason(status), entry.lastPromotionFailureReason ?? "", ...summary.reasonCodes]),
|
||||||
|
textPreview: safeTextPreview(entry.text),
|
||||||
|
evidenceEventIds: summary.eventIds,
|
||||||
|
});
|
||||||
|
seenIds.add(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of allEvents) {
|
||||||
|
if (event.outcome !== "absorbed") continue;
|
||||||
|
const memory = event.memory;
|
||||||
|
if (!memory?.memoryId || !memory.type || !memory.source || seenIds.has(memory.memoryId)) continue;
|
||||||
|
const summary = evidenceSummaryForMemory(evidenceByMemoryId, memory.memoryId);
|
||||||
|
memoryRows.push({
|
||||||
|
id: memory.memoryId,
|
||||||
|
type: memory.type,
|
||||||
|
source: memory.source,
|
||||||
|
status: "omitted_absorbed_duplicate",
|
||||||
|
reasonCodes: uniqueStrings([...renderStatusReason("omitted_absorbed_duplicate"), ...summary.reasonCodes]),
|
||||||
|
evidenceEventIds: summary.eventIds.length > 0 ? summary.eventIds : [event.eventId],
|
||||||
|
});
|
||||||
|
seenIds.add(memory.memoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentEvents = recentEvidence.map(event => ({
|
||||||
|
eventId: event.eventId,
|
||||||
|
type: event.type,
|
||||||
|
outcome: event.outcome,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
memoryId: eventMemoryId(event),
|
||||||
|
reasonCodes: uniqueStrings([
|
||||||
|
...event.reasonCodes,
|
||||||
|
...(event.type === "storage_corrupt_json_quarantined" ? ["quarantined_corrupt_store"] : []),
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
journal,
|
||||||
|
retention,
|
||||||
|
memories: memoryRows,
|
||||||
|
recentEvents,
|
||||||
|
allEvents,
|
||||||
|
summary: {
|
||||||
|
storedActive: store.entries.filter(entry => entry.status !== "superseded").length,
|
||||||
|
rendered: retention.rendered.length,
|
||||||
|
pending: journal.entries.length,
|
||||||
|
rejectedLast7Days: allEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7, now)).length,
|
||||||
|
corruptStoresQuarantinedLast30Days: allEvents.filter(event => event.type === "storage_corrupt_json_quarantined" && isWithinDays(event.createdAt, 30, now)).length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildMemoryDiagJSON(root: string): Promise<MemoryDiagJSON> {
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
const snapshot = await buildWorkspaceDiagSnapshot({
|
||||||
|
root,
|
||||||
|
key,
|
||||||
|
memoryPath: await workspaceMemoryPath(root),
|
||||||
|
pendingPath: await workspacePendingJournalPath(root),
|
||||||
|
});
|
||||||
|
|
||||||
|
return memoryDiagJSONFromSnapshot(root, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memoryDiagJSONFromSnapshot(root: string, snapshot: WorkspaceDiagSnapshot, generatedAt = new Date().toISOString()): MemoryDiagJSON {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
workspace: { rootHash: workspaceRootHash(snapshot.store.workspace.root || root), key: snapshot.store.workspace.key },
|
||||||
|
generatedAt,
|
||||||
|
summary: snapshot.summary,
|
||||||
|
memories: snapshot.memories,
|
||||||
|
recentEvents: snapshot.recentEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function snapshotForOptions(options: CliOptions): Promise<WorkspaceDiagSnapshot> {
|
||||||
|
const root = options.workspace ?? process.cwd();
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
return buildWorkspaceDiagSnapshot({
|
||||||
|
root,
|
||||||
|
key,
|
||||||
|
memoryPath: await workspaceMemoryPath(root),
|
||||||
|
pendingPath: await workspacePendingJournalPath(root),
|
||||||
|
});
|
||||||
|
}
|
||||||
+5
-1
@@ -20,8 +20,10 @@ export type EvidenceEventType =
|
|||||||
| "promotion_retry_scheduled"
|
| "promotion_retry_scheduled"
|
||||||
| "promotion_retry_exhausted"
|
| "promotion_retry_exhausted"
|
||||||
| "memory_reinforced"
|
| "memory_reinforced"
|
||||||
|
| "memory_migration_superseded"
|
||||||
| "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 +47,7 @@ export type EvidenceOutcome =
|
|||||||
| "superseded"
|
| "superseded"
|
||||||
| "rendered"
|
| "rendered"
|
||||||
| "omitted"
|
| "omitted"
|
||||||
|
| "removed"
|
||||||
| "retried"
|
| "retried"
|
||||||
| "exhausted"
|
| "exhausted"
|
||||||
| "reinforced"
|
| "reinforced"
|
||||||
@@ -73,7 +76,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;
|
||||||
|
|||||||
+22
-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,
|
||||||
@@ -115,6 +116,9 @@ function buildCompactionPrompt(privateContext: string): string {
|
|||||||
"",
|
"",
|
||||||
"CRITICAL MEMORY RULES:",
|
"CRITICAL MEMORY RULES:",
|
||||||
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
|
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
|
||||||
|
"- Existing workspace memory may already contain durable facts. If a fact is already present and still accurate, do not create a rephrased duplicate.",
|
||||||
|
"- If the same durable fact truly needs to be emitted again, reuse the existing memory wording exactly whenever possible.",
|
||||||
|
"- Only emit a new memory when the fact is new, materially corrected, or materially more specific than the existing memory.",
|
||||||
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
|
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
|
||||||
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
|
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
|
||||||
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
|
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
|
||||||
@@ -183,6 +187,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 +745,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,
|
||||||
|
|||||||
+154
-17
@@ -12,11 +12,13 @@ import {
|
|||||||
reinforceMemory,
|
reinforceMemory,
|
||||||
} from "./retention.ts";
|
} from "./retention.ts";
|
||||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
||||||
|
import { appendEvidenceEvents } from "./evidence-log.ts";
|
||||||
|
|
||||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||||
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"
|
||||||
@@ -72,6 +74,17 @@ export type QualityCleanupMigrationLogEntry = {
|
|||||||
afterStatus: "superseded";
|
afterStatus: "superseded";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type P0CleanupMigrationResult = {
|
||||||
|
store: WorkspaceMemoryStore;
|
||||||
|
events: EvidenceEventInput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QualityCleanupMigrationResult = {
|
||||||
|
store: WorkspaceMemoryStore;
|
||||||
|
events: QualityCleanupMigrationLogEntry[];
|
||||||
|
evidence: EvidenceEventInput[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
@@ -113,6 +126,14 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
|
|||||||
// writes for ordering/capacity/timestamp-only normalization.
|
// writes for ordering/capacity/timestamp-only normalization.
|
||||||
if (hasSecurityOrMigrationChange(store, normalized.store)) {
|
if (hasSecurityOrMigrationChange(store, normalized.store)) {
|
||||||
await atomicWriteJSON(path, normalized.store);
|
await atomicWriteJSON(path, normalized.store);
|
||||||
|
|
||||||
|
// loadWorkspaceMemory has a narrow migration side effect: when a first-load
|
||||||
|
// migration actually supersedes entries, append evidence for those storage
|
||||||
|
// changes. Migration IDs keep this idempotent on repeated loads.
|
||||||
|
const migrationEvidence = normalized.evidence.filter(event => event.type === "memory_migration_superseded");
|
||||||
|
if (migrationEvidence.length > 0) {
|
||||||
|
await appendEvidenceEvents(root, migrationEvidence);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized.store;
|
return normalized.store;
|
||||||
@@ -129,6 +150,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 ?? []);
|
||||||
@@ -157,8 +179,15 @@ export async function updateWorkspaceMemoryWithAccounting(
|
|||||||
const fallback = await emptyWorkspaceMemory(root);
|
const fallback = await emptyWorkspaceMemory(root);
|
||||||
let finalResult: WorkspaceMemoryNormalizationResult | undefined;
|
let finalResult: WorkspaceMemoryNormalizationResult | undefined;
|
||||||
const store = await updateJSON(path, () => fallback, async current => {
|
const store = await updateJSON(path, () => fallback, async current => {
|
||||||
const normalized = await normalizeWorkspaceMemory(root, current);
|
const currentNormalization = await normalizeWorkspaceMemoryWithAccounting(root, current);
|
||||||
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized));
|
const currentMigrationEvidence = currentNormalization.evidence.filter(event => event.type === "memory_migration_superseded");
|
||||||
|
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(currentNormalization.store));
|
||||||
|
if (currentMigrationEvidence.length > 0) {
|
||||||
|
finalResult = {
|
||||||
|
...finalResult,
|
||||||
|
evidence: [...currentMigrationEvidence, ...finalResult.evidence],
|
||||||
|
};
|
||||||
|
}
|
||||||
return finalResult.store;
|
return finalResult.store;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,7 +213,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,
|
||||||
@@ -221,10 +251,12 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
const beforeQualityCleanup = result;
|
const beforeQualityCleanup = result;
|
||||||
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
|
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
|
||||||
result = qualityCleanup.store;
|
result = qualityCleanup.store;
|
||||||
|
let migrationEvidence: EvidenceEventInput[] = [];
|
||||||
let skipRemainingMigrations = false;
|
let skipRemainingMigrations = false;
|
||||||
if (qualityCleanup.events.length > 0) {
|
if (qualityCleanup.events.length > 0) {
|
||||||
try {
|
try {
|
||||||
await appendQualityCleanupMigrationLog(qualityCleanup.events);
|
await appendQualityCleanupMigrationLog(qualityCleanup.events);
|
||||||
|
migrationEvidence = [...migrationEvidence, ...qualityCleanup.evidence];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[memory] failed to write quality cleanup migration log:", error);
|
console.error("[memory] failed to write quality cleanup migration log:", error);
|
||||||
console.error("[memory] aborting migration to maintain audit trail integrity");
|
console.error("[memory] aborting migration to maintain audit trail integrity");
|
||||||
@@ -233,7 +265,18 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipRemainingMigrations) {
|
if (!skipRemainingMigrations) {
|
||||||
result = runMigrationP0Cleanup(result, nowIso);
|
const p0Cleanup = runMigrationP0Cleanup(result, nowIso);
|
||||||
|
result = p0Cleanup.store;
|
||||||
|
migrationEvidence = [...migrationEvidence, ...p0Cleanup.events];
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -257,39 +300,64 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
dropped: accounting.dropped,
|
dropped: accounting.dropped,
|
||||||
absorbed: accounting.absorbed,
|
absorbed: accounting.absorbed,
|
||||||
superseded: accounting.superseded,
|
superseded: accounting.superseded,
|
||||||
evidence: accounting.evidence,
|
evidence: [...migrationEvidence, ...accounting.evidence],
|
||||||
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
): WorkspaceMemoryStore {
|
): P0CleanupMigrationResult {
|
||||||
if (store.migrations?.includes(MIGRATION_ID)) {
|
if (store.migrations?.includes(MIGRATION_ID)) {
|
||||||
return store;
|
return { store, events: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const events: EvidenceEventInput[] = [];
|
||||||
const entries = store.entries.map(entry => {
|
const entries = store.entries.map(entry => {
|
||||||
if (entry.source !== "compaction") return entry;
|
if (entry.source !== "compaction") return entry;
|
||||||
if (entry.type !== "project") return entry;
|
if (entry.type !== "project") return entry;
|
||||||
|
if (entry.status === "superseded") return entry;
|
||||||
|
|
||||||
if (isProgressSnapshotViolation(entry.text)) {
|
if (isProgressSnapshotViolation(entry.text)) {
|
||||||
return {
|
const superseded = {
|
||||||
...entry,
|
...entry,
|
||||||
status: "superseded" as const,
|
status: "superseded" as const,
|
||||||
updatedAt: nowIso,
|
updatedAt: nowIso,
|
||||||
};
|
};
|
||||||
|
events.push(migrationSupersededEvidence(superseded, ["migration:p0_cleanup"], MIGRATION_ID));
|
||||||
|
return superseded;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...store,
|
store: {
|
||||||
entries,
|
...store,
|
||||||
migrations: [...(store.migrations || []), MIGRATION_ID],
|
entries,
|
||||||
updatedAt: nowIso,
|
migrations: [...(store.migrations || []), MIGRATION_ID],
|
||||||
|
updatedAt: nowIso,
|
||||||
|
},
|
||||||
|
events,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,12 +371,13 @@ async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationL
|
|||||||
export function runMigrationQualityCleanup(
|
export function runMigrationQualityCleanup(
|
||||||
store: WorkspaceMemoryStore,
|
store: WorkspaceMemoryStore,
|
||||||
nowIso: string,
|
nowIso: string,
|
||||||
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
|
): QualityCleanupMigrationResult {
|
||||||
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
|
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
|
||||||
return { store, events: [] };
|
return { store, events: [], evidence: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const events: QualityCleanupMigrationLogEntry[] = [];
|
const events: QualityCleanupMigrationLogEntry[] = [];
|
||||||
|
const evidence: EvidenceEventInput[] = [];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const entries = store.entries.map(entry => {
|
const entries = store.entries.map(entry => {
|
||||||
if (entry.source !== "compaction") return entry;
|
if (entry.source !== "compaction") return entry;
|
||||||
@@ -342,12 +411,19 @@ export function runMigrationQualityCleanup(
|
|||||||
...hardReasons.map(reason => `quality:${reason}`),
|
...hardReasons.map(reason => `quality:${reason}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
const superseded = {
|
||||||
...entry,
|
...entry,
|
||||||
status: "superseded" as const,
|
status: "superseded" as const,
|
||||||
updatedAt: nowIso,
|
updatedAt: nowIso,
|
||||||
tags: [...tags],
|
tags: [...tags],
|
||||||
};
|
};
|
||||||
|
evidence.push(migrationSupersededEvidence(
|
||||||
|
superseded,
|
||||||
|
["migration:quality_cleanup", ...hardReasons.map(reason => `quality:${reason}`)],
|
||||||
|
QUALITY_CLEANUP_MIGRATION_ID,
|
||||||
|
{ hardReasons },
|
||||||
|
));
|
||||||
|
return superseded;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -358,6 +434,7 @@ export function runMigrationQualityCleanup(
|
|||||||
updatedAt: changed ? nowIso : store.updatedAt,
|
updatedAt: changed ? nowIso : store.updatedAt,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
|
evidence,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +547,54 @@ 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 } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationSupersededEvidence(
|
||||||
|
memory: LongTermMemoryEntry,
|
||||||
|
reasonCodes: string[],
|
||||||
|
migrationId: string,
|
||||||
|
details: EvidenceEventInput["details"] = {},
|
||||||
|
): EvidenceEventInput {
|
||||||
|
return {
|
||||||
|
type: "memory_migration_superseded",
|
||||||
|
phase: "storage",
|
||||||
|
outcome: "superseded",
|
||||||
|
memory: memoryEvidenceRef(memory),
|
||||||
|
relations: [{ role: "superseded", memory: memoryEvidenceRef(memory) }],
|
||||||
|
reasonCodes,
|
||||||
|
details: {
|
||||||
|
migrationId,
|
||||||
|
type: memory.type,
|
||||||
|
source: memory.source,
|
||||||
|
...details,
|
||||||
|
},
|
||||||
|
textPreview: memory.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 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 +650,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 +666,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],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,6 +701,11 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
|||||||
const evidence: EvidenceEventInput[] = [];
|
const evidence: EvidenceEventInput[] = [];
|
||||||
|
|
||||||
// For project/reference/feedback: dedupe by concrete identity or exact canonical text.
|
// For project/reference/feedback: dedupe by concrete identity or exact canonical text.
|
||||||
|
// Feedback is grouped with project/reference for entity dedupe, but
|
||||||
|
// workspaceMemoryIdentityKey() returns exact key for feedback (no concrete
|
||||||
|
// identity extraction). This means feedback absorbed_identity is currently
|
||||||
|
// impossible. When identity key extraction is extended to all types, feedback
|
||||||
|
// with matching concrete identifiers will correctly produce absorbed_identity.
|
||||||
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
||||||
|
|
||||||
// Build identity key dedup for project/reference/feedback.
|
// Build identity key dedup for project/reference/feedback.
|
||||||
@@ -612,7 +749,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
|||||||
const dropped = retained === entry ? existing : entry;
|
const dropped = retained === entry ? existing : entry;
|
||||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||||
? "absorbed_exact" as const
|
? "absorbed_exact" as const
|
||||||
: "superseded_existing" as const;
|
: "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs
|
||||||
const reinforced = reinforceMemory(
|
const reinforced = reinforceMemory(
|
||||||
retained,
|
retained,
|
||||||
reinforcementSessionId(retained, dropped),
|
reinforcementSessionId(retained, dropped),
|
||||||
|
|||||||
@@ -132,6 +132,83 @@ 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("memory_migration_superseded event round-trips through append and query", async () => {
|
||||||
|
const root = await tempRoot();
|
||||||
|
try {
|
||||||
|
await appendEvidenceEvent(root, eventInput({
|
||||||
|
type: "memory_migration_superseded",
|
||||||
|
phase: "storage",
|
||||||
|
outcome: "superseded",
|
||||||
|
reasonCodes: ["migration:quality_cleanup", "quality:progress_snapshot"],
|
||||||
|
memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" },
|
||||||
|
relations: [{ role: "superseded", memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" } }],
|
||||||
|
details: {
|
||||||
|
migrationId: "2026-04-28-quality-cleanup",
|
||||||
|
type: "project",
|
||||||
|
source: "compaction",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await queryEvidenceEvents(root, {
|
||||||
|
types: ["memory_migration_superseded"],
|
||||||
|
phases: ["storage"],
|
||||||
|
outcomes: ["superseded"],
|
||||||
|
memoryId: "migrated-memory",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.length, 1);
|
||||||
|
assert.equal(result[0].type, "memory_migration_superseded");
|
||||||
|
assert.equal(result[0].phase, "storage");
|
||||||
|
assert.equal(result[0].outcome, "superseded");
|
||||||
|
assert.ok(result[0].reasonCodes.includes("migration:quality_cleanup"));
|
||||||
|
assert.equal(result[0].memory?.status, "superseded");
|
||||||
|
} 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 +316,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;
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
|
||||||
|
import type { LongTermMemoryEntry } from "../src/types.ts";
|
||||||
|
import type { MemoryInspectionReadModel } from "../scripts/memory-diag/types.ts";
|
||||||
|
import { CliInputError, normalizeRejection, rejectionQualitySummary, sinceCutoff } from "../scripts/memory-diag/rejections-model.ts";
|
||||||
|
import { coverageRows, disappearanceRows } from "../scripts/memory-diag/inspection-model.ts";
|
||||||
|
import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts";
|
||||||
|
import { statusFromTraceEvent } from "../scripts/memory-diag/trace-model.ts";
|
||||||
|
|
||||||
|
function entry(id: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
|
||||||
|
const now = new Date("2026-01-01T00:00:00.000Z").toISOString();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
text: `${id} text`,
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
eventId: `evt-${overrides.type}-${Math.random()}`,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
workspaceKey: "workspace-key",
|
||||||
|
workspaceRootHash: "workspace-root-hash",
|
||||||
|
reasonCodes: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function model(entries: LongTermMemoryEntry[], events: EvidenceEventV1[]): MemoryInspectionReadModel {
|
||||||
|
return {
|
||||||
|
store: {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root: "/tmp/workspace", key: "workspace-key" },
|
||||||
|
limits: { maxRenderedChars: 24_000, maxEntries: 28 },
|
||||||
|
entries,
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
},
|
||||||
|
pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() },
|
||||||
|
evidenceEvents: events,
|
||||||
|
rejectionRecords: [],
|
||||||
|
currentById: new Map(entries.map(memory => [memory.id, memory])),
|
||||||
|
evidenceByMemoryId: groupEvidenceByMemoryId(events),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("normalizeRejection infers origins from source", () => {
|
||||||
|
assert.equal(normalizeRejection({ source: "compaction", text: "a", reasons: ["bad_decision"] })?.origin, "compaction_candidate");
|
||||||
|
assert.equal(normalizeRejection({ source: "explicit", text: "a", reasons: ["bad_feedback"] })?.origin, "explicit_trigger");
|
||||||
|
assert.equal(normalizeRejection({ source: "manual", text: "a", reasons: ["bad_feedback"] })?.origin, "manual");
|
||||||
|
assert.equal(normalizeRejection({ source: "unknown-source", text: "a", reasons: ["bad_feedback"] })?.origin, "unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sinceCutoff accepts relative durations and ISO timestamps", () => {
|
||||||
|
const now = new Date("2026-01-15T12:00:00.000Z").getTime();
|
||||||
|
|
||||||
|
assert.equal(sinceCutoff("14d", now), now - 14 * 86_400_000);
|
||||||
|
assert.equal(sinceCutoff("3h", now), now - 3 * 3_600_000);
|
||||||
|
assert.equal(sinceCutoff("30m", now), now - 30 * 60_000);
|
||||||
|
assert.equal(sinceCutoff("2026-01-01T00:00:00.000Z", now), new Date("2026-01-01T00:00:00.000Z").getTime());
|
||||||
|
assert.throws(() => sinceCutoff("forever", now), (error: unknown) => {
|
||||||
|
assert.ok(error instanceof CliInputError);
|
||||||
|
assert.equal((error as Error).message, "Invalid --since value: forever");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejectionQualitySummary keeps architecture-like false-positive grouping", () => {
|
||||||
|
const records = [
|
||||||
|
normalizeRejection({ type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] }),
|
||||||
|
normalizeRejection({ type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] }),
|
||||||
|
normalizeRejection({ type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] }),
|
||||||
|
].filter(record => record !== null);
|
||||||
|
|
||||||
|
const summary = rejectionQualitySummary(records);
|
||||||
|
|
||||||
|
assert.equal(summary.totalRecords, 3);
|
||||||
|
assert.equal(summary.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
|
||||||
|
assert.equal(summary.possibleFalsePositiveGroups.clearly_garbage.count, 1);
|
||||||
|
assert.equal(summary.possibleFalsePositiveGroups.ambiguous.count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coverageRows classifies current and historical memory evidence", () => {
|
||||||
|
const entries = [entry("mem-full", "feedback"), entry("mem-render-only", "decision"), entry("mem-no-evidence", "project")];
|
||||||
|
const events = [
|
||||||
|
event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }),
|
||||||
|
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }),
|
||||||
|
event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-render-only", type: "decision", source: "compaction" } }),
|
||||||
|
event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "historical-cap", type: "project", source: "compaction" }, reasonCodes: ["global_cap"] }),
|
||||||
|
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "reference", source: "compaction" } }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = coverageRows(model(entries, events), true);
|
||||||
|
const byId = new Map(rows.map(row => [row.id, row.class]));
|
||||||
|
|
||||||
|
assert.equal(byId.get("mem-full"), "full_lifecycle");
|
||||||
|
assert.equal(byId.get("mem-render-only"), "render_only");
|
||||||
|
assert.equal(byId.get("mem-no-evidence"), "no_evidence");
|
||||||
|
assert.equal(byId.get("historical-cap"), "historical_absent_with_reason");
|
||||||
|
assert.equal(byId.get("historical-unknown"), "historical_absent_unknown_reason");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disappearanceRows surfaces terminal capacity evidence", () => {
|
||||||
|
const events = [
|
||||||
|
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" } }),
|
||||||
|
event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" }, reasonCodes: ["type_cap"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = disappearanceRows(model([], events));
|
||||||
|
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
assert.equal(rows[0].id, "capacity-loser");
|
||||||
|
assert.equal(rows[0].classification, "historical_absent_with_reason");
|
||||||
|
assert.equal(rows[0].terminalType, "memory_removed_capacity");
|
||||||
|
assert.deepEqual(rows[0].reasonCodes, ["type_cap"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("statusFromTraceEvent maps lifecycle events", () => {
|
||||||
|
assert.equal(statusFromTraceEvent(undefined), "unknown");
|
||||||
|
assert.equal(statusFromTraceEvent(event({ type: "render_selected", phase: "render", outcome: "rendered" })), "rendered");
|
||||||
|
assert.equal(statusFromTraceEvent(event({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["type_cap"] })), "omitted_type_cap");
|
||||||
|
assert.equal(statusFromTraceEvent(event({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed" })), "omitted_absorbed_duplicate");
|
||||||
|
assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_scheduled", phase: "promotion", outcome: "retried" })), "pending_retry");
|
||||||
|
assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_exhausted", phase: "promotion", outcome: "exhausted" })), "pending_rejected_capacity");
|
||||||
|
assert.equal(statusFromTraceEvent(event({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined" })), "quarantined_corrupt_store");
|
||||||
|
assert.equal(statusFromTraceEvent(event({ type: "promotion_superseded", phase: "promotion", outcome: "superseded" })), "omitted_superseded");
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseArgs } from "../scripts/memory-diag/cli.ts";
|
||||||
|
|
||||||
|
test("help returns usage without exiting", () => {
|
||||||
|
const parsed = parseArgs(["--help"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true);
|
||||||
|
assert.equal("help" in parsed && parsed.help, true);
|
||||||
|
assert.match(parsed.usage, /Usage:/);
|
||||||
|
assert.match(parsed.usage, /memory-diag \[status\]/);
|
||||||
|
assert.doesNotMatch(parsed.usage, /health/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status defaults when no subcommand", () => {
|
||||||
|
const parsed = parseArgs([]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true);
|
||||||
|
assert.equal("command" in parsed && parsed.command, "status");
|
||||||
|
assert.deepEqual("options" in parsed && parsed.options, { raw: false, positional: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown command returns usage error", () => {
|
||||||
|
const parsed = parseArgs(["unknown"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, false);
|
||||||
|
if (parsed.ok) return;
|
||||||
|
assert.equal(parsed.message, "Unknown subcommand: unknown");
|
||||||
|
assert.equal(parsed.exitCode, 1);
|
||||||
|
assert.match(parsed.usage, /Usage:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("current command flag validation messages are preserved", () => {
|
||||||
|
const cases: Array<{ args: string[]; message: string }> = [
|
||||||
|
{ args: ["health", "--json", "--all"], message: "health --json does not support --all" },
|
||||||
|
{ args: ["quality", "--all"], message: "quality does not accept --all" },
|
||||||
|
{ args: ["coverage", "--all"], message: "coverage does not accept --all" },
|
||||||
|
{ args: ["disappearances", "--all"], message: "disappearances does not accept --all" },
|
||||||
|
{ args: ["rejections", "--all"], message: "rejections does not accept --all" },
|
||||||
|
{ args: ["audit", "--workspace", "/tmp/workspace"], message: "audit does not accept --all or --workspace" },
|
||||||
|
{ args: ["explain", "--all"], message: "explain does not accept --all" },
|
||||||
|
{ args: ["trace", "--all", "--memory", "mem-1"], message: "trace does not accept --all" },
|
||||||
|
{ args: ["quality", "--since", "forever"], message: "quality does not accept rejection filters" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of cases) {
|
||||||
|
const parsed = parseArgs(item.args);
|
||||||
|
assert.equal(parsed.ok, false, item.args.join(" "));
|
||||||
|
if (parsed.ok) continue;
|
||||||
|
assert.equal(parsed.message, item.message);
|
||||||
|
assert.match(parsed.usage, /Usage:/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trace without memory returns current required id error", () => {
|
||||||
|
const parsed = parseArgs(["trace"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, false);
|
||||||
|
if (parsed.ok) return;
|
||||||
|
assert.equal(parsed.message, "--memory requires an id");
|
||||||
|
assert.match(parsed.usage, /Usage:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("health with all and workspace returns current conflict error", () => {
|
||||||
|
const parsed = parseArgs(["health", "--all", "--workspace", "/tmp/workspace"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, false);
|
||||||
|
if (parsed.ok) return;
|
||||||
|
assert.equal(parsed.message, "Use either --all or --workspace, not both");
|
||||||
|
assert.match(parsed.usage, /Usage:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejections invalid since value returns current error", () => {
|
||||||
|
const parsed = parseArgs(["rejections", "--since", "forever"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, false);
|
||||||
|
if (parsed.ok) return;
|
||||||
|
assert.equal(parsed.message, "Invalid --since value: forever");
|
||||||
|
assert.match(parsed.usage, /Usage:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy health alias emits deprecation notice and maps to status", () => {
|
||||||
|
const parsed = parseArgs(["health"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true);
|
||||||
|
assert.equal("command" in parsed && parsed.command, "status");
|
||||||
|
assert.equal("options" in parsed && parsed.options.legacyCommand, "health");
|
||||||
|
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'health' is now 'status'. This alias will be removed in v2.0.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy quality alias sets verbose and emits deprecation notice", () => {
|
||||||
|
const parsed = parseArgs(["quality"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true);
|
||||||
|
assert.equal("command" in parsed && parsed.command, "status");
|
||||||
|
assert.equal("options" in parsed && parsed.options.verbose, true);
|
||||||
|
assert.equal("options" in parsed && parsed.options.legacyCommand, "quality");
|
||||||
|
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy rejections alias emits deprecation notice", () => {
|
||||||
|
const parsed = parseArgs(["rejections"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true);
|
||||||
|
assert.equal("command" in parsed && parsed.command, "rejected");
|
||||||
|
assert.equal("options" in parsed && parsed.options.legacyCommand, "rejections");
|
||||||
|
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy disappearances alias emits deprecation notice", () => {
|
||||||
|
const parsed = parseArgs(["disappearances"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true);
|
||||||
|
assert.equal("command" in parsed && parsed.command, "missing");
|
||||||
|
assert.equal("options" in parsed && parsed.options.legacyCommand, "disappearances");
|
||||||
|
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy trace alias emits deprecation notice", () => {
|
||||||
|
const parsed = parseArgs(["trace", "--memory", "mem-1"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true);
|
||||||
|
assert.equal("command" in parsed && parsed.command, "explain");
|
||||||
|
assert.equal("options" in parsed && parsed.options.legacyCommand, "trace");
|
||||||
|
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
|
||||||
|
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'trace --memory <id>' is now 'explain <memory-id>'. This alias will be removed in v2.0.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explain accepts positional memory id", () => {
|
||||||
|
const parsed = parseArgs(["explain", "mem-1", "--workspace", "/tmp/workspace"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, true);
|
||||||
|
assert.equal("command" in parsed && parsed.command, "explain");
|
||||||
|
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
|
||||||
|
assert.deepEqual("options" in parsed && parsed.options.positional, ["mem-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explain with both positional and memory flag errors", () => {
|
||||||
|
const parsed = parseArgs(["explain", "mem-1", "--memory", "mem-2"]);
|
||||||
|
|
||||||
|
assert.equal(parsed.ok, false);
|
||||||
|
if (parsed.ok) return;
|
||||||
|
assert.equal(parsed.message, "Use either explain <memory-id> or --memory, not both");
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
|
||||||
|
import type { MemoryInspectionReadModel, WorkspaceDiagSnapshot } from "../scripts/memory-diag/types.ts";
|
||||||
|
import { formatWorkspaceHealth } from "../scripts/memory-diag/formatters/health.ts";
|
||||||
|
import { formatQuality } from "../scripts/memory-diag/formatters/quality.ts";
|
||||||
|
import { formatCoverage } from "../scripts/memory-diag/formatters/coverage.ts";
|
||||||
|
import { formatDisappearances } from "../scripts/memory-diag/formatters/disappearances.ts";
|
||||||
|
import { formatRejectionQuality } from "../scripts/memory-diag/formatters/rejections.ts";
|
||||||
|
import { formatMigrationAudit } from "../scripts/memory-diag/formatters/audit.ts";
|
||||||
|
import { formatExplain } from "../scripts/memory-diag/formatters/explain.ts";
|
||||||
|
import { formatTrace } from "../scripts/memory-diag/formatters/trace.ts";
|
||||||
|
import { rejectionQualitySummary } from "../scripts/memory-diag/rejections-model.ts";
|
||||||
|
|
||||||
|
function emptyInspectionModel(): MemoryInspectionReadModel {
|
||||||
|
return {
|
||||||
|
store: {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root: "/tmp/workspace", key: "workspace-key" },
|
||||||
|
limits: { maxRenderedChars: 24_000, maxEntries: 28 },
|
||||||
|
entries: [],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
},
|
||||||
|
pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() },
|
||||||
|
evidenceEvents: [],
|
||||||
|
rejectionRecords: [],
|
||||||
|
currentById: new Map(),
|
||||||
|
evidenceByMemoryId: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySnapshot(): WorkspaceDiagSnapshot {
|
||||||
|
return {
|
||||||
|
store: emptyInspectionModel().store,
|
||||||
|
journal: emptyInspectionModel().pending,
|
||||||
|
retention: { sorted: [], rendered: [], typeCapped: [], globalCapped: [] },
|
||||||
|
memories: [],
|
||||||
|
recentEvents: [],
|
||||||
|
allEvents: [],
|
||||||
|
summary: {
|
||||||
|
storedActive: 0,
|
||||||
|
rendered: 0,
|
||||||
|
pending: 0,
|
||||||
|
rejectedLast7Days: 0,
|
||||||
|
corruptStoresQuarantinedLast30Days: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
eventId: `evt-${overrides.type}`,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
workspaceKey: "workspace-key",
|
||||||
|
workspaceRootHash: "workspace-root-hash",
|
||||||
|
reasonCodes: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("health formatter includes existing retention cap label", () => {
|
||||||
|
const output = formatWorkspaceHealth({
|
||||||
|
root: "/tmp/workspace",
|
||||||
|
key: "workspace-key",
|
||||||
|
memoryPath: "/tmp/workspace-memory.json",
|
||||||
|
pendingPath: "/tmp/workspace-pending-journal.json",
|
||||||
|
raw: false,
|
||||||
|
now: new Date("2026-01-01T00:00:00.000Z").getTime(),
|
||||||
|
includeTitle: true,
|
||||||
|
}, { rawStore: null, rawJournal: null, pendingExists: false });
|
||||||
|
|
||||||
|
assert.match(output, /Workspace memory health/);
|
||||||
|
assert.match(output, /Retention caps:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality formatter includes caps and retention clock sections", () => {
|
||||||
|
const output = formatQuality(emptyInspectionModel(), new Date("2026-01-01T00:00:00.000Z").getTime());
|
||||||
|
|
||||||
|
assert.match(output, /Caps:/);
|
||||||
|
assert.match(output, /Retention clocks:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejection quality formatter includes reason distribution sections", () => {
|
||||||
|
const summary = rejectionQualitySummary([]);
|
||||||
|
const output = formatRejectionQuality({ path: "/tmp/rejections.jsonl", invalidLines: 0, summary, raw: false });
|
||||||
|
|
||||||
|
assert.match(output, /Reason distribution \(raw records\):/);
|
||||||
|
assert.match(output, /Reason distribution \(unique text\):/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coverage formatter includes class counts section", () => {
|
||||||
|
const output = formatCoverage([]);
|
||||||
|
|
||||||
|
assert.match(output, /Class counts:/);
|
||||||
|
assert.match(output, /Per-memory rows:\n \(none\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disappearances formatter preserves empty-state label", () => {
|
||||||
|
const output = formatDisappearances([]);
|
||||||
|
|
||||||
|
assert.match(output, /No evidence-only memories found\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trace formatter includes lifecycle section", () => {
|
||||||
|
const output = formatTrace("mem-1", emptySnapshot(), {
|
||||||
|
events: [event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-1", type: "feedback", source: "explicit" } })],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(output, /Lifecycle:/);
|
||||||
|
assert.match(output, /evt-render_selected render_selected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audit formatter preserves no-log output", () => {
|
||||||
|
const output = formatMigrationAudit([], { raw: false });
|
||||||
|
|
||||||
|
assert.match(output, /Migration audit report/);
|
||||||
|
assert.match(output, /No migration logs found\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explain formatter preserves no-memory output", () => {
|
||||||
|
const output = formatExplain(emptySnapshot());
|
||||||
|
|
||||||
|
assert.match(output, /Workspace memory explainability/);
|
||||||
|
assert.match(output, /No memories found\./);
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { canonicalMemoryText, cleanText, countBy, sortedCounts, truncate } from "../scripts/memory-diag/text.ts";
|
||||||
|
import { readJSONLFile } from "../scripts/memory-diag/io.ts";
|
||||||
|
|
||||||
|
test("cleanText redacts credentials and absolute paths unless raw", () => {
|
||||||
|
const text = "Use password: sushi and api_key=abc123 in /Users/alice/project/config.json";
|
||||||
|
|
||||||
|
const cleaned = cleanText(text, false);
|
||||||
|
|
||||||
|
assert.match(cleaned, /password: \[REDACTED\]/);
|
||||||
|
assert.match(cleaned, /api_key=\[REDACTED\]/);
|
||||||
|
assert.match(cleaned, /<path>/);
|
||||||
|
assert.doesNotMatch(cleaned, /sushi/);
|
||||||
|
assert.doesNotMatch(cleaned, /\/Users\/alice/);
|
||||||
|
assert.equal(cleanText(text, true), text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncate collapses whitespace and applies ellipsis at max length", () => {
|
||||||
|
assert.equal(truncate(" hello\n\tworld "), "hello world");
|
||||||
|
assert.equal(truncate("abcdef", 5), "abcd…");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonicalMemoryText normalizes punctuation and case", () => {
|
||||||
|
assert.equal(canonicalMemoryText("Hello, WORLD!!! Path?"), "hello world path");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sortedCounts sorts by count descending then key ascending", () => {
|
||||||
|
const counts = new Map<string, number>([["b", 2], ["a", 2], ["c", 3]]);
|
||||||
|
|
||||||
|
assert.deepEqual(sortedCounts(counts), [["c", 3], ["a", 2], ["b", 2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("countBy counts string items", () => {
|
||||||
|
assert.deepEqual([...countBy(["beta", "alpha", "beta"]).entries()], [["beta", 2], ["alpha", 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readJSONLFile returns valid records and invalid line count", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-utils-"));
|
||||||
|
try {
|
||||||
|
const path = join(root, "records.jsonl");
|
||||||
|
await writeFile(path, '{"id":"one"}\nnot-json\n\n{"id":"two"}\n', "utf8");
|
||||||
|
|
||||||
|
const result = await readJSONLFile<{ id: string }>(path);
|
||||||
|
|
||||||
|
assert.deepEqual(result.records, [{ id: "one" }, { id: "two" }]);
|
||||||
|
assert.equal(result.invalidLines, 1);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { appendEvidenceEvents, queryEvidenceEvents, summarizeMemoryEvidence, type EvidenceEventInput, type EvidenceEventV1 } from "../src/evidence-log.ts";
|
||||||
|
import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts";
|
||||||
|
import { retentionCandidatesForDiag } from "../scripts/memory-diag/retention-model.ts";
|
||||||
|
import { buildMemoryDiagJSON, memoryDiagJSONFromSnapshot, normalizedJournal, normalizedStore, snapshotForOptions } from "../scripts/memory-diag/workspace-snapshot.ts";
|
||||||
|
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||||
|
import { LONG_TERM_LIMITS, type LongTermMemoryEntry, type PendingMemoryJournalStore, type WorkspaceMemoryStore } from "../src/types.ts";
|
||||||
|
|
||||||
|
function entry(id: string, text: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
|
||||||
|
const now = new Date("2026-01-01T00:00:00.000Z").toISOString();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
retentionClock: new Date(now).getTime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
|
||||||
|
return {
|
||||||
|
type: "promotion_promoted",
|
||||||
|
phase: "promotion",
|
||||||
|
outcome: "promoted",
|
||||||
|
memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" },
|
||||||
|
reasonCodes: ["new_workspace_entry"],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupedEvidenceSummary(grouped: Map<string, EvidenceEventV1[]>, memoryId: string): { eventIds: string[]; reasonCodes: string[] } {
|
||||||
|
const events = grouped.get(memoryId) ?? [];
|
||||||
|
const reasonCodes = new Set<string>();
|
||||||
|
for (const event of events) {
|
||||||
|
for (const reason of event.reasonCodes) reasonCodes.add(reason);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
eventIds: events.map(event => event.eventId),
|
||||||
|
reasonCodes: [...reasonCodes],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
const path = await workspaceMemoryPath(root);
|
||||||
|
const store: WorkspaceMemoryStore = {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries,
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
const path = await workspacePendingJournalPath(root);
|
||||||
|
const store: PendingMemoryJournalStore = {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key },
|
||||||
|
entries,
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("normalizedStore returns an empty store with limits and empty arrays", () => {
|
||||||
|
const store = normalizedStore(null, "/tmp/example-workspace", "workspace-key");
|
||||||
|
|
||||||
|
assert.equal(store.version, 1);
|
||||||
|
assert.deepEqual(store.workspace, { root: "/tmp/example-workspace", key: "workspace-key" });
|
||||||
|
assert.deepEqual(store.limits, { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries });
|
||||||
|
assert.deepEqual(store.entries, []);
|
||||||
|
assert.deepEqual(store.migrations, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizedJournal returns an empty journal", () => {
|
||||||
|
const journal = normalizedJournal(null);
|
||||||
|
|
||||||
|
assert.equal(journal.version, 1);
|
||||||
|
assert.deepEqual(journal.workspace, { root: "", key: "" });
|
||||||
|
assert.deepEqual(journal.entries, []);
|
||||||
|
assert.equal(journal.updatedAt, new Date(0).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retentionCandidatesForDiag separates rendered, type-capped, and global-capped entries", () => {
|
||||||
|
const entries: LongTermMemoryEntry[] = [
|
||||||
|
...Array.from({ length: 11 }, (_, i) => entry(`feedback-${String(i).padStart(2, "0")}`, `Feedback memory ${i}`, "feedback")),
|
||||||
|
...Array.from({ length: 10 }, (_, i) => entry(`decision-${String(i).padStart(2, "0")}`, `Decision memory ${i}`, "decision")),
|
||||||
|
...Array.from({ length: 8 }, (_, i) => entry(`project-${String(i).padStart(2, "0")}`, `Project memory ${i}`, "project")),
|
||||||
|
...Array.from({ length: 6 }, (_, i) => entry(`reference-${String(i).padStart(2, "0")}`, `Reference memory ${i}`, "reference")),
|
||||||
|
];
|
||||||
|
const store = normalizedStore({ entries, workspace: { root: "/tmp/root", key: "key" } } as WorkspaceMemoryStore, "/tmp/root", "key");
|
||||||
|
|
||||||
|
const candidates = retentionCandidatesForDiag(store, new Date("2026-01-02T00:00:00.000Z").getTime());
|
||||||
|
|
||||||
|
assert.equal(candidates.rendered.length, LONG_TERM_LIMITS.maxEntries);
|
||||||
|
assert.equal(candidates.typeCapped.length, 1);
|
||||||
|
assert.equal(candidates.globalCapped.length, 6);
|
||||||
|
assert.equal(candidates.typeCapped[0].entry.type, "feedback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildMemoryDiagJSON redacts previews, includes pending entries, and preserves summary fields", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-workspace-model-"));
|
||||||
|
try {
|
||||||
|
const active = entry("mem-active", "Remember password: sushi and file /Users/alice/private.txt", "decision");
|
||||||
|
const pending = { ...entry("mem-pending", "Pending api_key=secret-value", "project"), promotionAttempts: 1 };
|
||||||
|
await writeWorkspaceStore(root, [active]);
|
||||||
|
await writePendingJournal(root, [pending]);
|
||||||
|
|
||||||
|
const diag = await buildMemoryDiagJSON(root);
|
||||||
|
|
||||||
|
assert.equal(diag.version, 1);
|
||||||
|
assert.equal(diag.summary.storedActive, 1);
|
||||||
|
assert.equal(diag.summary.rendered, 1);
|
||||||
|
assert.equal(diag.summary.pending, 1);
|
||||||
|
assert.equal(diag.summary.rejectedLast7Days, 0);
|
||||||
|
assert.equal(diag.summary.corruptStoresQuarantinedLast30Days, 0);
|
||||||
|
assert.equal(diag.memories.length, 2);
|
||||||
|
assert.equal(diag.memories.find(memory => memory.id === "mem-pending")?.status, "pending_retry");
|
||||||
|
assert.ok(diag.memories.some(memory => memory.textPreview?.includes("[REDACTED]")));
|
||||||
|
assert.ok(diag.memories.some(memory => memory.textPreview?.includes("<path>")));
|
||||||
|
assert.ok(!JSON.stringify(diag).includes("sushi"));
|
||||||
|
assert.ok(!JSON.stringify(diag).includes("secret-value"));
|
||||||
|
assert.equal(diag.workspace.key, await workspaceKey(root));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memoryDiagJSONFromSnapshot serializes an existing snapshot with fixed generatedAt", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-snapshot-json-"));
|
||||||
|
try {
|
||||||
|
const active = entry("mem-active", "Stable decision memory", "decision");
|
||||||
|
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
|
||||||
|
await writeWorkspaceStore(root, [active]);
|
||||||
|
await writePendingJournal(root, [pending]);
|
||||||
|
|
||||||
|
const snapshot = await snapshotForOptions({ raw: false, workspace: root });
|
||||||
|
const generatedAt = "2026-05-02T00:00:00.000Z";
|
||||||
|
const diag = memoryDiagJSONFromSnapshot(root, snapshot, generatedAt);
|
||||||
|
|
||||||
|
assert.equal(diag.version, 1);
|
||||||
|
assert.equal(diag.generatedAt, generatedAt);
|
||||||
|
assert.equal(diag.memories, snapshot.memories);
|
||||||
|
assert.equal(diag.recentEvents, snapshot.recentEvents);
|
||||||
|
assert.equal(diag.summary, snapshot.summary);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grouped evidence summaries match per-memory summaries for stored pending and absorbed memories", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-equivalence-"));
|
||||||
|
try {
|
||||||
|
const active = entry("mem-active", "Stable decision memory", "decision");
|
||||||
|
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
|
||||||
|
await writeWorkspaceStore(root, [active]);
|
||||||
|
await writePendingJournal(root, [pending]);
|
||||||
|
await appendEvidenceEvents(root, [
|
||||||
|
evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }),
|
||||||
|
evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }),
|
||||||
|
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const grouped = groupEvidenceByMemoryId(await queryEvidenceEvents(root));
|
||||||
|
for (const id of ["mem-active", "mem-pending", "mem-absorbed"]) {
|
||||||
|
const oldSummary = await summarizeMemoryEvidence(root, { memoryId: id });
|
||||||
|
const groupedSummary = groupedEvidenceSummary(grouped, id);
|
||||||
|
|
||||||
|
assert.deepEqual(groupedSummary.eventIds, oldSummary.eventIds);
|
||||||
|
assert.deepEqual(groupedSummary.reasonCodes, oldSummary.reasonCodes);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildMemoryDiagJSON preserves evidence ids and reason codes for stored pending and absorbed memories", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-rows-"));
|
||||||
|
try {
|
||||||
|
const active = entry("mem-active", "Stable decision memory", "decision");
|
||||||
|
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
|
||||||
|
await writeWorkspaceStore(root, [active]);
|
||||||
|
await writePendingJournal(root, [pending]);
|
||||||
|
const events = await appendEvidenceEvents(root, [
|
||||||
|
evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }),
|
||||||
|
evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }),
|
||||||
|
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const diag = await buildMemoryDiagJSON(root);
|
||||||
|
const activeRow = diag.memories.find(memory => memory.id === "mem-active");
|
||||||
|
const pendingRow = diag.memories.find(memory => memory.id === "mem-pending");
|
||||||
|
const absorbedRow = diag.memories.find(memory => memory.id === "mem-absorbed");
|
||||||
|
|
||||||
|
assert.ok(activeRow);
|
||||||
|
assert.ok(pendingRow);
|
||||||
|
assert.ok(absorbedRow);
|
||||||
|
assert.deepEqual(activeRow.evidenceEventIds, [events[0].eventId]);
|
||||||
|
assert.ok(activeRow.reasonCodes.includes("stored_reason"));
|
||||||
|
assert.deepEqual(pendingRow.evidenceEventIds, [events[1].eventId]);
|
||||||
|
assert.ok(pendingRow.reasonCodes.includes("pending_reason"));
|
||||||
|
assert.deepEqual(absorbedRow.evidenceEventIds, [events[2].eventId]);
|
||||||
|
assert.ok(absorbedRow.reasonCodes.includes("same_exact_key"));
|
||||||
|
assert.ok(absorbedRow.reasonCodes.includes("absorbed_duplicate"));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
+569
-4
@@ -1,6 +1,7 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
@@ -9,7 +10,7 @@ import { promisify } from "node:util";
|
|||||||
import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts";
|
import { 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, workspaceEvidenceLogPath, 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)), "..");
|
||||||
@@ -51,13 +52,18 @@ async function runMemoryDiagHealth(root: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runMemoryDiag(args: string[]): Promise<string> {
|
async function runMemoryDiag(args: string[]): Promise<string> {
|
||||||
const { stdout } = await execFileAsync(process.execPath, [
|
const { stdout } = await runMemoryDiagResult(args);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMemoryDiagResult(args: string[], options: { cwd?: string } = {}): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const { stdout, stderr } = await execFileAsync(process.execPath, [
|
||||||
"--experimental-strip-types",
|
"--experimental-strip-types",
|
||||||
"scripts/memory-diag.ts",
|
"scripts/memory-diag.ts",
|
||||||
...args,
|
...args,
|
||||||
], { cwd: repoRoot });
|
], { cwd: options.cwd ?? repoRoot });
|
||||||
|
|
||||||
return stdout;
|
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
|
async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
|
||||||
@@ -73,6 +79,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",
|
||||||
@@ -84,6 +96,120 @@ function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("health handles missing workspace store as empty", async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-"));
|
||||||
|
try {
|
||||||
|
const stdout = await runMemoryDiag(["health", "--workspace", root]);
|
||||||
|
|
||||||
|
assert.match(stdout, /memory store: missing or unreadable \(treated as empty\)/);
|
||||||
|
assert.match(stdout, /pending journal: missing \(treated as empty\)/);
|
||||||
|
assert.match(stdout, /Stored active memories: 0/);
|
||||||
|
assert.match(stdout, /Pending journal:\n\s+total: 0/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality handles missing workspace store as empty", async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-quality-"));
|
||||||
|
try {
|
||||||
|
const stdout = await runMemoryDiag(["quality", "--workspace", root]);
|
||||||
|
|
||||||
|
assert.match(stdout, /Memory quality inspection/);
|
||||||
|
assert.match(stdout, /0 active memories/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coverage and disappearances handle missing workspace store as empty", async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-inspection-"));
|
||||||
|
try {
|
||||||
|
const coverageStdout = await runMemoryDiag(["coverage", "--workspace", root]);
|
||||||
|
assert.match(coverageStdout, /no_evidence: 0/);
|
||||||
|
assert.match(coverageStdout, /Per-memory rows:\n\s+\(none\)/);
|
||||||
|
|
||||||
|
const missingStdout = await runMemoryDiag(["missing", "--workspace", root]);
|
||||||
|
assert.match(missingStdout, /No missing memories found\./);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("health with conflicting flags shows usage error", async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-conflicting-flags-"));
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
runMemoryDiagResult(["health", "--all", "--workspace", root]),
|
||||||
|
(error: unknown) => {
|
||||||
|
const err = error as { code?: number; stderr?: string };
|
||||||
|
assert.notEqual(err.code, 0);
|
||||||
|
assert.match(err.stderr ?? "", /Use either --all or --workspace, not both/);
|
||||||
|
assert.match(err.stderr ?? "", /Usage:/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memory-diag reports unexpected command errors without stack traces", async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-error-boundary-"));
|
||||||
|
try {
|
||||||
|
await writeWorkspaceStore(root, []);
|
||||||
|
// Invalid rejection filter values are parse-caught before dispatch, so this
|
||||||
|
// fixture exercises the same top-level boundary with a command-thrown Error.
|
||||||
|
await mkdir(await workspaceEvidenceLogPath(root), { recursive: true });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
runMemoryDiagResult(["status", "--workspace", root]),
|
||||||
|
(error: unknown) => {
|
||||||
|
const err = error as { code?: number; stderr?: string };
|
||||||
|
assert.notEqual(err.code, 0);
|
||||||
|
assert.match(err.stderr ?? "", /memory-diag failed:/);
|
||||||
|
assert.doesNotMatch(err.stderr ?? "", /\n\s+at\s|Error:/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memory-diag defaults to status when no subcommand is supplied", async () => {
|
||||||
|
const result = await runMemoryDiagResult([]);
|
||||||
|
|
||||||
|
assert.match(result.stdout, /Memory status/);
|
||||||
|
assert.match(result.stdout, /Key metrics:/);
|
||||||
|
assert.equal(result.stderr, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy health alias emits deprecation notice and still runs", async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-"));
|
||||||
|
try {
|
||||||
|
const result = await runMemoryDiagResult(["health", "--workspace", root]);
|
||||||
|
|
||||||
|
assert.match(result.stdout, /Workspace memory health/);
|
||||||
|
assert.match(result.stderr, /Note: 'health' is now 'status'\. This alias will be removed in v2\.0\./);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy trace alias emits deprecation notice and still traces memory", async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-trace-"));
|
||||||
|
try {
|
||||||
|
const result = await runMemoryDiagResult(["trace", "--memory", "test-id", "--workspace", root]);
|
||||||
|
|
||||||
|
assert.match(result.stdout, /Memory test-id: unknown/);
|
||||||
|
assert.match(result.stdout, /Lifecycle:/);
|
||||||
|
assert.match(result.stderr, /Note: 'trace --memory <id>' is now 'explain <memory-id>'\. This alias will be removed in v2\.0\./);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("memory health reports stored vs rendered retention counts", async () => {
|
test("memory health reports stored vs rendered retention counts", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
|
||||||
try {
|
try {
|
||||||
@@ -289,6 +415,30 @@ test("memory-diag trace prints lifecycle relations and redacts secrets", async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("memory-diag explain positional memory id prints lifecycle", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-explain-positional-"));
|
||||||
|
try {
|
||||||
|
await writeWorkspaceStore(root, [
|
||||||
|
{ ...entry("mem-life", "Old token password: sushi should be redacted", "decision"), status: "superseded" as const },
|
||||||
|
entry("mem-new", "Replacement memory", "decision"),
|
||||||
|
]);
|
||||||
|
await appendEvidenceEvents(root, [
|
||||||
|
evidence({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, reasonCodes: ["quality_gate_passed"], textPreview: "password: sushi" }),
|
||||||
|
evidence({ type: "promotion_superseded", phase: "promotion", outcome: "superseded", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "superseded_by", memory: { memoryId: "mem-new" } }], reasonCodes: ["superseded_existing"] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stdout = await runMemoryDiag(["explain", "mem-life", "--workspace", root]);
|
||||||
|
|
||||||
|
assert.match(stdout, /Memory mem-life: omitted_superseded/);
|
||||||
|
assert.match(stdout, /Lifecycle:/);
|
||||||
|
assert.match(stdout, /promotion_superseded: superseded; reasons=superseded_existing; .*superseded_by=mem-new/);
|
||||||
|
assert.match(stdout, /Superseded by:\n- mem-new/);
|
||||||
|
assert.ok(!stdout.includes("sushi"));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("memory-diag trace requires --memory and reports unknown IDs", async () => {
|
test("memory-diag trace requires --memory and reports unknown IDs", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-"));
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-"));
|
||||||
try {
|
try {
|
||||||
@@ -310,3 +460,418 @@ 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("status default output is concise and actionable", async () => {
|
||||||
|
const root = await setupQualityFixture();
|
||||||
|
try {
|
||||||
|
const stdout = await runMemoryDiag(["status", "--workspace", root]);
|
||||||
|
|
||||||
|
assert.match(stdout, /OK Memory status|WARNING Memory status|DEGRADED Memory status/);
|
||||||
|
assert.match(stdout, /Key metrics:/);
|
||||||
|
assert.match(stdout, /active memories:/);
|
||||||
|
assert.match(stdout, /rendered:/);
|
||||||
|
assert.match(stdout, /pending:/);
|
||||||
|
assert.match(stdout, /rejected 7d:/);
|
||||||
|
assert.match(stdout, /evidence coverage:/);
|
||||||
|
assert.match(stdout, /Needs attention:/);
|
||||||
|
assert.match(stdout, /Suggested next steps:/);
|
||||||
|
assert.doesNotMatch(stdout, /Caps:|Retention clocks:|Rejection scoping:|Dormancy:/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status --verbose includes detailed diagnostics", async () => {
|
||||||
|
const root = await setupQualityFixture();
|
||||||
|
try {
|
||||||
|
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
|
||||||
|
|
||||||
|
assert.match(stdout, /Memory status inspection/);
|
||||||
|
assert.match(stdout, /Caps:/);
|
||||||
|
assert.match(stdout, /Retention clocks:/);
|
||||||
|
assert.match(stdout, /Evidence:/);
|
||||||
|
assert.match(stdout, /Rejection scoping:/);
|
||||||
|
assert.match(stdout, /Dormancy:/);
|
||||||
|
assert.match(stdout, /Top rendered candidates:/);
|
||||||
|
assert.match(stdout, /Weakest active memories:/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status --json includes additive summary fields", async () => {
|
||||||
|
const root = await setupQualityFixture();
|
||||||
|
try {
|
||||||
|
const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]);
|
||||||
|
const parsed = JSON.parse(stdout) as {
|
||||||
|
version: 1;
|
||||||
|
summary: {
|
||||||
|
storedActive: number;
|
||||||
|
rendered: number;
|
||||||
|
pending: number;
|
||||||
|
rejectedLast7Days: number;
|
||||||
|
corruptStoresQuarantinedLast30Days: number;
|
||||||
|
status?: string;
|
||||||
|
evidenceCoveragePercent?: number;
|
||||||
|
needsAttention?: string[];
|
||||||
|
suggestedNextSteps?: string[];
|
||||||
|
};
|
||||||
|
memories: unknown[];
|
||||||
|
recentEvents: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(parsed.version, 1);
|
||||||
|
assert.equal(typeof parsed.summary.storedActive, "number");
|
||||||
|
assert.equal(typeof parsed.summary.rendered, "number");
|
||||||
|
assert.equal(typeof parsed.summary.pending, "number");
|
||||||
|
assert.equal(typeof parsed.summary.rejectedLast7Days, "number");
|
||||||
|
assert.equal(typeof parsed.summary.corruptStoresQuarantinedLast30Days, "number");
|
||||||
|
assert.match(parsed.summary.status ?? "", /ok|warning|degraded/);
|
||||||
|
assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number");
|
||||||
|
assert.ok(Array.isArray(parsed.summary.needsAttention));
|
||||||
|
assert.ok(Array.isArray(parsed.summary.suggestedNextSteps));
|
||||||
|
assert.ok(Array.isArray(parsed.memories));
|
||||||
|
assert.ok(Array.isArray(parsed.recentEvents));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status non-tty and --no-emoji use text labels", async () => {
|
||||||
|
const root = await setupQualityFixture();
|
||||||
|
try {
|
||||||
|
const stdout = await runMemoryDiag(["status", "--workspace", root, "--no-emoji"]);
|
||||||
|
|
||||||
|
assert.match(stdout, /^DEGRADED Memory status/);
|
||||||
|
assert.doesNotMatch(stdout, /🧠|⚠️|✖️/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coverage human output includes class counts and per-memory rows", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-coverage-"));
|
||||||
|
try {
|
||||||
|
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("missing default output summarizes unknown disappearances", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-"));
|
||||||
|
try {
|
||||||
|
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
||||||
|
await appendEvidenceEvents(root, [
|
||||||
|
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stdout = await runMemoryDiag(["missing", "--workspace", root]);
|
||||||
|
|
||||||
|
assert.match(stdout, /Missing memory summary/);
|
||||||
|
assert.match(stdout, /Total missing: 1/);
|
||||||
|
assert.match(stdout, /Explained: 0/);
|
||||||
|
assert.match(stdout, /Needs review: 1/);
|
||||||
|
assert.match(stdout, /Unknown disappearance samples:/);
|
||||||
|
assert.match(stdout, /- historical-unknown terminal=unknown reasons=none/);
|
||||||
|
assert.doesNotMatch(stdout, /Memory historical-unknown:/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing --verbose shows capacity details and render omitted type-cap observations", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-explain-"));
|
||||||
|
try {
|
||||||
|
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
||||||
|
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(["missing", "--workspace", root, "--verbose"]);
|
||||||
|
|
||||||
|
assert.match(stdout, /Missing memory summary/);
|
||||||
|
assert.match(stdout, /capacity-loser: historical_absent_with_reason terminal=memory_removed_capacity reasons=type_cap/);
|
||||||
|
assert.match(stdout, /events:/);
|
||||||
|
assert.match(stdout, /memory_removed_capacity details: .*globalCap=28 .*typeCap=10/);
|
||||||
|
assert.match(stdout, /render-loser: historical_absent_with_reason terminal=render_omitted reasons=type_cap/);
|
||||||
|
assert.match(stdout, /render_omitted type-cap observation: reasons=type_cap/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing --json includes disappearances and additive summary", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-missing-json-"));
|
||||||
|
try {
|
||||||
|
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
||||||
|
await appendEvidenceEvents(root, [
|
||||||
|
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stdout = await runMemoryDiag(["missing", "--workspace", root, "--json"]);
|
||||||
|
const parsed = JSON.parse(stdout) as { disappearances: Array<{ id: string }>; summary: { total: number; explained: number; needsReview: number } };
|
||||||
|
|
||||||
|
assert.equal(parsed.summary.total, 1);
|
||||||
|
assert.equal(parsed.summary.explained, 0);
|
||||||
|
assert.equal(parsed.summary.needsReview, 1);
|
||||||
|
assert.equal(parsed.disappearances[0]?.id, "historical-unknown");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy disappearances --explain emits deprecation notice and detailed output", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-legacy-"));
|
||||||
|
try {
|
||||||
|
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
||||||
|
await appendEvidenceEvents(root, [
|
||||||
|
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await runMemoryDiagResult(["disappearances", "--workspace", root, "--explain"]);
|
||||||
|
|
||||||
|
assert.match(result.stderr, /Note: 'disappearances' is now 'missing'\. This alias will be removed in v2\.0\./);
|
||||||
|
assert.match(result.stdout, /^Memory disappearances/);
|
||||||
|
assert.match(result.stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/);
|
||||||
|
assert.match(result.stdout, /events:/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejected default output is concise with top reasons and samples", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-"));
|
||||||
|
try {
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await writeRejectionRecords([
|
||||||
|
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Keep memory system boundary stable", reasons: ["bad_decision"] },
|
||||||
|
{ timestamp: now, workspaceKey: key, type: "feedback", source: "explicit", text: "Remember password: sushi from rejected sample", reasons: ["raw_secret", "bad_feedback"] },
|
||||||
|
{ timestamp: "2020-01-01T00:00:00.000Z", workspaceKey: key, type: "project", source: "manual", text: "Old rejected sample", reasons: ["bad_project"] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stdout = await runMemoryDiag(["rejected", "--workspace", root]);
|
||||||
|
|
||||||
|
assert.match(stdout, /Rejected memory summary/);
|
||||||
|
assert.match(stdout, /Total rejected: 3/);
|
||||||
|
assert.match(stdout, /Unique texts: 3/);
|
||||||
|
assert.match(stdout, /Top reasons:/);
|
||||||
|
assert.match(stdout, /bad_decision\s+1/);
|
||||||
|
assert.match(stdout, /False-positive risk: low/);
|
||||||
|
assert.match(stdout, /Recent samples:/);
|
||||||
|
assert.match(stdout, /\[decision\] Keep memory system boundary stable/);
|
||||||
|
assert.ok(!stdout.includes("sushi"));
|
||||||
|
assert.doesNotMatch(stdout, /By origin:|Reason distribution \(raw records\):|Possible false-positive groups/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejected --verbose includes detailed distributions", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-verbose-"));
|
||||||
|
try {
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await writeRejectionRecords([
|
||||||
|
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
|
||||||
|
{ timestamp: now, workspaceKey: key, type: "feedback", source: "explicit", text: "Status update completed", reasons: ["bad_feedback"] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stdout = await runMemoryDiag(["rejected", "--workspace", root, "--verbose"]);
|
||||||
|
|
||||||
|
assert.match(stdout, /Rejected memory summary/);
|
||||||
|
assert.match(stdout, /Reason distribution \(raw records\):/);
|
||||||
|
assert.match(stdout, /Reason distribution \(unique text\):/);
|
||||||
|
assert.match(stdout, /By origin:/);
|
||||||
|
assert.match(stdout, /Possible false-positive groups \(heuristic, not deterministic\):/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejected --json includes quality summary and false-positive risk", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-json-"));
|
||||||
|
try {
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await writeRejectionRecords([
|
||||||
|
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
|
||||||
|
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stdout = await runMemoryDiag(["rejected", "--workspace", root, "--json"]);
|
||||||
|
const parsed = JSON.parse(stdout) as { totalRecords: number; uniqueTexts: number; falsePositiveRisk: string; possibleFalsePositiveGroups: Record<string, { count: number }> };
|
||||||
|
|
||||||
|
assert.equal(parsed.totalRecords, 2);
|
||||||
|
assert.equal(parsed.uniqueTexts, 1);
|
||||||
|
assert.equal(parsed.falsePositiveRisk, "high");
|
||||||
|
assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejections --quality --reason bad_decision --unique groups architecture-like samples heuristically", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-quality-"));
|
||||||
|
try {
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await writeRejectionRecords([
|
||||||
|
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] },
|
||||||
|
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] },
|
||||||
|
{ timestamp: now, type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--reason", "bad_decision", "--unique"]);
|
||||||
|
|
||||||
|
assert.match(stdout, /Extraction rejection quality inspection/);
|
||||||
|
assert.match(stdout, /Possible false-positive grouping is heuristic, not deterministic truth/);
|
||||||
|
assert.match(stdout, /architecture_like_possible_false_positive: 1/);
|
||||||
|
assert.match(stdout, /clearly_garbage: 1/);
|
||||||
|
assert.match(stdout, /ambiguous: 1/);
|
||||||
|
assert.doesNotMatch(stdout, /deterministic truth\s*:/);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejections --quality --json includes scoping, unique reasons, and possible false-positive groups", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-json-"));
|
||||||
|
try {
|
||||||
|
const key = await workspaceKey(root);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await writeRejectionRecords([
|
||||||
|
{ timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
|
||||||
|
{ timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--json"]);
|
||||||
|
const parsed = JSON.parse(stdout) as {
|
||||||
|
workspaceScopedCount: number;
|
||||||
|
legacyUnscopedCount: number;
|
||||||
|
falsePositiveRisk: string;
|
||||||
|
uniqueReasonDistribution: Record<string, number>;
|
||||||
|
possibleFalsePositiveGroups: Record<string, { count: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(parsed.workspaceScopedCount, 1);
|
||||||
|
assert.equal(parsed.legacyUnscopedCount, 1);
|
||||||
|
assert.equal(parsed.falsePositiveRisk, "high");
|
||||||
|
assert.equal(parsed.uniqueReasonDistribution.bad_decision, 1);
|
||||||
|
assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -339,6 +339,69 @@ test("compaction prompt forbids progress and session-internal memory candidates"
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("compaction prompt includes existing-memory wording reuse guidance", async () => {
|
||||||
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
|
||||||
|
try {
|
||||||
|
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||||
|
const output = { prompt: "", context: [] as string[] };
|
||||||
|
|
||||||
|
await (plugin as Record<string, Function>)["experimental.session.compacting"](
|
||||||
|
{ sessionID: "prompt-wording-reuse-session", model: {} },
|
||||||
|
output,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(output.prompt, /Existing workspace memory may already contain durable facts/);
|
||||||
|
assert.match(output.prompt, /do not create a rephrased duplicate/);
|
||||||
|
assert.match(output.prompt, /reuse the existing memory wording exactly whenever possible/);
|
||||||
|
assert.match(output.prompt, /new, materially corrected, or materially more specific/);
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compaction prompt does not introduce CRUD memory directives", async () => {
|
||||||
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
|
||||||
|
try {
|
||||||
|
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||||
|
const output = { prompt: "", context: [] as string[] };
|
||||||
|
|
||||||
|
await (plugin as Record<string, Function>)["experimental.session.compacting"](
|
||||||
|
{ sessionID: "prompt-no-crud-session", model: {} },
|
||||||
|
output,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.doesNotMatch(output.prompt, /\bREPLACE\b/);
|
||||||
|
assert.doesNotMatch(output.prompt, /\bDROP\b/);
|
||||||
|
assert.doesNotMatch(output.prompt, /\bREINFORCE\s+\[M/);
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compaction prompt preserves Memory candidates output format", async () => {
|
||||||
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
|
||||||
|
try {
|
||||||
|
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||||
|
const output = { prompt: "", context: [] as string[] };
|
||||||
|
|
||||||
|
await (plugin as Record<string, Function>)["experimental.session.compacting"](
|
||||||
|
{ sessionID: "prompt-format-session", model: {} },
|
||||||
|
output,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
output.prompt,
|
||||||
|
/Format when there ARE durable memories:\nMemory candidates:\n- \[feedback\|decision\|project\|reference\] future-facing durable fact/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
output.prompt,
|
||||||
|
/Format when there are NO durable memories:\nMemory candidates:\n\(none\)/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("compaction hook merges existing output.context from other plugins", async () => {
|
test("compaction hook merges existing output.context from other plugins", async () => {
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
|||||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
||||||
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
|
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
|
||||||
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
|
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
|
||||||
|
import { queryEvidenceEvents } from "../src/evidence-log.ts";
|
||||||
import {
|
import {
|
||||||
renderWorkspaceMemory,
|
renderWorkspaceMemory,
|
||||||
accountWorkspaceMemoryRender,
|
accountWorkspaceMemoryRender,
|
||||||
@@ -562,6 +563,23 @@ test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates",
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("dedupeLongTermEntriesWithAccounting decision same-identity variants absorb exact only", () => {
|
||||||
|
const retained = entry("decision-a", "Use pnpm for package management!!!", "decision");
|
||||||
|
const duplicate = entry("decision-b", "use pnpm for package management", "decision");
|
||||||
|
|
||||||
|
assert.notEqual(retained.text, duplicate.text);
|
||||||
|
assert.equal(workspaceMemoryIdentityKey(retained), workspaceMemoryIdentityKey(duplicate));
|
||||||
|
assert.equal(workspaceMemoryExactKey(retained), workspaceMemoryExactKey(duplicate));
|
||||||
|
|
||||||
|
const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]);
|
||||||
|
|
||||||
|
assert.equal(result.kept.length, 1);
|
||||||
|
assert.equal(result.absorbed.length, 1);
|
||||||
|
assert.equal(result.absorbed[0].reason, "absorbed_exact");
|
||||||
|
assert.equal(result.superseded.length, 0);
|
||||||
|
assert.equal(result.absorbed.some(event => event.reason === "superseded_existing"), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => {
|
test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const retained: LongTermMemoryEntry = {
|
const retained: LongTermMemoryEntry = {
|
||||||
@@ -999,6 +1017,16 @@ test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () =
|
|||||||
assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading");
|
assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("workspaceMemoryIdentityKey returns exact key for feedback entries", () => {
|
||||||
|
const feedback = entry(
|
||||||
|
"feedback-exact-identity",
|
||||||
|
"User prefers references to mention `.opencode/opencode.json` explicitly.",
|
||||||
|
"feedback",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(workspaceMemoryIdentityKey(feedback), workspaceMemoryExactKey(feedback));
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => {
|
test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => {
|
||||||
const root = "/repo";
|
const root = "/repo";
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -1029,6 +1057,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();
|
||||||
@@ -1115,6 +1280,50 @@ test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("updateWorkspaceMemoryWithAccounting includes migration evidence from pre-update normalization", async () => {
|
||||||
|
const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-migration-update-"));
|
||||||
|
const dataHome = join(sandbox, "xdg-data-home");
|
||||||
|
const root = join(sandbox, "workspace");
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
const now = "2026-04-26T00:00:00.000Z";
|
||||||
|
const storePath = await workspaceMemoryPath(root);
|
||||||
|
await mkdir(dirname(storePath), { recursive: true });
|
||||||
|
await writeFile(storePath, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [{
|
||||||
|
id: "update_p0_progress",
|
||||||
|
type: "project",
|
||||||
|
text: "Phase 1-4 completed successfully",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}],
|
||||||
|
migrations: ["2026-04-28-quality-cleanup"],
|
||||||
|
updatedAt: now,
|
||||||
|
}, null, 2), "utf8");
|
||||||
|
|
||||||
|
const result = await updateWorkspaceMemoryWithAccounting(root, store => store);
|
||||||
|
const migrationEvidence = result.evidence.filter(event => event.type === "memory_migration_superseded");
|
||||||
|
|
||||||
|
assert.equal(result.store.entries.find(memory => memory.id === "update_p0_progress")?.status, "superseded");
|
||||||
|
assert.equal(migrationEvidence.length, 1);
|
||||||
|
assert.deepEqual(migrationEvidence[0].reasonCodes, ["migration:p0_cleanup"]);
|
||||||
|
assert.equal(migrationEvidence[0].memory?.memoryId, "update_p0_progress");
|
||||||
|
} finally {
|
||||||
|
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||||
|
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
await rm(sandbox, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// P0d: identity-key dedup, supersession, staleness
|
// P0d: identity-key dedup, supersession, staleness
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -1467,7 +1676,7 @@ test("redactCredentials is idempotent and also redacts rationale text", () => {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
now,
|
now,
|
||||||
);
|
).store;
|
||||||
assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]");
|
assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]");
|
||||||
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
|
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
|
||||||
});
|
});
|
||||||
@@ -1526,14 +1735,69 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o
|
|||||||
};
|
};
|
||||||
|
|
||||||
const once = runMigrationP0Cleanup(store, now);
|
const once = runMigrationP0Cleanup(store, now);
|
||||||
assert.deepEqual(once.migrations, ["2026-04-26-p0-cleanup"]);
|
assert.deepEqual(once.store.migrations, ["2026-04-26-p0-cleanup"]);
|
||||||
assert.equal(once.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
|
assert.equal(once.store.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
|
||||||
assert.equal(once.entries.find(e => e.id === "project-explicit")?.status, "active");
|
assert.equal(once.store.entries.find(e => e.id === "project-explicit")?.status, "active");
|
||||||
assert.equal(once.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
|
assert.equal(once.store.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
|
||||||
|
assert.equal(once.events.length, 1);
|
||||||
|
assert.equal(once.events[0].type, "memory_migration_superseded");
|
||||||
|
assert.equal(once.events[0].phase, "storage");
|
||||||
|
assert.equal(once.events[0].outcome, "superseded");
|
||||||
|
assert.ok(once.events[0].reasonCodes.includes("migration:p0_cleanup"));
|
||||||
|
assert.equal(once.events[0].memory?.memoryId, "project-snapshot");
|
||||||
|
|
||||||
const twice = runMigrationP0Cleanup(once, later);
|
const twice = runMigrationP0Cleanup(once.store, later);
|
||||||
assert.deepEqual(twice.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
|
assert.deepEqual(twice.store.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
|
||||||
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
|
assert.equal(twice.store.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.store.entries.find(e => e.id === "project-snapshot")?.updatedAt);
|
||||||
|
assert.equal(twice.events.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadWorkspaceMemory appends P0 migration evidence once", async () => {
|
||||||
|
const sandbox = await mkdtemp(join(tmpdir(), "wm-p0-evidence-"));
|
||||||
|
const dataHome = join(sandbox, "xdg-data-home");
|
||||||
|
const root = join(sandbox, "workspace");
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
const now = "2026-04-26T00:00:00.000Z";
|
||||||
|
const storePath = await workspaceMemoryPath(root);
|
||||||
|
await mkdir(dirname(storePath), { recursive: true });
|
||||||
|
await writeFile(storePath, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [{
|
||||||
|
id: "p0_progress",
|
||||||
|
type: "project",
|
||||||
|
text: "Phase 1-4 completed successfully",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}],
|
||||||
|
migrations: ["2026-04-28-quality-cleanup"],
|
||||||
|
updatedAt: now,
|
||||||
|
}, null, 2), "utf8");
|
||||||
|
|
||||||
|
const firstLoad = await loadWorkspaceMemory(root);
|
||||||
|
await loadWorkspaceMemory(root);
|
||||||
|
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
|
||||||
|
|
||||||
|
assert.equal(firstLoad.entries.find(memory => memory.id === "p0_progress")?.status, "superseded");
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
assert.equal(events[0].type, "memory_migration_superseded");
|
||||||
|
assert.equal(events[0].phase, "storage");
|
||||||
|
assert.equal(events[0].outcome, "superseded");
|
||||||
|
assert.deepEqual(events[0].reasonCodes, ["migration:p0_cleanup"]);
|
||||||
|
assert.equal(events[0].memory?.memoryId, "p0_progress");
|
||||||
|
} finally {
|
||||||
|
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||||
|
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
await rm(sandbox, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("quality cleanup migration preserves soft-only feedback and decision violations", async () => {
|
test("quality cleanup migration preserves soft-only feedback and decision violations", async () => {
|
||||||
@@ -1660,6 +1924,56 @@ test("quality cleanup migration writes audit log for hard supersedes", async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration appends superseded evidence with hard reasons", async () => {
|
||||||
|
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-evidence-"));
|
||||||
|
const dataHome = join(sandbox, "xdg-data-home");
|
||||||
|
const root = join(sandbox, "workspace");
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
const now = "2026-04-28T00:00:00.000Z";
|
||||||
|
const storePath = await workspaceMemoryPath(root);
|
||||||
|
await mkdir(dirname(storePath), { recursive: true });
|
||||||
|
await writeFile(storePath, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [{
|
||||||
|
id: "quality_progress",
|
||||||
|
type: "project",
|
||||||
|
text: "Test suite: 1237 tests pass, 226 suites",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
staleAfterDays: 60,
|
||||||
|
}],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
}, null, 2), "utf8");
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
|
||||||
|
|
||||||
|
assert.equal(loaded.entries.find(memory => memory.id === "quality_progress")?.status, "superseded");
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
assert.equal(events[0].type, "memory_migration_superseded");
|
||||||
|
assert.equal(events[0].phase, "storage");
|
||||||
|
assert.equal(events[0].outcome, "superseded");
|
||||||
|
assert.ok(events[0].reasonCodes.includes("migration:quality_cleanup"));
|
||||||
|
assert.ok(events[0].reasonCodes.includes("quality:progress_snapshot"));
|
||||||
|
assert.equal(events[0].memory?.memoryId, "quality_progress");
|
||||||
|
assert.equal(events[0].memory?.status, "superseded");
|
||||||
|
} finally {
|
||||||
|
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||||
|
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
await rm(sandbox, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("quality cleanup migration aborts supersede when audit log cannot be written", async () => {
|
test("quality cleanup migration aborts supersede when audit log cannot be written", async () => {
|
||||||
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-"));
|
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-"));
|
||||||
const dataHome = join(sandbox, "xdg-data-home");
|
const dataHome = join(sandbox, "xdg-data-home");
|
||||||
@@ -2179,3 +2493,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);
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -25,6 +25,6 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["index.ts", "src/**/*.ts"],
|
"include": ["index.ts", "src/**/*.ts", "scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user