Compare commits

...

5 Commits

Author SHA1 Message Date
Ralph Chang 5163ea3b8f docs(release): prepare v1.6.3 2026-05-14 17:06:03 +08:00
Ralph Chang 9591f85dca fix(memory-diag): remove duplicate active memory candidates 2026-05-14 16:53:17 +08:00
Ralph Chang 93550b2e41 feat(memory-diag): add memory command detail 2026-05-14 09:29:55 +08:00
Ralph Chang 3c4282b241 feat(memory-diag): clarify diagnostic provenance 2026-05-13 21:18:24 +08:00
Ralph Chang 5bca3432b0 feat(memory-diag): add quality review board command
Add memory-diag quality command for objective review of memory-system
mechanisms and active memory content. The command is read-only and
non-authoritative, providing evidence, heuristic flags, and review
questions without making quality judgments or suggesting mutations.

Key components:
- quality-review-model.ts: builds ReviewBoardReport with provenance,
  re-absorption detection, mechanism facts (rejection, reinforcement,
  eviction/caps, identity/dedup), and memory content facts
- formatters/quality.ts: human and JSON output with separate
  system-mechanism and memory-content sections
- commands/quality.ts: command entry point with --json, --verbose,
  --no-emoji, --raw options
- cli.ts: parser whitelist for quality accepting --workspace, --json,
  rejecting mutation/filter flags

co-author: code-execute-agent, comprehensive-code-reviewer,
systems-architect, creative-disruptor

Closes docs/plans/2026-05-11-memory-diag-quality-review-board.md
2026-05-12 14:13:03 +08:00
26 changed files with 4331 additions and 50 deletions
+18
View File
@@ -5,6 +5,24 @@ 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.6.3] - 2026-05-14
### Added
- Added `memory-diag quality`, a read-only review board for memory-system mechanism evidence, answerability levels, provenance classification, active-memory review surfaces, and JSON review output.
- Added producer/version-aware diagnostic facts so current instrumentation can be separated from historical or unversioned evidence when reviewing reinforcement, rejection, and eviction patterns.
- Added `memory-diag commands --memory <memory-id>` for focused reinforcement command detail, including current memory status, recorded block reasons, missing block detail counts, UTC-day evidence, and privacy-safe JSON.
### Changed
- Made `activeMemoryDisplay` the canonical active-memory review surface in `memory-diag quality` JSON and removed duplicate active-memory `reviewCandidates` entries.
- Clarified diagnostic provenance and answerability wording so `memory-diag quality` separates facts, heuristic flags, review questions, and human judgment requirements.
### Fixed
- Removed duplicate active-memory candidate construction from `memory-diag quality` to prevent drift between human output and JSON surfaces.
- Kept reinforcement detail diagnostics evidence-only so blocked reinforcement attempts are shown as recorded evidence without claiming policy failure or memory loss.
## [1.6.2] - 2026-05-11 ## [1.6.2] - 2026-05-11
### Fixed ### Fixed
+2 -1
View File
@@ -248,6 +248,7 @@ npx --package opencode-working-memory memory-diag status
npx --package opencode-working-memory memory-diag rejected npx --package opencode-working-memory memory-diag rejected
npx --package opencode-working-memory memory-diag missing npx --package opencode-working-memory memory-diag missing
npx --package opencode-working-memory memory-diag explain <memory-id> npx --package opencode-working-memory memory-diag explain <memory-id>
npx --package opencode-working-memory memory-diag quality
``` ```
See [Diagnostics](docs/diagnostics.md) for the full command reference, numbered memory command reports, and dry-run recovery workflow. See [Diagnostics](docs/diagnostics.md) for the full command reference, numbered memory command reports, and dry-run recovery workflow.
@@ -276,7 +277,7 @@ See [Configuration](docs/configuration.md) for customization options.
## Requirements ## Requirements
- OpenCode plugin API `>=1.2.0 <2.0.0` - OpenCode plugin API `>=1.2.0 <2.0.0`
- Node.js >= 22.6.0 (for `memory-diag` CLI, which runs TypeScript with `--experimental-strip-types`) - Node.js >= 22.6.0 (the published `memory-diag` CLI runs compiled JavaScript)
## Limitations ## Limitations
+41
View File
@@ -1,5 +1,46 @@
# Release Notes # Release Notes
## 1.6.3 (2026-05-14)
### Diagnostic Quality Review Board
This patch release focuses on safer memory diagnostics. It adds a read-only quality review board and finer reinforcement drill-downs so reviewers can inspect memory-system evidence without treating historical artifacts as current failures or turning heuristic flags into automatic cleanup decisions.
The goal is better observability before policy changes: diagnostics show facts, provenance, answerability, and review questions, while leaving judgment to the operator.
### What Changed
- **Quality review board**: `memory-diag quality` now reports system-mechanism facts for rejection filters, reinforcement rules, eviction/caps, identity/dedup, and active memory content review.
- **Version/provenance context**: diagnostics distinguish current producer-instrumented events from historical or unversioned evidence where possible, and label ambiguous evidence conservatively.
- **Focused reinforcement drill-down**: `memory-diag commands --memory <memory-id>` shows one memory's reinforcement command evidence, current status, recorded block reasons, missing details, and UTC-day evidence.
- **Canonical active-memory JSON surface**: `memory-diag quality --json` now uses `activeMemoryDisplay` as the single active-memory review surface instead of duplicating active memories under `reviewCandidates`.
- **Attribution-safe wording**: quality and reinforcement diagnostics avoid claiming that a block is a bug, policy failure, or cause of memory loss; they present recorded evidence and review prompts instead.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files and evidence logs remain compatible.
- If you consume `memory-diag quality --json` from unreleased builds after v1.6.2, read active-memory review data from `activeMemoryDisplay`; `reviewCandidates` is now reserved for system-mechanism candidates.
### Useful Commands
```bash
npx --package opencode-working-memory@1.6.3 memory-diag quality
npx --package opencode-working-memory@1.6.3 memory-diag quality --json
npx --package opencode-working-memory@1.6.3 memory-diag commands --memory <memory-id>
```
### Validation
- `node --test --experimental-strip-types tests/memory-diag-quality.test.ts` — 53 tests passing
- `node --test --experimental-strip-types tests/memory-diag.test.ts` — 39 tests passing
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 486 tests passing, `TEST_PASS`
- `npm run build` — `BUILD_PASS`
- `npm run test:pack:memory-diag` — packed tarball smoke test passed
---
## 1.6.2 (2026-05-11) ## 1.6.2 (2026-05-11)
### Published `memory-diag` Bin Fix ### Published `memory-diag` Bin Fix
+24
View File
@@ -55,6 +55,30 @@ Long-term memory that persists across sessions within the same workspace. Perfec
} }
``` ```
### Evidence Log Schema
Workspace diagnostics also read the append-only evidence log for the current workspace. New evidence records are additive and keep historical records valid:
```typescript
{
version: 1,
eventId: string,
createdAt: string,
workspaceKey: string,
workspaceRootHash: string,
producerName?: string,
producerVersion?: string,
instrumentationVersion?: number,
type: EvidenceEventType,
phase: EvidencePhase,
outcome: EvidenceOutcome,
reasonCodes: string[],
details?: Record<string, string | number | boolean | null | string[] | number[]>
}
```
Instrumentation version 2 adds optional causal details for future diagnostics without backfilling old JSONL records. Reinforcement-block events may include `details.blockReason` (for example `same_session` or `same_utc_day`). Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior.
### Entry Types ### Entry Types
| Type | Purpose | Example | | Type | Purpose | Example |
+39
View File
@@ -13,6 +13,8 @@ The npm package is `opencode-working-memory`; the installed bin is `memory-diag`
| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` | | 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>` | | Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain <memory-id>` |
| How are numbered memory commands behaving? | `npx --package opencode-working-memory memory-diag commands` | | How are numbered memory commands behaving? | `npx --package opencode-working-memory memory-diag commands` |
| What reinforcement evidence exists for one memory? | `npx --package opencode-working-memory memory-diag commands --memory <memory-id>` |
| How do I review memory quality without automatic cleanup? | `npx --package opencode-working-memory memory-diag quality` |
| Revert a numbered replacement? | `npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>` | | Revert a numbered replacement? | `npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>` |
## Global Options ## Global Options
@@ -21,6 +23,25 @@ The npm package is `opencode-working-memory`; the installed bin is `memory-diag`
- `--verbose` — show detailed diagnostics. - `--verbose` — show detailed diagnostics.
- `--json` — print machine-readable output where supported. - `--json` — print machine-readable output where supported.
## Diagnostic Answerability Contract
Every diagnostic section must document:
1. **Question:** What does the reviewer want to know?
2. **Decision:** What action could the answer inform?
3. **Competing explanations:** At least two interpretations of the same metric.
4. **Required signals:** What fields/events distinguish those explanations?
5. **Current signals:** What currently exists?
6. **Answerability level:** `supported` | `partial` | `inventory_only` | `not_instrumented`
7. **Output permission:** What the tool may say without overclaiming.
For `memory-diag quality`:
- `reinforcementRules`: `inventory_only` (cannot distinguish spam from legitimate blocks)
- `evictionAndCaps`: `inventory_only` (cannot distinguish healthy turnover from premature eviction)
- Old evidence remains ambiguous. Answerability improves only for events produced after instrumentation version 2. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections.
- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and UTC-day grouping; they still require human content judgment.
- Producer-instrumented capacity removals with rank/strength snapshots can upgrade `evictionAndCaps` to `partial`; fullness alone remains occupancy inventory, not proof of a capacity problem.
## Examples ## Examples
```bash ```bash
@@ -29,9 +50,24 @@ 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 missing --workspace /path/to/project
npx --package opencode-working-memory memory-diag status --json npx --package opencode-working-memory memory-diag status --json
npx --package opencode-working-memory memory-diag commands --verbose npx --package opencode-working-memory memory-diag commands --verbose
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
npx --package opencode-working-memory memory-diag quality
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id> npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>
``` ```
## Quality Review Board
Use `memory-diag quality` for a read-only, answerability-scoped evidence inventory without automatic cleanup.
- Primarily provides memory-system mechanism observations for human/agent interpretation.
- Secondarily helps review active memory content quality.
- Prints answerability labels and output permissions so inventory facts are not presented as conclusions.
- Separates system-mechanism facts, memory-content facts, heuristic flags, and review questions.
- Includes inferred evidence provenance because historical records do not record producer package version.
- Labels uncertain provenance as `unversioned_ambiguous` so old artifacts are not treated as current mechanism failures.
- Does not decide what to delete or mutate.
- Use `--json` for agent/objective review.
## Numbered Memory Command Reports ## Numbered Memory Command Reports
Use `memory-diag commands` to inspect `REINFORCE [M#]` and `REPLACE [M#]` outcomes from compaction. Use `memory-diag commands` to inspect `REINFORCE [M#]` and `REPLACE [M#]` outcomes from compaction.
@@ -39,10 +75,13 @@ Use `memory-diag commands` to inspect `REINFORCE [M#]` and `REPLACE [M#]` outcom
```bash ```bash
npx --package opencode-working-memory memory-diag commands npx --package opencode-working-memory memory-diag commands
npx --package opencode-working-memory memory-diag commands --verbose npx --package opencode-working-memory memory-diag commands --verbose
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
``` ```
The report includes successful reinforcements, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode. The report includes successful reinforcements, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode.
Use `commands --memory <memory-id>` when you need a focused, evidence-only reinforcement view for one memory. It reports current memory status separately from recorded reinforcement attempts, block reasons, missing block details, and UTC-day evidence without judging whether the policy is correct.
## Dry-run Recovery ## Dry-run Recovery
`memory-diag revert` is dry-run by default. Add `--apply` only after reviewing the planned original/replacement status changes. `memory-diag revert` is dry-run by default. Add `--apply` only after reviewing the planned original/replacement status changes.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "opencode-working-memory", "name": "opencode-working-memory",
"version": "1.6.2", "version": "1.6.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",
+5 -4
View File
@@ -9,7 +9,8 @@ export function usage(): string {
memory-diag rejected [--workspace <path>] [--verbose] [--json] memory-diag rejected [--workspace <path>] [--verbose] [--json]
memory-diag missing [--workspace <path>] [--verbose] [--json] memory-diag missing [--workspace <path>] [--verbose] [--json]
memory-diag explain [memory-id] [--workspace <path>] [--raw] memory-diag explain [memory-id] [--workspace <path>] [--raw]
memory-diag commands [--workspace <path>] [--verbose] [--json] memory-diag commands [--workspace <path>] [--verbose] [--json] [--memory <id>]
memory-diag quality [--workspace <path>] [--verbose] [--json] [--raw] [--no-emoji]
memory-diag revert (--memory <replacement-id> | --event <event-id>) [--workspace <path>] [--apply] memory-diag revert (--memory <replacement-id> | --event <event-id>) [--workspace <path>] [--apply]
Global options: Global options:
@@ -103,12 +104,12 @@ export function parseArgs(argv: string[]): ParsedArgs {
if (command === "status") { if (command === "status") {
if (options.all) return error(`${command} does not accept --all`); if (options.all) return error(`${command} does not accept --all`);
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "revert") { } else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "quality" || command === "revert") {
if (options.all) return error(`${command} does not accept --all`); if (options.all) return error(`${command} does not accept --all`);
} else { } else {
if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`); if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`);
} }
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands") { if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands" && command !== "quality") {
return error(`${command} does not accept --json`); return error(`${command} does not accept --json`);
} }
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) { if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
@@ -120,7 +121,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
if (command !== "audit" && options.migration) { if (command !== "audit" && options.migration) {
return error(`${command} does not accept --migration`); return error(`${command} does not accept --migration`);
} }
if (command !== "explain" && command !== "revert" && options.memory) { if (command !== "explain" && command !== "revert" && command !== "commands" && options.memory) {
return error(`${command} does not accept --memory`); return error(`${command} does not accept --memory`);
} }
if (command !== "revert" && options.event) return error(`${command} does not accept --event`); if (command !== "revert" && options.event) return error(`${command} does not accept --event`);
+1 -1
View File
@@ -1,4 +1,4 @@
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "revert"] as const; export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "quality", "revert"] as const;
export const HIDDEN_COMMANDS = ["coverage", "audit"] as const; export const HIDDEN_COMMANDS = ["coverage", "audit"] as const;
export type VisibleCommand = typeof VISIBLE_COMMANDS[number]; export type VisibleCommand = typeof VISIBLE_COMMANDS[number];
+2
View File
@@ -3,6 +3,7 @@ import { runCommands } from "./commands/commands.ts";
import { runCoverage } from "./commands/coverage.ts"; import { runCoverage } from "./commands/coverage.ts";
import { runExplain } from "./commands/explain.ts"; import { runExplain } from "./commands/explain.ts";
import { runMissing } from "./commands/missing.ts"; import { runMissing } from "./commands/missing.ts";
import { runQuality } from "./commands/quality.ts";
import { runRejected } from "./commands/rejected.ts"; import { runRejected } from "./commands/rejected.ts";
import { runRevert } from "./commands/revert.ts"; import { runRevert } from "./commands/revert.ts";
import { runStatus } from "./commands/status.ts"; import { runStatus } from "./commands/status.ts";
@@ -17,6 +18,7 @@ export async function dispatch(command: Command, options: CliOptions): Promise<C
case "audit": return runAudit(options); case "audit": return runAudit(options);
case "explain": return runExplain(options); case "explain": return runExplain(options);
case "commands": return runCommands(options); case "commands": return runCommands(options);
case "quality": return runQuality(options);
case "revert": return runRevert(options); case "revert": return runRevert(options);
} }
} }
+237
View File
@@ -1,6 +1,11 @@
import { queryEvidenceEvents, type EvidenceEventV1, type EvidenceOutcome } from "../../../src/evidence-log.ts"; import { queryEvidenceEvents, type EvidenceEventV1, type EvidenceOutcome } from "../../../src/evidence-log.ts";
import { workspaceKey, workspaceMemoryPath } from "../../../src/paths.ts";
import type { WorkspaceMemoryStore } from "../../../src/types.ts";
import { accountWorkspaceMemoryRender } from "../../../src/workspace-memory.ts";
import { readJSONFile } from "../io.ts";
import { objectFromCounts, sortedCounts } from "../text.ts"; import { objectFromCounts, sortedCounts } from "../text.ts";
import type { CliOptions, CommandResult } from "../types.ts"; import type { CliOptions, CommandResult } from "../types.ts";
import { normalizedStore } from "../workspace-snapshot.ts";
type CommandKind = "reinforce" | "replace"; type CommandKind = "reinforce" | "replace";
@@ -33,6 +38,42 @@ type MemoryCommandSummary = {
}>; }>;
}; };
type MemoryCommandDetail = {
version: 1;
generatedAt: string;
memoryId: string;
current: {
present: boolean;
status?: string;
renderStatus?: "rendered" | "not_rendered" | "unknown";
type?: string;
source?: string;
};
summary: {
attempts: number;
reinforced: number;
rejectedOrBlocked: number;
windowBlocked: number;
blocksByReason: Record<string, number>;
blockDetailsMissing: number;
refs: string[];
sameSessionCrossUtcDayBlocks: number;
};
events: Array<{
eventId: string;
createdAt: string;
outcome: EvidenceOutcome;
ref?: string;
blockReason?: string;
reasonCodes: string[];
attemptedAtIso?: string;
lastReinforcedAtIso?: string;
crossUtcDay?: boolean | "unknown";
producerVersion?: string;
instrumentationVersion?: number;
}>;
};
const INVALID_COMMAND_REASONS = new Set([ const INVALID_COMMAND_REASONS = new Set([
"invalid_memory_command", "invalid_memory_command",
"invalid_memory_ref", "invalid_memory_ref",
@@ -66,6 +107,124 @@ function refFromEvent(event: EvidenceEventV1): string | undefined {
return typeof ref === "string" ? ref : undefined; return typeof ref === "string" ? ref : undefined;
} }
function isReinforcementEvent(event: EvidenceEventV1): boolean {
return event.type === "memory_reinforced";
}
function stringDetail(event: EvidenceEventV1, key: string): string | undefined {
const value = event.details?.[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function isRejectedOrBlocked(event: EvidenceEventV1): boolean {
return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked");
}
function blockReasonFor(event: EvidenceEventV1): string | undefined {
if (!isRejectedOrBlocked(event)) return undefined;
return stringDetail(event, "blockReason") ?? "unknown";
}
function isCrossUtcDay(attemptedAtIso: string | undefined, lastReinforcedAtIso: string | undefined): boolean | "unknown" {
if (!attemptedAtIso || !lastReinforcedAtIso) return "unknown";
const attempted = new Date(attemptedAtIso);
const lastReinforced = new Date(lastReinforcedAtIso);
if (Number.isNaN(attempted.getTime()) || Number.isNaN(lastReinforced.getTime())) return "unknown";
return attempted.toISOString().slice(0, 10) !== lastReinforced.toISOString().slice(0, 10);
}
async function currentMemoryStatus(root: string, memoryId: string): Promise<MemoryCommandDetail["current"]> {
const rawStore = await readJSONFile<WorkspaceMemoryStore>(await workspaceMemoryPath(root));
const storeRoot = rawStore?.workspace?.root ?? root;
const storeKey = rawStore?.workspace?.key ?? await workspaceKey(root);
const store = normalizedStore(rawStore, storeRoot, storeKey);
const activeEntry = store.entries.find(entry => entry.id === memoryId && entry.status !== "superseded");
const renderAccounting = accountWorkspaceMemoryRender(store);
const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id));
const omittedIds = new Set(renderAccounting.omitted.map(item => item.memory.id));
const renderStatus = renderedIds.has(memoryId)
? "rendered"
: omittedIds.has(memoryId)
? "not_rendered"
: "unknown";
if (!activeEntry) {
return { present: false, renderStatus };
}
return {
present: true,
status: activeEntry.status,
renderStatus,
type: activeEntry.type,
source: activeEntry.source,
};
}
function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][number] {
const attemptedAtIso = stringDetail(event, "attemptedAtIso");
const lastReinforcedAtIso = stringDetail(event, "lastReinforcedAtIso");
const blocked = isRejectedOrBlocked(event);
const blockReason = blockReasonFor(event);
return {
eventId: event.eventId,
createdAt: event.createdAt,
outcome: event.outcome,
ref: refFromEvent(event),
blockReason,
reasonCodes: event.reasonCodes,
attemptedAtIso,
lastReinforcedAtIso,
crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined,
producerVersion: event.producerVersion,
instrumentationVersion: event.instrumentationVersion,
};
}
export async function buildMemoryCommandDetail(
root: string,
memoryId: string,
events: EvidenceEventV1[],
generatedAt = new Date().toISOString(),
): Promise<MemoryCommandDetail> {
const reinforcementEvents = events.filter(isReinforcementEvent);
const blockReasonCounts = new Map<string, number>();
const refs = new Set<string>();
let blockDetailsMissing = 0;
let sameSessionCrossUtcDayBlocks = 0;
for (const event of reinforcementEvents) {
const ref = refFromEvent(event);
if (ref) refs.add(ref);
if (!isRejectedOrBlocked(event)) continue;
const blockReason = blockReasonFor(event) ?? "unknown";
blockReasonCounts.set(blockReason, (blockReasonCounts.get(blockReason) ?? 0) + 1);
if (!stringDetail(event, "blockReason")) blockDetailsMissing += 1;
if (blockReason === "same_session" && isCrossUtcDay(stringDetail(event, "attemptedAtIso"), stringDetail(event, "lastReinforcedAtIso")) === true) {
sameSessionCrossUtcDayBlocks += 1;
}
}
return {
version: 1,
generatedAt,
memoryId,
current: await currentMemoryStatus(root, memoryId),
summary: {
attempts: reinforcementEvents.length,
reinforced: reinforcementEvents.filter(event => event.outcome === "reinforced").length,
rejectedOrBlocked: reinforcementEvents.filter(isRejectedOrBlocked).length,
windowBlocked: reinforcementEvents.filter(event => hasReason(event, "reinforcement_window_blocked")).length,
blocksByReason: objectFromCounts(blockReasonCounts),
blockDetailsMissing,
refs: [...refs].sort(),
sameSessionCrossUtcDayBlocks,
},
events: reinforcementEvents.map(detailEventJSON),
};
}
function latestEventJSON(event: EvidenceEventV1): MemoryCommandSummary["latestEvents"][number] { function latestEventJSON(event: EvidenceEventV1): MemoryCommandSummary["latestEvents"][number] {
return { return {
eventId: event.eventId, eventId: event.eventId,
@@ -145,6 +304,73 @@ function formatLatestEvents(events: MemoryCommandSummary["latestEvents"]): strin
}); });
} }
function formatInlineCounts(counts: Record<string, number>): string {
const rows = sortedCounts(new Map(Object.entries(counts)));
return rows.length > 0 ? rows.map(([reason, count]) => `${reason}=${count}`).join(", ") : "(none)";
}
function formatCrossUtcDay(value: boolean | "unknown" | undefined): string {
if (value === true) return "yes";
if (value === false) return "no";
return "unknown";
}
function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] {
if (events.length === 0) return [" (none)"];
return events.map(event => {
const ref = event.ref ? ` ref=${event.ref}` : "";
const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : "";
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : "";
const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : "";
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${attemptedAt}${lastReinforcedAt}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`;
});
}
export function formatMemoryCommandDetail(detail: MemoryCommandDetail, options: Pick<CliOptions, "verbose"> = {}): string {
const current = detail.current;
const lines = [
`Memory command diagnostics for ${detail.memoryId}`,
"",
"Current memory:",
` - present: ${current.present ? "yes" : "no"}`,
` - status: ${current.status ?? "unknown"}`,
` - render: ${current.renderStatus ?? "unknown"}`,
];
if (current.type) lines.push(` - type: ${current.type}`);
if (current.source) lines.push(` - source: ${current.source}`);
lines.push("");
if (detail.summary.attempts === 0) {
lines.push(`No reinforcement command evidence found for ${detail.memoryId}.`);
return lines.join("\n");
}
lines.push(
"Reinforcement summary:",
` - attempts: ${detail.summary.attempts}`,
` - reinforced: ${detail.summary.reinforced}`,
` - rejected/blocked: ${detail.summary.rejectedOrBlocked}`,
` - window blocked: ${detail.summary.windowBlocked}`,
` - block reasons: ${formatInlineCounts(detail.summary.blocksByReason)}`,
` - block details missing: ${detail.summary.blockDetailsMissing}`,
` - same-session cross UTC day blocks: ${detail.summary.sameSessionCrossUtcDayBlocks}`,
` - refs: ${detail.summary.refs.length > 0 ? detail.summary.refs.join(", ") : "(none)"}`,
"",
);
const eventRows = options.verbose ? detail.events : detail.events.slice(-10).reverse();
if (!options.verbose && detail.events.length > eventRows.length) {
lines.push(`Latest reinforcement events (showing ${eventRows.length} of ${detail.events.length}):`);
} else {
lines.push("Latest reinforcement events:");
}
lines.push(...formatMemoryCommandDetailEvents(eventRows));
return lines.join("\n");
}
export function formatMemoryCommandSummary(summary: MemoryCommandSummary, options: Pick<CliOptions, "verbose" | "noEmoji"> = {}): string { export function formatMemoryCommandSummary(summary: MemoryCommandSummary, options: Pick<CliOptions, "verbose" | "noEmoji"> = {}): string {
const warning = options.noEmoji ? "!" : "⚠"; const warning = options.noEmoji ? "!" : "⚠";
const lines = [ const lines = [
@@ -176,6 +402,17 @@ export function formatMemoryCommandSummary(summary: MemoryCommandSummary, option
export async function runCommands(options: CliOptions): Promise<CommandResult> { export async function runCommands(options: CliOptions): Promise<CommandResult> {
const root = options.workspace ?? process.cwd(); const root = options.workspace ?? process.cwd();
if (options.memory) {
const events = await queryEvidenceEvents(root, { memoryId: options.memory });
const detail = await buildMemoryCommandDetail(root, options.memory, events);
if (options.json) {
return { stdout: JSON.stringify(detail, null, 2) };
}
return { stdout: formatMemoryCommandDetail(detail, options) };
}
const events = await queryEvidenceEvents(root); const events = await queryEvidenceEvents(root);
const summary = buildMemoryCommandSummary(events); const summary = buildMemoryCommandSummary(events);
+19
View File
@@ -0,0 +1,19 @@
import { buildQualityJSON, formatQualityReviewBoard } from "../formatters/quality.ts";
import { buildInspectionReadModel } from "../inspection-model.ts";
import { buildQualityReviewBoard } from "../quality-review-model.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runQuality(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const report = buildQualityReviewBoard(model, {
verbose: options.verbose,
raw: options.raw,
json: options.json,
});
if (options.json) {
return { stdout: JSON.stringify(buildQualityJSON(report, options.raw), null, 2) };
}
return { stdout: formatQualityReviewBoard(report, { verbose: options.verbose }) };
}
+467
View File
@@ -0,0 +1,467 @@
import { cleanText, formatDetails } from "../text.ts";
import type {
CandidateProvenance,
HeuristicFlag,
ProvenanceClassification,
RejectionVersionFacts,
ReinforcementVersionFacts,
ReviewBoardActiveMemory,
ReviewBoardCandidate,
ReviewBoardReport,
EvictionVersionFacts,
VersionAvailability,
VersionCoverage,
VersionedMechanismFacts,
} from "../quality-review-model.ts";
const PROVENANCE_ORDER: ProvenanceClassification[] = [
"explicit_migration_evidence",
"legacy_unversioned_format",
"reabsorbed_post_rejection",
"suspected_pre_migration_legacy",
"likely_current_behavior",
"unversioned_ambiguous",
];
const REVIEW_FLAG_CAVEAT = "This flag is a prompt for review, not a conclusion.";
export function buildQualityJSON(report: ReviewBoardReport, raw = false): unknown {
if (raw) return report;
return redactUnknown(report);
}
export function formatQualityReviewBoard(
report: ReviewBoardReport,
options: { verbose?: boolean },
): string {
const bullet = "-";
const lines: string[] = [];
lines.push("Memory quality review board");
lines.push("Purpose: evidence for human/agent review only; no automatic judgment or cleanup.");
lines.push("Producer version note: historical records do not include package/plugin version; provenance below is inferred.");
lines.push("Primary review purpose: SYSTEM MECHANISM observations (filters, reinforcement, eviction/caps, identity/dedup).");
lines.push("Secondary review purpose: MEMORY CONTENT quality (staleness, durability, redundancy, specificity).");
lines.push("");
pushEvidenceProvenance(lines, report, bullet);
lines.push("");
pushSystemMechanismFacts(lines, report, bullet);
lines.push("");
pushMemoryContentFacts(lines, report, bullet);
lines.push("");
pushSystemMechanismCandidates(lines, report, bullet);
lines.push("");
pushMemoryContentCandidates(lines, report, bullet, options);
lines.push("");
pushReviewQuestions(lines, report, bullet);
lines.push("");
pushNextCommands(lines, report, bullet);
return lines.join("\n");
}
function pushEvidenceProvenance(lines: string[], report: ReviewBoardReport, bullet: string): void {
const context = report.provenanceContext;
const instrumentation = report.facts.systemMechanisms.instrumentation;
lines.push("Evidence provenance");
lines.push(` ${bullet} method: migration/timestamp/format inference`);
lines.push(` ${bullet} confidence: ${context.confidenceDisclaimer}`);
lines.push(` ${bullet} Producer coverage: ${instrumentation.evidenceEventsWithProducer} of ${instrumentation.evidenceEventsTotal} evidence events instrumented`);
lines.push(` ${bullet} Rejection producer coverage: ${instrumentation.rejectionRecordsWithProducer} of ${instrumentation.rejectionRecordsTotal} rejection records instrumented`);
lines.push(` ${bullet} instrumentation versions: ${formatCounts(instrumentation.instrumentationVersions)}`);
lines.push(` ${bullet} migration timeline: ${formatMigrationTimeline(context.migrationTimeline)}`);
if (context.lastActivityAt) lines.push(` ${bullet} last activity: ${context.lastActivityAt}`);
}
function pushSystemMechanismFacts(lines: string[], report: ReviewBoardReport, bullet: string): void {
const facts = report.facts.systemMechanisms;
lines.push("Facts - system mechanisms");
lines.push(" Provenance counts for mechanism evidence");
lines.push(` ${bullet} ${formatProvenanceCounts(report.provenanceContext.countsByClassification)}`);
lines.push(" Rejection filters");
pushAnswerability(lines, report.answerability?.rejectionFilters, " ");
lines.push(` ${bullet} rejected records: ${facts.rejectionFilters.totalRecords} (unique: ${facts.rejectionFilters.uniqueTexts})`);
lines.push(` ${bullet} raw reason-code distribution: ${formatCounts(facts.rejectionFilters.byRawReasonCode)}`);
lines.push(` ${bullet} type distribution: ${formatCounts(facts.rejectionFilters.byType)}`);
lines.push(` ${bullet} ambiguous/architecture-like rejected candidates: ${facts.rejectionFilters.ambiguousOrArchitectureLike}`);
lines.push(` ${bullet} status-or-hard-reason evidence: ${facts.rejectionFilters.hardReasonOrNoiseHeuristic}`);
lines.push(` ${bullet} re-absorbed rejected texts: ${facts.rejectionFilters.reabsorbedRejectedTexts}`);
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.rejectionFilters, facts.versionedFacts.versionCoverage, formatRejectionVersionFacts, bullet);
lines.push(" Reinforcement rules");
pushAnswerability(lines, report.answerability?.reinforcementRules, " ");
lines.push(` ${bullet} reinforce attempts: ${facts.reinforcementRules.reinforceEvents}, reinforced: ${facts.reinforcementRules.reinforcedEvents}, rejected/blocked: ${facts.reinforcementRules.rejectedOrBlockedEvents}`);
lines.push(` ${bullet} reinforcement-window blocked: ${facts.reinforcementRules.windowBlockedEvents} (rate: ${formatPercent(facts.reinforcementRules.windowBlockRate)})`);
lines.push(` ${bullet} Exact block reasons: ${formatCounts(facts.reinforcementRules.blocksByExactReason)}`);
lines.push(` ${bullet} window blocks by UTC day: ${formatCounts(facts.reinforcementRules.windowBlocksByUtcDay)}`);
lines.push(` ${bullet} block details missing: ${facts.reinforcementRules.blockDetailsMissing}`);
lines.push(` ${bullet} repeated blocks by memory: ${formatRepeatedBlocks(facts.reinforcementRules.repeatedBlocksByMemory)}`);
lines.push(` ${bullet} malformed command events: ${facts.reinforcementRules.malformedCommandEvents}`);
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.reinforcementRules, facts.versionedFacts.versionCoverage, formatReinforcementVersionFacts, bullet);
lines.push(" Eviction and caps");
pushAnswerability(lines, report.answerability?.evictionAndCaps, " ");
lines.push(` ${bullet} active memories: ${facts.evictionAndCaps.activeMemories} / ${facts.evictionAndCaps.maxEntries}`);
lines.push(` ${bullet} rendered memories: ${facts.evictionAndCaps.renderedMemories}`);
lines.push(` ${bullet} cap occupancy: ${formatFullCaps(facts.evictionAndCaps.fullCaps, facts.evictionAndCaps.typeCounts, facts.evictionAndCaps.typeCaps, facts.evictionAndCaps.activeMemories, facts.evictionAndCaps.maxEntries)}`);
lines.push(` ${bullet} capacity removals: total=${facts.evictionAndCaps.removedByCapacity}, global=${facts.evictionAndCaps.removedByGlobalCap}, type=${facts.evictionAndCaps.removedByTypeCap}`);
lines.push(` ${bullet} Removals with snapshot: ${facts.evictionAndCaps.recentCapacityRemovalsWithSnapshot}`);
lines.push(` ${bullet} Removals without snapshot: ${facts.evictionAndCaps.capacitySnapshotsMissing} (historical)`);
if (facts.evictionAndCaps.highestRankRemoved) lines.push(` ${bullet} highest-rank removed snapshot: ${formatHighestRankRemoved(facts.evictionAndCaps.highestRankRemoved)}`);
lines.push(` ${bullet} recent evictions by type: ${formatCounts(facts.evictionAndCaps.recentEvictionsByType)}`);
lines.push(` ${bullet} recent evicted content shown: ${facts.evictionAndCaps.recentEvictedContentShown}`);
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.evictionAndCaps, facts.versionedFacts.versionCoverage, formatEvictionVersionFacts, bullet);
lines.push(" Unknown disappearances");
pushAnswerability(lines, report.answerability?.unknownDisappearances, " ");
lines.push(` ${bullet} unversioned disappearance inventory: evidence-only=${facts.evictionAndCaps.missingEvidenceOnly}, unknown=${facts.evictionAndCaps.unknownDisappearances}`);
lines.push(" Identity and dedup");
pushAnswerability(lines, report.answerability?.identityAndDedup, " ");
lines.push(` ${bullet} replacements: total=${facts.identityAndDedup.replacementEvents}, same-type=${facts.identityAndDedup.sameTypeReplacementEvents}, cross-type=${facts.identityAndDedup.crossTypeReplacementEvents}`);
lines.push(` ${bullet} superseded entries: ${facts.identityAndDedup.supersededEntries}`);
lines.push(` ${bullet} exact duplicate/identity groups identified: ${facts.identityAndDedup.duplicateTextOrIdentityGroups}`);
}
function pushMemoryContentFacts(lines: string[], report: ReviewBoardReport, bullet: string): void {
const facts = report.facts.memoryContent;
lines.push("Facts - memory content");
pushAnswerability(lines, report.answerability?.memoryContent, " ");
lines.push(` ${bullet} rendered memories: ${facts.renderedMemories}`);
lines.push(` ${bullet} evidence coverage: ${facts.evidenceCoverage.covered} / ${facts.evidenceCoverage.total}`);
lines.push(` ${bullet} type counts: ${formatTypeCountsWithCaps(facts.typeCounts, facts.typeCaps)}`);
lines.push(` ${bullet} weakest/strongest active memory previews: weakest=${formatMemoryPreviews(facts.weakestActiveMemories)}; strongest=${formatMemoryPreviews(facts.strongestActiveMemories)}`);
}
function pushAnswerability(
lines: string[],
assessment: NonNullable<ReviewBoardReport["answerability"]>[keyof NonNullable<ReviewBoardReport["answerability"]>] | undefined,
indent: string,
): void {
if (!assessment) return;
const suffix = assessment.level === "partial" ? " — causal fields exist, but human content judgment is still required" : "";
lines.push(`${indent}(Answerability: ${assessment.level}${suffix})`);
lines.push(`${indent}Output permission: ${assessment.outputPermission}`);
}
function pushVersionAnalysis<TFacts>(
lines: string[],
mechanism: VersionedMechanismFacts<TFacts>,
coverage: VersionCoverage,
formatFacts: (facts: TFacts) => string,
bullet: string,
): void {
lines.push(" Version analysis by producer version");
lines.push(` Version-stamp coverage (all evidence/rejection records, not mechanism problem counts): Coverage: ${formatCoveragePercent(coverage.coveragePercent)} of ${formatInteger(coverage.totalEvents)} records carry a version stamp (${formatInteger(coverage.currentVersionEvents)} current, ${formatInteger(coverage.previousVersionEvents)} previous, ${formatInteger(coverage.unknownVersionEvents)} unknown/unversioned). Comparison will become meaningful as new events accumulate.`);
if (coverage.isTransitional) {
lines.push(" NOTE: Version coverage is below 50%. Current-version comparisons may not be representative.");
}
lines.push(` ${mechanismOpportunityDescription(mechanism)}`);
for (const group of ["current", "previous", "unknown_unversioned"] as const) {
const bucket = mechanism.buckets[group];
lines.push(` ${bullet} ${bucket.label}: opportunities=${bucket.opportunityCount}, observed=${bucket.observedPatternCount}, sample=${bucket.sampleAssessment}, answerability=${bucket.answerabilityLevel}`);
if (Object.keys(bucket.producerVersions).length > 0) lines.push(` producer versions: ${formatCounts(bucket.producerVersions)}`);
lines.push(` composition: ${formatVersionAvailability(bucket.versionAvailability)}`);
lines.push(` facts: ${formatFacts(bucket.facts)}`);
}
lines.push(` ${bullet} inference: ${mechanism.inference.message}`);
lines.push(` diagnostic strength: ${diagnosticStrengthLabel(mechanism)}`);
const diagnosticLine = currentMechanismDiagnosticLine(mechanism);
if (diagnosticLine) lines.push(` ${diagnosticLine}`);
if (mechanism.diagnosticQuestions) {
for (const question of mechanism.diagnosticQuestions) {
lines.push(` diagnostic question: ${question.question} Evidence: ${question.evidence.join(", ")}`);
}
}
lines.push(` caveat: ${mechanism.inference.caveat}`);
}
function diagnosticStrengthLabel<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string {
const current = mechanism.buckets.current;
const strength = mechanism.inference.status === "no_current_version_opportunities" || current.opportunityCount === 0
? "unavailable"
: current.opportunityCount < mechanism.sampleThreshold
? "weak"
: "moderate";
return hasCurrentCausalDetail(mechanism) ? `${strength}; causal detail available` : strength;
}
function hasCurrentCausalDetail<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): boolean {
const currentFacts = mechanism.buckets.current.facts;
return isReinforcementVersionFacts(currentFacts) && Object.keys(currentFacts.blocksByExactReason).length > 0;
}
function currentMechanismDiagnosticLine<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string | undefined {
const current = mechanism.buckets.current;
if (!isReinforcementVersionFacts(current.facts)) return undefined;
const facts = current.facts;
const parts: string[] = [];
if (Object.keys(facts.blocksByExactReason).length > 0) parts.push(`current block reasons=${formatCounts(facts.blocksByExactReason)}`);
if (facts.blockDetailsMissing > 0) parts.push(`current block details missing=${facts.blockDetailsMissing}`);
if (parts.length === 0) return undefined;
parts.push(`sample=${current.opportunityCount} attempts`);
return `diagnostic: ${parts.join("; ")}`;
}
function isReinforcementVersionFacts(facts: unknown): facts is ReinforcementVersionFacts {
return typeof facts === "object"
&& facts !== null
&& "blocksByExactReason" in facts
&& "blockDetailsMissing" in facts
&& "windowBlockedEvents" in facts;
}
function mechanismOpportunityDescription<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string {
if (mechanism.opportunityName === "rejection candidates") return "Mechanism opportunities below are reviewable rejection candidates only; render/accounting events are excluded.";
if (mechanism.opportunityName === "attempts") return "Mechanism opportunities below are reinforcement attempts only; render/accounting events are excluded.";
if (mechanism.opportunityName === "capacity removals") return "Mechanism opportunities below are capacity removals only; render/accounting events are excluded.";
return `Mechanism opportunities below are ${mechanism.opportunityName} only; render/accounting events are excluded.`;
}
function formatVersionAvailability(availability: VersionAvailability): string {
const parts = [
availability.noProducerFields > 0 ? `no producer fields=${availability.noProducerFields}` : undefined,
availability.unknownProducerVersion > 0 ? `unknown version=${availability.unknownProducerVersion}` : undefined,
availability.emptyProducerVersion > 0 ? `empty version=${availability.emptyProducerVersion}` : undefined,
availability.knownProducerVersion > 0 ? `known version=${availability.knownProducerVersion}` : undefined,
].filter((part): part is string => Boolean(part));
return parts.length === 0 ? "(empty bucket)" : parts.join(", ");
}
function formatRejectionVersionFacts(facts: RejectionVersionFacts): string {
return `records=${facts.totalRecords}, candidates=${facts.candidateRecords}, raw reason codes=${formatCounts(facts.byRawReasonCode)}, types=${formatCounts(facts.byType)}`;
}
function formatReinforcementVersionFacts(facts: ReinforcementVersionFacts): string {
return `reinforce attempts=${facts.reinforceEvents}, window blocked=${facts.windowBlockedEvents}, exact reasons=${formatCounts(facts.blocksByExactReason)}, block details missing=${facts.blockDetailsMissing}`;
}
function formatEvictionVersionFacts(facts: EvictionVersionFacts): string {
return `capacity removals=${facts.removedByCapacity}, with snapshot=${facts.recentCapacityRemovalsWithSnapshot}, missing snapshot=${facts.capacitySnapshotsMissing}`;
}
function pushSystemMechanismCandidates(lines: string[], report: ReviewBoardReport, bullet: string): void {
const display = report.provenanceContext.candidateDisplay;
if (report.provenanceContext.candidateLimit && display && display.shown < display.total) {
lines.push(`System mechanism review candidates (representative; ${display.shown} shown of ${display.total} total; limit ${report.provenanceContext.candidateLimit} per mechanism category)`);
} else {
lines.push("System mechanism review candidates");
}
pushCandidateGroup(lines, "Rejection filter evidence", candidatesFor(report, ["rejection_rule_evidence"]), bullet);
pushCandidateGroup(lines, "Re-absorption evidence", candidatesFor(report, ["reabsorption_evidence"]), bullet);
pushCandidateGroup(lines, "Reinforcement rule evidence", candidatesFor(report, ["numbered_command_evidence"]), bullet);
pushCandidateGroup(lines, "Eviction/cap evidence", candidatesFor(report, ["eviction_cap_evidence", "missing_evidence"]), bullet);
pushCandidateGroup(lines, "Identity/dedup evidence", candidatesFor(report, ["identity_dedup_evidence"]), bullet);
}
function pushCandidateGroup(lines: string[], title: string, candidates: ReviewBoardCandidate[], bullet: string): void {
lines.push(` ${title}`);
if (candidates.length === 0) {
lines.push(" (none)");
return;
}
const shared = sharedProvenance(candidates);
if (shared) lines.push(` shared provenance for displayed candidates in this group: ${formatProvenance(shared)}`);
for (const candidate of candidates) pushCandidate(lines, candidate, bullet, shared);
}
function pushCandidate(lines: string[], candidate: ReviewBoardCandidate, bullet: string, groupProvenance?: CandidateProvenance): void {
const rawReasonCodes = candidate.evidence.rawReasonCodes && candidate.evidence.rawReasonCodes.length > 0
? candidate.evidence.rawReasonCodes.join(", ")
: "none";
const question = candidate.reviewQuestions[0] ?? "What should a reviewer infer from this evidence?";
lines.push(` ${bullet} concern=${formatConcern(candidate.concernKind)} id=${candidate.id} source=${candidate.source} mechanism=${candidate.mechanism ?? "unspecified"} raw reason codes=${rawReasonCodes} question=${question}`);
if (candidate.provenance && (!groupProvenance || formatProvenance(candidate.provenance) !== formatProvenance(groupProvenance))) {
lines.push(` provenance: ${formatProvenance(candidate.provenance)}`);
}
if (candidate.evidence.eventIds && candidate.evidence.eventIds.length > 0) lines.push(` event ids: ${candidate.evidence.eventIds.join(", ")}`);
if (candidate.evidence.textAvailable) {
lines.push(` text preview: ${candidate.evidence.textPreview ?? "available but empty after redaction"}`);
} else {
lines.push(" text preview: unavailable in historical evidence");
}
lines.push(` facts: ${formatCandidateFacts(candidate.facts)}`);
pushHeuristicFlags(lines, candidate.heuristicFlags, " ", bullet);
}
function pushMemoryContentCandidates(lines: string[], report: ReviewBoardReport, bullet: string, options: { verbose?: boolean }): void {
const display = report.activeMemoryDisplay;
lines.push("Memory content review candidates");
if (report.reviewQuestions.memoryContent.length > 0) {
lines.push(" Standard review questions (applicable to all active memories below):");
for (const question of report.reviewQuestions.memoryContent) lines.push(` ${bullet} ${question}`);
}
if (display.total === 0) {
lines.push(" Active memories (none)");
return;
}
if (display.total <= display.threshold) {
lines.push(` Active memories (showing all ${display.total} because <= ${display.threshold})`);
} else if (display.mode === "all" || options.verbose) {
lines.push(` Active memories (showing all ${display.total} because --verbose)`);
} else {
lines.push(` Active memories (showing ${display.shown} of ${display.total})`);
lines.push(` Showing ${display.shown} of ${display.total} active memories. Use --verbose or --json for all active memory text.`);
}
display.items.forEach((item, index) => pushActiveMemory(lines, item, index + 1, bullet, report.reviewQuestions.memoryContent));
}
function pushActiveMemory(lines: string[], item: ReviewBoardActiveMemory, index: number, bullet: string, standardQuestions: string[]): void {
const strength = typeof item.strength === "number" ? item.strength.toFixed(3) : "unknown";
lines.push(` [${index}] id=${item.id} type=${item.type} source=${item.source} status=${item.status} strength=${strength}`);
lines.push(" text: " + indentContinuation(item.text, " "));
const rawReasonCodes = item.evidence.rawReasonCodes.length > 0 ? item.evidence.rawReasonCodes.join(", ") : "none";
lines.push(` evidence: events=${item.evidence.eventCount} raw reason codes=${rawReasonCodes}`);
if (item.provenance) lines.push(` provenance: ${formatProvenance(item.provenance)}`);
pushHeuristicFlags(lines, item.heuristicFlags, " ", bullet);
if (questionsEqual(item.reviewQuestions, standardQuestions)) return;
const additionalQuestions = item.reviewQuestions.filter(question => !standardQuestions.includes(question));
if (additionalQuestions.length > 0 && additionalQuestions.length < item.reviewQuestions.length) {
lines.push(" additional review questions:");
for (const question of additionalQuestions) lines.push(` ${bullet} ${question}`);
return;
}
lines.push(" review questions:");
for (const question of item.reviewQuestions) lines.push(` ${bullet} ${question}`);
}
function pushHeuristicFlags(lines: string[], flags: HeuristicFlag[], indent: string, bullet: string): void {
if (flags.length === 0) return;
lines.push(`${indent}heuristic flags:`);
for (const flag of flags) {
const caveat = flag.caveat || REVIEW_FLAG_CAVEAT;
lines.push(`${indent} ${bullet} ${flag.label}: ${flag.evidence}. ${caveat}`);
}
}
function pushReviewQuestions(lines: string[], report: ReviewBoardReport, bullet: string): void {
lines.push("Review questions");
lines.push(" SYSTEM MECHANISM");
for (const question of report.reviewQuestions.systemMechanism) lines.push(` ${bullet} ${question}`);
lines.push(" MEMORY CONTENT");
for (const question of report.reviewQuestions.memoryContent) lines.push(` ${bullet} ${question}`);
}
function pushNextCommands(lines: string[], report: ReviewBoardReport, bullet: string): void {
lines.push("Next commands");
for (const command of report.nextCommands) lines.push(` ${bullet} ${command}`);
}
function candidatesFor(report: ReviewBoardReport, sources: ReviewBoardCandidate["source"][]): ReviewBoardCandidate[] {
const sourceSet = new Set(sources);
return report.reviewCandidates.filter(candidate => candidate.concernKind === "system_mechanism" && sourceSet.has(candidate.source));
}
function sharedProvenance(candidates: ReviewBoardCandidate[]): CandidateProvenance | undefined {
if (candidates.length <= 1) return undefined;
const first = candidates[0]?.provenance;
if (!first) return undefined;
const key = formatProvenance(first);
return candidates.every(candidate => candidate.provenance && formatProvenance(candidate.provenance) === key) ? first : undefined;
}
function formatConcern(concern: ReviewBoardCandidate["concernKind"]): string {
return concern === "system_mechanism" ? "SYSTEM MECHANISM" : "MEMORY CONTENT";
}
function formatMigrationTimeline(timeline: ReviewBoardReport["provenanceContext"]["migrationTimeline"]): string {
if (timeline.length === 0) return "(none)";
return timeline.map(row => `${row.migrationId}=${row.presentInStore ? "present" : "absent"}${row.firstEvidenceAt ? ` firstEvidenceAt=${row.firstEvidenceAt}` : ""}`).join(", ");
}
function formatProvenanceCounts(counts: Record<ProvenanceClassification, number>): string {
return PROVENANCE_ORDER.map(classification => `${classification}=${counts[classification] ?? 0}`).join(", ");
}
function formatCounts(counts: Record<string, number>): string {
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
return entries.length === 0 ? "(none)" : entries.map(([key, count]) => `${key}=${count}`).join(", ");
}
function formatTypeCountsWithCaps(counts: Record<string, number>, caps: Record<string, number>): string {
const keys = uniqueSorted([...Object.keys(caps), ...Object.keys(counts)]);
return keys.length === 0 ? "(none)" : keys.map(key => `${key} ${counts[key] ?? 0}/${caps[key] ?? "?"}`).join(", ");
}
function formatFullCaps(fullCaps: string[], typeCounts: Record<string, number>, typeCaps: Record<string, number>, active: number, maxEntries: number): string {
if (fullCaps.length === 0) return "(none)";
return fullCaps.map(cap => cap === "global" ? `global ${active}/${maxEntries}` : `${cap} ${typeCounts[cap] ?? 0}/${typeCaps[cap] ?? "?"}`).join(", ");
}
function formatRepeatedBlocks(blocks: ReviewBoardReport["facts"]["systemMechanisms"]["reinforcementRules"]["repeatedBlocksByMemory"]): string {
if (blocks.length === 0) return "(none)";
return blocks.map(block => `${block.memoryId} count=${block.count} refs=${block.refs.join("|") || "none"} raw reason codes=${block.rawReasonCodes.join("|") || "none"}`).join(", ");
}
function formatHighestRankRemoved(snapshot: NonNullable<ReviewBoardReport["facts"]["systemMechanisms"]["evictionAndCaps"]["highestRankRemoved"]>): string {
const parts = [
`eventId=${snapshot.eventId}`,
snapshot.memoryId ? `memoryId=${snapshot.memoryId}` : undefined,
snapshot.type ? `type=${snapshot.type}` : undefined,
`rankAtRemoval=${snapshot.rankAtRemoval}`,
typeof snapshot.strengthAtRemoval === "number" ? `strengthAtRemoval=${snapshot.strengthAtRemoval}` : undefined,
].filter((part): part is string => Boolean(part));
return parts.join(" ");
}
function formatMemoryPreviews(items: ReviewBoardReport["facts"]["memoryContent"]["weakestActiveMemories"]): string {
if (items.length === 0) return "(none)";
return items.map(item => `${item.id} type=${item.type} strength=${typeof item.strength === "number" ? item.strength.toFixed(3) : "unknown"} text=${JSON.stringify(item.textPreview)}`).join(" | ");
}
function formatPercent(value: number): string {
return `${(Number.isFinite(value) ? value * 100 : 0).toFixed(1)}%`;
}
function formatCoveragePercent(value: number): string {
if (!Number.isFinite(value)) return "0%";
return Number.isInteger(value) ? `${value}%` : `${value.toFixed(1)}%`;
}
function formatInteger(value: number): string {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
}
function formatProvenance(provenance: CandidateProvenance): string {
return `${provenance.classification} confidence=${provenance.confidence}; basis=${provenance.basis.join("; ") || "unavailable"}; caveat=${provenance.interpretationCaveat}`;
}
function formatCandidateFacts(facts: Record<string, unknown>): string {
if (Object.keys(facts).length === 0) return "(none)";
return formatDetails(Object.fromEntries(
Object.entries(facts).map(([key, value]) => [key, formatFactValue(value)]),
));
}
function formatFactValue(value: unknown): string | number | boolean | string[] | undefined {
if (value === undefined) return undefined;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (value === null) return "null";
if (Array.isArray(value)) return value.map(item => typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null ? String(item) : stringifyUnknown(item));
return stringifyUnknown(value);
}
function stringifyUnknown(value: unknown): string {
try {
const serialized = JSON.stringify(value);
return serialized === undefined ? String(value) : serialized;
} catch {
return String(value);
}
}
function questionsEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function indentContinuation(text: string, indent: string): string {
return text.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values)].sort();
}
function redactUnknown(value: unknown): unknown {
if (typeof value === "string") return cleanText(value, false);
if (Array.isArray(value)) return value.map(item => redactUnknown(item));
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactUnknown(item)]));
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -27,6 +27,11 @@ export function normalizeRejection(record: RejectionLogRecord): NormalizedReject
source: record.source, source: record.source,
origin, origin,
fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger", fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger",
producerName: typeof record.producerName === "string" ? record.producerName : undefined,
producerVersion: typeof record.producerVersion === "string" ? record.producerVersion : undefined,
instrumentationVersion: typeof record.instrumentationVersion === "number" ? record.instrumentationVersion : undefined,
decisionLogicName: typeof record.decisionLogicName === "string" ? record.decisionLogicName : undefined,
decisionLogicVersion: typeof record.decisionLogicVersion === "number" ? record.decisionLogicVersion : undefined,
text: record.text, text: record.text,
reasons: record.reasons, reasons: record.reasons,
}; };
+10
View File
@@ -93,6 +93,11 @@ export type RejectionLogRecord = {
fromTrigger?: boolean; fromTrigger?: boolean;
text?: string; text?: string;
reasons?: string[]; reasons?: string[];
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
decisionLogicName?: string;
decisionLogicVersion?: number;
}; };
export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & { export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
@@ -102,6 +107,11 @@ export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp"
source?: string; source?: string;
origin: Origin; origin: Origin;
fromTrigger: boolean; fromTrigger: boolean;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
decisionLogicName?: string;
decisionLogicVersion?: number;
}; };
export type MigrationLogRecord = { export type MigrationLogRecord = {
+8 -1
View File
@@ -4,6 +4,7 @@ import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } fr
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts"; import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
import { redactCredentials } from "./redaction.ts"; import { redactCredentials } from "./redaction.ts";
import { producerFields } from "./instrumentation.ts";
export type EvidenceEventType = export type EvidenceEventType =
| "extraction_candidate_accepted" | "extraction_candidate_accepted"
@@ -95,6 +96,9 @@ export type EvidenceEventV1 = {
workspaceRootHash: string; workspaceRootHash: string;
sessionHash?: string; sessionHash?: string;
messageHash?: string; messageHash?: string;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
type: EvidenceEventType; type: EvidenceEventType;
phase: EvidencePhase; phase: EvidencePhase;
outcome: EvidenceOutcome; outcome: EvidenceOutcome;
@@ -273,7 +277,10 @@ function buildEvidenceEvent(
if (details) event.details = details; if (details) event.details = details;
if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax); if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax);
return event; return {
...event,
...producerFields(),
};
} }
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> { async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
+9
View File
@@ -7,6 +7,7 @@ import { assessMemoryQuality } from "./memory-quality.ts";
import { extractionRejectionLogPath } from "./paths.ts"; import { extractionRejectionLogPath } from "./paths.ts";
import { redactCredentials } from "./redaction.ts"; import { redactCredentials } from "./redaction.ts";
import type { EvidenceEventInput } from "./evidence-log.ts"; import type { EvidenceEventInput } from "./evidence-log.ts";
import { producerFields } from "./instrumentation.ts";
function id(prefix: string): string { function id(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -329,6 +330,11 @@ type ExtractionRejectionLogEntry = {
source: "compaction"; source: "compaction";
workspaceKey?: string; workspaceKey?: string;
workspaceRootHash?: string; workspaceRootHash?: string;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
decisionLogicName?: string;
decisionLogicVersion?: number;
}; };
type WorkspaceMemoryCandidateParseOptions = { type WorkspaceMemoryCandidateParseOptions = {
@@ -381,6 +387,9 @@ function evaluateWorkspaceMemoryCandidate(
source: "compaction", source: "compaction",
workspaceKey: options.workspaceKey, workspaceKey: options.workspaceKey,
workspaceRootHash: options.workspaceRootHash, workspaceRootHash: options.workspaceRootHash,
...producerFields(),
decisionLogicName: "assessMemoryQuality",
decisionLogicVersion: 1,
}); });
return { accepted: false, reasons: quality.reasons }; return { accepted: false, reasons: quality.reasons };
} }
+41
View File
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
let cachedVersion: string | undefined;
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
const MEMORY_INSTRUMENTATION_VERSION = 2;
function producerVersion(): string {
if (cachedVersion) return cachedVersion;
try {
const candidates = [
join(__dirname, "..", "package.json"),
join(__dirname, "..", "..", "package.json"),
// resolve from compiled dist/src/ -> repo root
];
for (const path of candidates) {
try {
const pkg = JSON.parse(readFileSync(path, "utf8"));
cachedVersion = pkg.version as string;
break;
} catch {
// try next
}
}
if (!cachedVersion) cachedVersion = "unknown";
} catch {
cachedVersion = "unknown";
}
return cachedVersion;
}
export function producerFields(): { producerName: string; producerVersion: string; instrumentationVersion: number } {
return {
producerName: MEMORY_PRODUCER_NAME,
producerVersion: producerVersion(),
instrumentationVersion: MEMORY_INSTRUMENTATION_VERSION,
};
}
+20 -5
View File
@@ -44,7 +44,7 @@ import {
workspaceMemoryExactKey, workspaceMemoryExactKey,
workspaceMemoryIdentityKey, workspaceMemoryIdentityKey,
} from "./workspace-memory.ts"; } from "./workspace-memory.ts";
import { reinforceMemory } from "./retention.ts"; import { tryReinforceMemory } from "./retention.ts";
import { import {
appendPendingMemories, appendPendingMemories,
clearPendingMemories, clearPendingMemories,
@@ -429,17 +429,31 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const { refSnapshot, target, targetIndex } = resolution; const { refSnapshot, target, targetIndex } = resolution;
if (command.kind === "REINFORCE") { if (command.kind === "REINFORCE") {
const reinforced = reinforceMemory(target, sessionID, now); const decision = tryReinforceMemory(target, sessionID, now);
if (reinforced === target) { if (decision.outcome === "blocked") {
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked"], { evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], {
memoryId: refSnapshot.memoryId, memoryId: refSnapshot.memoryId,
blockReason: decision.blockReason,
attemptedAtMs: now,
attemptedAtIso: new Date(now).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
})); }));
continue; continue;
} }
const reinforced = decision.memory;
workspaceMemory.entries[targetIndex] = reinforced; workspaceMemory.entries[targetIndex] = reinforced;
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], { evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
memoryId: refSnapshot.memoryId, memoryId: refSnapshot.memoryId,
reinforcementOutcome: "reinforced",
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
})); }));
continue; continue;
} }
@@ -662,11 +676,12 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const key = memoryKey(memory); const key = memoryKey(memory);
const existing = existingByKey.get(key); const existing = existingByKey.get(key);
if (existing) { if (existing) {
const reinforced = reinforceMemory( const decision = tryReinforceMemory(
existing.memory, existing.memory,
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion", sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
promotedAt, promotedAt,
); );
const reinforced = decision.memory;
if (reinforced !== existing.memory) { if (reinforced !== existing.memory) {
workspaceMemory.entries[existing.index] = reinforced; workspaceMemory.entries[existing.index] = reinforced;
existingByKey.set(key, { memory: reinforced, index: existing.index }); existingByKey.set(key, { memory: reinforced, index: existing.index });
+45 -13
View File
@@ -1,5 +1,11 @@
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
export type ReinforcementBlockReason = "same_session" | "same_utc_day" | "min_interval" | "max_count";
export type ReinforcementDecision =
| { outcome: "reinforced"; memory: LongTermMemoryEntry; previousReinforcementCount: number; newReinforcementCount: number }
| { outcome: "blocked"; memory: LongTermMemoryEntry; blockReason: ReinforcementBlockReason; lastReinforcedAt?: number; reinforcementCount: number; maxReinforcementCount: number; minIntervalMs: number };
// Retention decay model constants (v1.5) // Retention decay model constants (v1.5)
export const BASE_HALF_LIFE_DAYS = 45; export const BASE_HALF_LIFE_DAYS = 45;
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85; export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
@@ -116,33 +122,59 @@ function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
&& d1.getUTCDate() === d2.getUTCDate(); && d1.getUTCDate() === d2.getUTCDate();
} }
export function reinforceMemory( export function tryReinforceMemory(
memory: LongTermMemoryEntry, memory: LongTermMemoryEntry,
sessionId: string, sessionId: string,
now: number, now: number,
): LongTermMemoryEntry { ): ReinforcementDecision {
if (memory.lastReinforcedSessionID === sessionId) { const count = memory.reinforcementCount ?? 0;
return memory; const lastAt = memory.lastReinforcedAt ?? 0;
const lastSession = memory.lastReinforcedSessionID;
if (lastSession === sessionId) {
return blockedDecision(memory, "same_session", count, lastAt);
} }
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement. if (count >= REINFORCEMENT_MAX_COUNT) {
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) { return blockedDecision(memory, "max_count", count, lastAt);
return memory;
} }
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) { if (lastAt > 0 && now < lastAt + REINFORCEMENT_MIN_INTERVAL_MS) {
return memory; return blockedDecision(memory, "min_interval", count, lastAt);
} }
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) { if (lastAt > 0 && isSameUTCCalendarDay(lastAt, now)) {
return memory; return blockedDecision(memory, "same_utc_day", count, lastAt);
} }
return { const reinforced: LongTermMemoryEntry = {
...memory, ...memory,
reinforcementCount: (memory.reinforcementCount ?? 0) + 1, reinforcementCount: count + 1,
lastReinforcedAt: now, lastReinforcedAt: now,
lastReinforcedSessionID: sessionId, lastReinforcedSessionID: sessionId,
retentionClock: now, retentionClock: now,
}; };
return {
outcome: "reinforced",
memory: reinforced,
previousReinforcementCount: count,
newReinforcementCount: count + 1,
};
}
function blockedDecision(
memory: LongTermMemoryEntry,
blockReason: ReinforcementBlockReason,
reinforcementCount: number,
lastReinforcedAt: number,
): ReinforcementDecision {
return {
outcome: "blocked",
memory,
blockReason,
...(lastReinforcedAt > 0 ? { lastReinforcedAt } : {}),
reinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
minIntervalMs: REINFORCEMENT_MIN_INTERVAL_MS,
};
} }
+79 -10
View File
@@ -9,7 +9,8 @@ import { redactCredentials } from "./redaction.ts";
import { import {
RETENTION_TYPE_MAX, RETENTION_TYPE_MAX,
calculateRetentionStrength, calculateRetentionStrength,
reinforceMemory, tryReinforceMemory,
type ReinforcementDecision,
} 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"; import { appendEvidenceEvents } from "./evidence-log.ts";
@@ -559,7 +560,13 @@ function consolidationEvent(
function capacityRemovalEvidence( function capacityRemovalEvidence(
memory: LongTermMemoryEntry, memory: LongTermMemoryEntry,
reason: "type_cap" | "global_cap" | "capacity", reason: "type_cap" | "global_cap",
details: {
strengthAtRemoval: number;
rankAtRemoval: number;
typeRankAtRemoval: number;
ageDaysAtRemoval: number;
},
): EvidenceEventInput { ): EvidenceEventInput {
return { return {
type: "memory_removed_capacity", type: "memory_removed_capacity",
@@ -578,6 +585,7 @@ function capacityRemovalEvidence(
...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}), ...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}),
...(memory.createdAt ? { createdAt: memory.createdAt } : {}), ...(memory.createdAt ? { createdAt: memory.createdAt } : {}),
...(memory.source ? { source: memory.source } : {}), ...(memory.source ? { source: memory.source } : {}),
...details,
}, },
}; };
} }
@@ -664,8 +672,8 @@ export function enforceLongTermLimitsWithAccounting(
const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id)); const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id));
const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id)); const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id));
const capacityEvidence: EvidenceEventInput[] = [ const capacityEvidence: EvidenceEventInput[] = [
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap")), ...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap")), ...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
]; ];
const capacityDropped = sorted const capacityDropped = sorted
.filter(entry => !keptIds.has(entry.id)) .filter(entry => !keptIds.has(entry.id))
@@ -680,6 +688,28 @@ export function enforceLongTermLimitsWithAccounting(
}; };
} }
function capacityRemovalSnapshot(
memory: LongTermMemoryEntry,
sorted: LongTermMemoryEntry[],
now: number,
lastActivityAt?: string,
): {
strengthAtRemoval: number;
rankAtRemoval: number;
typeRankAtRemoval: number;
ageDaysAtRemoval: number;
} {
const createdAtMs = new Date(memory.createdAt).getTime();
const rank = sorted.findIndex(entry => entry.id === memory.id);
const typeRank = sorted.filter(entry => entry.type === memory.type).findIndex(entry => entry.id === memory.id);
return {
strengthAtRemoval: calculateRetentionStrength(memory, now, lastActivityAt),
rankAtRemoval: rank >= 0 ? rank + 1 : -1,
typeRankAtRemoval: typeRank >= 0 ? typeRank + 1 : -1,
ageDaysAtRemoval: Number.isFinite(createdAtMs) ? Math.floor(Math.max(0, now - createdAtMs) / 86_400_000) : 0,
};
}
function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] { function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
return applyTypeMaxCapsWithOmissions(entries).kept; return applyTypeMaxCapsWithOmissions(entries).kept;
} }
@@ -732,12 +762,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing) const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const ? "absorbed_exact" as const
: "absorbed_identity" as const; : "absorbed_identity" as const;
const reinforced = reinforceMemory( const decision = tryReinforceMemory(
retained, retained,
reinforcementSessionId(retained, dropped), reinforcementSessionId(retained, dropped),
now, now,
); );
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason); const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
if (reinforcedEvent) evidence.push(reinforcedEvent); if (reinforcedEvent) evidence.push(reinforcedEvent);
absorbed.push(consolidationEvent(dropped, reason, reinforced)); absorbed.push(consolidationEvent(dropped, reason, reinforced));
@@ -760,12 +791,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing) const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const ? "absorbed_exact" as const
: "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs : "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs
const reinforced = reinforceMemory( const decision = tryReinforceMemory(
retained, retained,
reinforcementSessionId(retained, dropped), reinforcementSessionId(retained, dropped),
now, now,
); );
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason); const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
if (reinforcedEvent) evidence.push(reinforcedEvent); if (reinforcedEvent) evidence.push(reinforcedEvent);
if (reason === "superseded_existing") { if (reason === "superseded_existing") {
@@ -807,11 +839,41 @@ function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
function reinforcementEvidence( function reinforcementEvidence(
retained: LongTermMemoryEntry, retained: LongTermMemoryEntry,
dropped: LongTermMemoryEntry, dropped: LongTermMemoryEntry,
reinforced: LongTermMemoryEntry, decision: ReinforcementDecision,
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing", reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
attemptedAt: number,
): EvidenceEventInput | undefined { ): EvidenceEventInput | undefined {
if ((reinforced.reinforcementCount ?? 0) <= (retained.reinforcementCount ?? 0)) return undefined;
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact"; const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
if (decision.outcome === "blocked") {
return {
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: memoryEvidenceRef(retained),
relations: [
{ role: "target", memory: memoryEvidenceRef(retained) },
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`],
details: {
memoryId: retained.id,
droppedMemoryId: dropped.id,
blockReason: decision.blockReason,
attemptedAtMs: attemptedAt,
attemptedAtIso: new Date(attemptedAt).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
},
textPreview: retained.text,
};
}
const reinforced = decision.memory;
return { return {
type: "memory_reinforced", type: "memory_reinforced",
phase: "reinforcement", phase: "reinforcement",
@@ -822,6 +884,13 @@ function reinforcementEvidence(
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) }, { role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
], ],
reasonCodes: [duplicateReason, "reinforcement_window_allowed"], reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
details: {
memoryId: reinforced.id,
droppedMemoryId: dropped.id,
reinforcementOutcome: "reinforced",
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
},
textPreview: reinforced.text, textPreview: reinforced.text,
}; };
} }
+48 -2
View File
@@ -9,9 +9,10 @@ test("help returns usage without exposing hidden or removed commands", () => {
assert.equal("help" in parsed && parsed.help, true); assert.equal("help" in parsed && parsed.help, true);
assert.match(parsed.usage, /Usage:/); assert.match(parsed.usage, /Usage:/);
assert.match(parsed.usage, /memory-diag \[status\]/); assert.match(parsed.usage, /memory-diag \[status\]/);
assert.match(parsed.usage, /memory-diag quality/);
assert.match(parsed.usage, /memory-diag commands/); assert.match(parsed.usage, /memory-diag commands/);
assert.match(parsed.usage, /memory-diag revert/); assert.match(parsed.usage, /memory-diag revert/);
for (const command of ["health", "quality", "rejections", "disappearances", "trace", "coverage", "audit"]) { for (const command of ["health", "rejections", "disappearances", "trace", "coverage", "audit"]) {
assert.doesNotMatch(parsed.usage, new RegExp(command)); assert.doesNotMatch(parsed.usage, new RegExp(command));
} }
}); });
@@ -35,7 +36,7 @@ test("unknown command returns usage error", () => {
}); });
test("removed legacy aliases are ordinary unknown subcommands", () => { test("removed legacy aliases are ordinary unknown subcommands", () => {
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) { for (const command of ["health", "rejections", "disappearances", "trace"]) {
const parsed = parseArgs([command]); const parsed = parseArgs([command]);
assert.equal(parsed.ok, false, command); assert.equal(parsed.ok, false, command);
@@ -45,6 +46,41 @@ test("removed legacy aliases are ordinary unknown subcommands", () => {
} }
}); });
test("quality accepts read-only workspace json and display flags", () => {
const parsed = parseArgs(["quality", "--workspace", "/tmp/workspace", "--json", "--verbose", "--raw", "--no-emoji"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "quality");
assert.equal("options" in parsed && parsed.options.workspace, "/tmp/workspace");
assert.equal("options" in parsed && parsed.options.json, true);
assert.equal("options" in parsed && parsed.options.verbose, true);
assert.equal("options" in parsed && parsed.options.raw, true);
assert.equal("options" in parsed && parsed.options.noEmoji, true);
});
test("quality rejects mutation filter and drill-down flags", () => {
const cases: Array<{ args: string[]; message: string }> = [
{ args: ["quality", "--all"], message: "quality does not accept --all" },
{ args: ["quality", "--apply"], message: "quality does not accept --apply" },
{ args: ["quality", "--memory", "mem-1"], message: "quality does not accept --memory" },
{ args: ["quality", "--event", "evt-1"], message: "quality does not accept --event" },
{ args: ["quality", "--reason", "bad_decision"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--since", "7d"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--soft-only"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--trigger-only"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--include-historical"], message: "quality does not accept --include-historical" },
{ args: ["quality", "--explain"], message: "quality does not accept --explain" },
];
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("hidden maintainer commands are accepted with neutral notices", () => { test("hidden maintainer commands are accepted with neutral notices", () => {
const coverage = parseArgs(["coverage"]); const coverage = parseArgs(["coverage"]);
assert.equal(coverage.ok, true); assert.equal(coverage.ok, true);
@@ -90,6 +126,16 @@ test("commands accepts workspace json and verbose flags", () => {
assert.equal("options" in parsed && parsed.options.verbose, true); assert.equal("options" in parsed && parsed.options.verbose, true);
}); });
test("commands accepts memory drill-down selector", () => {
const parsed = parseArgs(["commands", "--workspace", "/tmp/workspace", "--memory", "mem-1", "--json", "--verbose"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "commands");
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
assert.equal("options" in parsed && parsed.options.json, true);
assert.equal("options" in parsed && parsed.options.verbose, true);
});
test("revert accepts memory or event selectors and apply flag", () => { test("revert accepts memory or event selectors and apply flag", () => {
const byMemory = parseArgs(["revert", "--memory", "mem-new", "--workspace", "/tmp/workspace", "--apply"]); const byMemory = parseArgs(["revert", "--memory", "mem-new", "--workspace", "/tmp/workspace", "--apply"]);
assert.equal(byMemory.ok, true); assert.equal(byMemory.ok, true);
File diff suppressed because it is too large Load Diff
+156 -1
View File
@@ -123,6 +123,74 @@ function replacementEvidence(original: LongTermMemoryEntry, replacement: LongTer
}); });
} }
const attributionSafetyTerms = [
"b" + "ug",
"fix" + "ed",
"incor" + "rect",
"wrong" + "ly blocked",
"caused memory" + " loss",
"prevent" + "ed retention",
"should" + " allow",
"policy" + " failure",
"regres" + "sion",
];
function assertNoAttributionSafetyTerms(text: string): void {
for (const term of attributionSafetyTerms) {
assert.doesNotMatch(text, new RegExp(term, "i"));
}
}
async function setupMemoryCommandDetailFixture(root: string): Promise<void> {
await writeWorkspaceStore(root, [
{ ...entry("mem-detail", "Detail drill-down memory remains current", "decision"), source: "explicit" as const },
]);
await appendEvidenceEvents(root, [
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"],
details: { ref: "M3", attemptedAtIso: "2026-05-12T23:55:00.000Z", reinforcedAtIso: "2026-05-12T23:55:01.000Z" },
sessionHash: "command-session-one",
messageHash: "command-message-one",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_same_session"],
details: {
ref: "M3",
blockReason: "same_session",
attemptedAtIso: "2026-05-13T00:05:00.000Z",
lastReinforcedAtIso: "2026-05-12T23:55:01.000Z",
},
sessionHash: "command-session-one",
messageHash: "command-message-two",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked"],
details: { ref: "M3", attemptedAtIso: "2026-05-13T00:06:00.000Z" },
sessionHash: "command-session-two",
messageHash: "command-message-three",
}),
evidence({
type: "render_selected",
phase: "render",
outcome: "rendered",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["within_caps", "within_char_budget"],
}),
]);
}
test("status handles missing workspace store as empty", async () => { test("status handles missing workspace store as empty", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-")); const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-"));
try { try {
@@ -214,7 +282,7 @@ test("memory-diag defaults to status when no subcommand is supplied", async () =
test("removed legacy aliases return unknown subcommand", async () => { test("removed legacy aliases return unknown subcommand", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-")); const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-"));
try { try {
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) { for (const command of ["health", "rejections", "disappearances", "trace"]) {
await assert.rejects( await assert.rejects(
runMemoryDiagResult([command, "--workspace", root]), runMemoryDiagResult([command, "--workspace", root]),
(error: unknown) => { (error: unknown) => {
@@ -402,6 +470,93 @@ test("memory-diag commands json exposes protected replacement counts", async ()
} }
}); });
test("memory-diag commands memory selector prints reinforcement detail", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-memory-"));
try {
await setupMemoryCommandDetailFixture(root);
const stdout = await runMemoryDiag(["commands", "--memory", "mem-detail", "--workspace", root]);
assert.match(stdout, /Memory command diagnostics for mem-detail/);
assert.match(stdout, /Current memory:/);
assert.match(stdout, /status: active/);
assert.match(stdout, /render: rendered/);
assert.match(stdout, /Reinforcement summary:/);
assert.match(stdout, /attempts: 3/);
assert.match(stdout, /reinforced: 1/);
assert.match(stdout, /rejected\/blocked: 2/);
assert.match(stdout, /window blocked: 2/);
assert.match(stdout, /block reasons: same_session=1, unknown=1/);
assert.match(stdout, /block details missing: 1/);
assert.match(stdout, /same-session cross UTC day blocks: 1/);
assert.match(stdout, /refs: M3/);
assert.match(stdout, /crossUtcDay=yes/);
assert.doesNotMatch(stdout, /render_selected/);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag commands memory selector emits stable JSON", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-memory-json-"));
try {
await setupMemoryCommandDetailFixture(root);
const stdout = await runMemoryDiag(["commands", "--memory", "mem-detail", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as {
version: 1;
memoryId: string;
current: { present: boolean; status?: string; renderStatus?: string };
summary: {
attempts: number;
reinforced: number;
rejectedOrBlocked: number;
blocksByReason: Record<string, number>;
blockDetailsMissing: number;
sameSessionCrossUtcDayBlocks: number;
};
events: Array<{ eventId: string; outcome: string; blockReason?: string; crossUtcDay?: boolean | "unknown" }>;
};
assert.equal(parsed.version, 1);
assert.equal(parsed.memoryId, "mem-detail");
assert.equal(parsed.current.present, true);
assert.equal(parsed.current.status, "active");
assert.equal(parsed.current.renderStatus, "rendered");
assert.equal(parsed.summary.attempts, 3);
assert.equal(parsed.summary.reinforced, 1);
assert.equal(parsed.summary.rejectedOrBlocked, 2);
assert.equal(parsed.summary.blocksByReason.same_session, 1);
assert.equal(parsed.summary.blocksByReason.unknown, 1);
assert.equal(parsed.summary.blockDetailsMissing, 1);
assert.equal(parsed.summary.sameSessionCrossUtcDayBlocks, 1);
assert.equal(parsed.events.some(event => event.blockReason === "same_session" && event.crossUtcDay === true), true);
assert.equal(parsed.events.some(event => event.blockReason === "unknown" && event.crossUtcDay === "unknown"), true);
assert.equal(JSON.stringify(parsed).includes("command-session"), false);
assert.equal(JSON.stringify(parsed).includes("command-message"), false);
assert.equal(JSON.stringify(parsed).includes("Detail drill-down memory remains current"), false);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag commands memory selector reports empty evidence neutrally", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-memory-empty-"));
try {
await writeWorkspaceStore(root, [entry("mem-empty", "Memory without reinforcement command evidence", "feedback")]);
const stdout = await runMemoryDiag(["commands", "--memory", "mem-empty", "--workspace", root]);
assert.match(stdout, /Memory command diagnostics for mem-empty/);
assert.match(stdout, /No reinforcement command evidence found for mem-empty\./);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag revert dry-run plans changes without mutating", async () => { test("memory-diag revert dry-run plans changes without mutating", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-dry-run-")); const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-dry-run-"));
try { try {
+103
View File
@@ -0,0 +1,103 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
REINFORCEMENT_MAX_COUNT,
REINFORCEMENT_MIN_INTERVAL_MS,
tryReinforceMemory,
} from "../src/retention.ts";
import type { LongTermMemoryEntry } from "../src/types.ts";
const baseMemory = (overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry => ({
id: "mem-retention",
type: "decision",
text: "Durable decision for reinforcement",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: "2026-05-10T00:00:00.000Z",
updatedAt: "2026-05-10T00:00:00.000Z",
...overrides,
});
test("tryReinforceMemory blocks same session with exact reason", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: now - 2 * REINFORCEMENT_MIN_INTERVAL_MS,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-a", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "same_session");
assert.equal(decision.memory, memory);
});
test("tryReinforceMemory blocks different session on same UTC day", () => {
const lastAt = Date.UTC(2026, 4, 12, 0, 15, 0);
const now = Date.UTC(2026, 4, 12, 23, 30, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "same_utc_day");
assert.equal(decision.lastReinforcedAt, lastAt);
});
test("tryReinforceMemory blocks min interval across UTC day boundary", () => {
const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0);
const now = Date.UTC(2026, 4, 13, 0, 15, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_interval");
assert.equal(decision.minIntervalMs, REINFORCEMENT_MIN_INTERVAL_MS);
});
test("tryReinforceMemory blocks max count with exact reason", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0),
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "max_count");
assert.equal(decision.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.maxReinforcementCount, REINFORCEMENT_MAX_COUNT);
});
test("tryReinforceMemory reinforces allowed memory and wrapper returns memory only", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0),
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.previousReinforcementCount, 1);
assert.equal(decision.newReinforcementCount, 2);
assert.notEqual(decision.memory, memory);
assert.equal(decision.memory.reinforcementCount, 2);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.memory.retentionClock, now);
});
+44 -11
View File
@@ -30,7 +30,7 @@ import {
calculateRetentionStrength, calculateRetentionStrength,
calculateDormantDays, calculateDormantDays,
calculateEffectiveAgeDays, calculateEffectiveAgeDays,
reinforceMemory, tryReinforceMemory,
} from "../src/retention.ts"; } from "../src/retention.ts";
import { redactCredentials } from "../src/redaction.ts"; import { redactCredentials } from "../src/redaction.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
@@ -583,7 +583,7 @@ test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for str
test("reinforceMemory enforces session interval and max guards", () => { test("reinforceMemory enforces session interval and max guards", () => {
const now = Date.UTC(2026, 3, 29); const now = Date.UTC(2026, 3, 29);
const base = entry("reinforce", "Durable memory should reinforce only when gated"); const base = entry("reinforce", "Durable memory should reinforce only when gated");
const reinforced = reinforceMemory(base, "session-a", now); const reinforced = tryReinforceMemory(base, "session-a", now).memory;
assert.notEqual(reinforced, base); assert.notEqual(reinforced, base);
assert.equal(reinforced.reinforcementCount, 1); assert.equal(reinforced.reinforcementCount, 1);
@@ -591,15 +591,15 @@ test("reinforceMemory enforces session interval and max guards", () => {
assert.equal(reinforced.lastReinforcedSessionID, "session-a"); assert.equal(reinforced.lastReinforcedSessionID, "session-a");
assert.equal(reinforced.retentionClock, now); assert.equal(reinforced.retentionClock, now);
assert.equal(reinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000), reinforced); assert.equal(tryReinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000).memory, reinforced);
assert.equal(reinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000), reinforced); assert.equal(tryReinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000).memory, reinforced);
const atMax: LongTermMemoryEntry = { const atMax: LongTermMemoryEntry = {
...base, ...base,
reinforcementCount: 6, reinforcementCount: 6,
lastReinforcedAt: now - 2 * 60 * 60 * 1000, lastReinforcedAt: now - 2 * 60 * 60 * 1000,
}; };
assert.equal(reinforceMemory(atMax, "session-c", now), atMax); assert.equal(tryReinforceMemory(atMax, "session-c", now).memory, atMax);
}); });
test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => { test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => {
@@ -613,9 +613,9 @@ test("reinforceMemory requires distinct UTC calendar days between reinforcements
lastReinforcedSessionID: "session-a", lastReinforcedSessionID: "session-a",
}; };
assert.equal(reinforceMemory(base, "session-b", sameUtcDayMuchLater), base); assert.equal(tryReinforceMemory(base, "session-b", sameUtcDayMuchLater).memory, base);
const reinforcedNextDay = reinforceMemory(base, "session-b", nextUtcDayAfterInterval); const reinforcedNextDay = tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory;
assert.notEqual(reinforcedNextDay, base); assert.notEqual(reinforcedNextDay, base);
assert.equal(reinforcedNextDay.reinforcementCount, 2); assert.equal(reinforcedNextDay.reinforcementCount, 2);
assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval); assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval);
@@ -626,7 +626,7 @@ test("reinforceMemory requires distinct UTC calendar days between reinforcements
...base, ...base,
reinforcementCount: 6, reinforcementCount: 6,
}; };
assert.equal(reinforceMemory(atMax, "session-c", nextUtcDayAfterInterval), atMax); assert.equal(tryReinforceMemory(atMax, "session-c", nextUtcDayAfterInterval).memory, atMax);
}); });
test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => { test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => {
@@ -737,7 +737,34 @@ test("dedupe reinforcement does not increment for same session", () => {
assert.ok(retained, "existing manual memory should be retained"); assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1); assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "same-session"); assert.equal(retained.lastReinforcedSessionID, "same-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false); assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "same_session"));
});
test("dedupe blocked reinforcement emits exact block reason details", () => {
const now = Date.now();
const existing: LongTermMemoryEntry = {
...entry("existing-blocked", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 1,
lastReinforcedAt: now - 30 * 60 * 1000,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-blocked", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
assert.ok(blocked, "blocked duplicate reinforcement should emit diagnostic evidence");
assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked"));
assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_interval"));
assert.equal(blocked.details?.blockReason, "min_interval");
assert.equal(blocked.details?.reinforcementCount, 1);
assert.equal(blocked.details?.maxReinforcementCount, 6);
assert.equal(blocked.details?.minIntervalMs, 60 * 60 * 1000);
}); });
test("dedupe reinforcement does not increment under one hour", () => { test("dedupe reinforcement does not increment under one hour", () => {
@@ -760,7 +787,8 @@ test("dedupe reinforcement does not increment under one hour", () => {
assert.ok(retained, "existing manual memory should be retained"); assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1); assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "old-session"); assert.equal(retained.lastReinforcedSessionID, "old-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false); assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "min_interval"));
}); });
test("dedupe reinforcement does not emit evidence at max reinforcement count", () => { test("dedupe reinforcement does not emit evidence at max reinforcement count", () => {
@@ -776,7 +804,8 @@ test("dedupe reinforcement does not emit evidence at max reinforcement count", (
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]); const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false); assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "max_count"));
}); });
test("enforceLongTermLimits orders entries by retention strength", () => { test("enforceLongTermLimits orders entries by retention strength", () => {
@@ -2644,6 +2673,10 @@ test("enforceLongTermLimitsWithAccounting keeps 11th and 12th decisions and type
assert.ok(event.relations?.[0]?.memory.memoryKeyHash, "removed relation should include memory key hash"); 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.ok(event.relations?.[0]?.memory.identityKeyHash, "removed relation should include identity key hash");
assert.deepEqual(event.reasonCodes, ["type_cap"]); assert.deepEqual(event.reasonCodes, ["type_cap"]);
assert.equal(typeof event.details?.strengthAtRemoval, "number");
assert.equal(typeof event.details?.rankAtRemoval, "number");
assert.equal(typeof event.details?.typeRankAtRemoval, "number");
assert.equal(typeof event.details?.ageDaysAtRemoval, "number");
} }
}); });