mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a480b734b2 | |||
| 5163ea3b8f | |||
| 9591f85dca | |||
| 93550b2e41 | |||
| 3c4282b241 | |||
| 5bca3432b0 | |||
| e4dfe81d89 | |||
| 9b6955f490 |
@@ -5,6 +5,50 @@ 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.6.4] - 2026-05-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced same-session reinforcement blocking with a rolling 7-day elapsed reinforcement window so long-lived OpenCode sessions can reinforce durable memories after meaningful weekly recurrence.
|
||||
- Kept the 45-day base half-life while changing the max reinforcement count to a growth saturation point: memories at count 6 can refresh retention timestamps weekly without increasing count or effective half-life.
|
||||
- Bumped memory evidence instrumentation to version 3 for the new elapsed-window and refresh-only reinforcement semantics.
|
||||
- Updated `memory-diag commands --memory` to show elapsed-window details, `sameSession` evidence, `reinforcementMode`, and legacy missing timestamp markers without exposing raw session IDs.
|
||||
- Updated `memory-diag quality` to keep historical `same_session` block accounting while preventing new `sameSession` evidence from triggering old same-session diagnostic questions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented long-lived sessions from indefinitely blocking reinforcement solely because the session ID stayed the same across days.
|
||||
- Prevented saturated memories from growing stronger beyond the max reinforcement count while still allowing continued weekly use to keep them fresh.
|
||||
- Preserved historical block reason compatibility for `same_session`, `same_utc_day`, `min_interval`, and `max_count` without producing those reasons from the new policy path.
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the published `memory-diag` npm bin by compiling the diagnostics CLI before packing and launching the compiled JavaScript runtime instead of type-stripping TypeScript under `node_modules`.
|
||||
|
||||
### Added
|
||||
|
||||
- Added a pack/npx smoke test that runs `memory-diag --help` from a packed tarball outside the repository.
|
||||
|
||||
## [1.6.1] - 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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 missing
|
||||
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.
|
||||
@@ -266,14 +267,6 @@ Default behavior:
|
||||
|
||||
See [Configuration](docs/configuration.md) for customization options.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Current focus:
|
||||
|
||||
- Add explicit delete tombstones so removed memories do not get re-extracted.
|
||||
- Monitor numbered refs and protected replacements with `memory-diag commands` before tightening automatic replacement policy further.
|
||||
- Explore tiered hot/warm/cold storage after the retention model has more real-world data.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture Overview](docs/architecture.md)
|
||||
@@ -284,7 +277,7 @@ Current focus:
|
||||
## Requirements
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,5 +1,115 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.6.4 (2026-05-15)
|
||||
|
||||
### Rolling Weekly Reinforcement
|
||||
|
||||
This patch release fixes the reinforcement policy for users who work in long-lived OpenCode sessions. Reinforcement no longer treats `same_session` as a hard block. Instead, each memory uses a rolling 7-day elapsed window, so recurring preferences can be reinforced after meaningful weekly use even when the session stays open.
|
||||
|
||||
The base retention half-life remains 45 days. The max reinforcement count remains 6, but it now acts as a growth saturation point rather than a lifetime hard stop.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **7-day rolling window**: repeated reinforcement is allowed once 7 rolling days have elapsed since the memory's last reinforcement; 7 days minus 1ms still blocks.
|
||||
- **Same-session as evidence**: `sameSession` is recorded for diagnostics but no longer blocks reinforcement by itself.
|
||||
- **Refresh-only saturation**: memories at reinforcement count 6 can refresh `retentionClock`, `lastReinforcedAt`, and session evidence after the weekly window without increasing count or effective half-life.
|
||||
- **Instrumentation v3**: new reinforcement evidence records elapsed-window fields, `sameSession`, `reinforcementMode`, and legacy missing timestamp markers.
|
||||
- **Diagnostics updated**: `memory-diag commands --memory` exposes the new fields, while `memory-diag quality` keeps historical `same_session` block analysis separate from new same-session evidence.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes are required.
|
||||
- Existing workspace memory files and evidence logs remain compatible.
|
||||
- Historical diagnostics may still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, or `max_count`; new instrumentation-version-3 events use the rolling elapsed-window semantics.
|
||||
- Consumers of `memory-diag commands --memory --json` should use `reinforcementMode` to distinguish count-increment reinforcement from refresh-only saturation.
|
||||
|
||||
### Validation
|
||||
|
||||
- `node --test --experimental-strip-types tests/retention.test.ts` — 10 tests passing
|
||||
- `node --test --experimental-strip-types tests/workspace-memory.test.ts tests/plugin.test.ts` — 181 tests passing
|
||||
- `node --test --experimental-strip-types tests/memory-diag.test.ts tests/memory-diag-quality.test.ts` — 93 tests passing
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 498 tests passing, `TEST_PASS`
|
||||
- `npm run build` — `BUILD_PASS`
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
### Published `memory-diag` Bin Fix
|
||||
|
||||
This patch release fixes the published npm package path for `memory-diag`. In v1.6.1 the source-tree CLI tests passed, but the installed package could fail under `npx --package opencode-working-memory memory-diag` because Node refuses TypeScript type stripping for files inside `node_modules`.
|
||||
|
||||
v1.6.2 compiles the diagnostics CLI during packing and makes the npm bin launch the compiled JavaScript runtime.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Compiled diagnostics runtime**: `prepack` now builds `dist/scripts/memory-diag.js` before the package is packed or published.
|
||||
- **Safer npm bin wrapper**: `memory-diag` no longer runs published `.ts` files through `--experimental-strip-types`; it launches the compiled JS artifact and reports a clear reinstall/build message if the artifact is missing.
|
||||
- **Packaged-bin smoke test**: release verification now includes a pack/npx smoke test from a temp consumer project outside the repository.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No config changes are required.
|
||||
- Existing OpenCode server and TUI plugin entry points are unchanged.
|
||||
- If you hit the v1.6.1 bin failure, upgrade and rerun:
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory@1.6.2 memory-diag --help
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run build` — `BUILD_PASS`
|
||||
- `node ./scripts/memory-diag-bin.cjs --help`
|
||||
- `npm run test:pack:memory-diag` — packed tarball smoke test passed
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 421 tests passing, `TEST_PASS`
|
||||
- `npm pack --dry-run` — includes compiled `dist/` diagnostics artifacts
|
||||
|
||||
---
|
||||
|
||||
## 1.6.1 (2026-05-08)
|
||||
|
||||
### Native TUI Memory Menu
|
||||
|
||||
+25
-1
@@ -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 added optional causal block details for diagnostics without backfilling old JSONL records. Instrumentation version 3 adds elapsed-window reinforcement details such as `details.elapsedMs`, `details.requiredElapsedMs`, `details.sameSession`, `details.reinforcementMode`, and `details.legacyMissingTimestamp`. Historical reinforcement-block events may still include older `details.blockReason` values such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or may have missing block details. 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
|
||||
|
||||
| Type | Purpose | Example |
|
||||
@@ -130,7 +154,7 @@ Default type caps:
|
||||
|
||||
The type-cap total is 34, intentionally above the global 28-entry cap. These are maximums, not quotas.
|
||||
|
||||
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement.
|
||||
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry only after a rolling 7-day elapsed window. Below reinforcement count 6, an allowed recurrence increments the reinforcement count and refreshes retention timestamps; at count 6 or higher, an allowed recurrence refreshes retention timestamps without increasing the count. Same-session status is recorded as diagnostic evidence, not as a new-policy block reason.
|
||||
|
||||
### Safety-Critical Deprecation
|
||||
|
||||
|
||||
+42
-1
@@ -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` |
|
||||
| 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` |
|
||||
| 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>` |
|
||||
|
||||
## Global Options
|
||||
@@ -21,6 +23,25 @@ The npm package is `opencode-working-memory`; the installed bin is `memory-diag`
|
||||
- `--verbose` — show detailed diagnostics.
|
||||
- `--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 for producer-instrumented events, including instrumentation version 2 block details and instrumentation version 3 elapsed-window details. 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, when available, rolling elapsed-window fields; 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
|
||||
|
||||
```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 status --json
|
||||
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>
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
Use `memory-diag commands` to inspect `REINFORCE [M#]` and `REPLACE [M#]` outcomes from compaction.
|
||||
@@ -39,9 +75,14 @@ Use `memory-diag commands` to inspect `REINFORCE [M#]` and `REPLACE [M#]` outcom
|
||||
```bash
|
||||
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 --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, refresh-only 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, elapsed-window fields (`elapsedMs`, `requiredElapsedMs`), `sameSession` evidence, `reinforcementMode` (`increment` or `refresh_only`), `legacyMissingTimestamp` when true, and historical UTC-day evidence without judging whether the policy is correct.
|
||||
|
||||
Current reinforcement policy uses a rolling 7-day elapsed window. Below reinforcement count 6, allowed attempts increment the count and refresh retention timestamps; at count 6 or higher, allowed attempts refresh retention timestamps without increasing the count. Historical evidence can still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or missing block details because evidence logs are append-only and are not backfilled.
|
||||
|
||||
## Dry-run Recovery
|
||||
|
||||
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.4",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
@@ -18,12 +18,17 @@
|
||||
"scripts/memory-diag.ts",
|
||||
"scripts/memory-diag/",
|
||||
"scripts/memory-diag-bin.cjs",
|
||||
"dist/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||
"diag": "node --experimental-strip-types scripts/memory-diag.ts",
|
||||
"clean:dist": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"build:memory-diag": "npm run clean:dist && tsc -p tsconfig.memory-diag.json",
|
||||
"build": "npm run build:memory-diag && node -e \"console.log('BUILD_PASS')\"",
|
||||
"prepack": "npm run build",
|
||||
"diag": "npm run --silent build:memory-diag && node ./scripts/memory-diag-bin.cjs",
|
||||
"test:pack:memory-diag": "node --test --experimental-strip-types tests/smoke/memory-diag-packaging.test.ts",
|
||||
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
|
||||
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
|
||||
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
const { execFileSync } = require("child_process");
|
||||
const { existsSync } = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function isSupportedNodeVersion(version) {
|
||||
@@ -9,13 +10,19 @@ function isSupportedNodeVersion(version) {
|
||||
}
|
||||
|
||||
if (!isSupportedNodeVersion(process.versions.node)) {
|
||||
process.stderr.write(`memory-diag requires Node >=22.6.0 because it runs TypeScript with --experimental-strip-types. Current Node: v${process.versions.node}.\n`);
|
||||
process.stderr.write(`memory-diag requires Node >=22.6.0 per opencode-working-memory package engines. Current Node: v${process.versions.node}.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binDir = __dirname;
|
||||
const tsScript = path.join(binDir, "memory-diag.ts");
|
||||
const args = ["--experimental-strip-types", tsScript, ...process.argv.slice(2)];
|
||||
const compiledScript = path.join(binDir, "..", "dist", "scripts", "memory-diag.js");
|
||||
|
||||
if (!existsSync(compiledScript)) {
|
||||
process.stderr.write("memory-diag package is missing dist/scripts/memory-diag.js. Reinstall opencode-working-memory or run npm run build before using the local package.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = [compiledScript, ...process.argv.slice(2)];
|
||||
try {
|
||||
execFileSync(process.execPath, args, { stdio: "inherit" });
|
||||
process.exit(0);
|
||||
|
||||
@@ -9,7 +9,8 @@ export function usage(): string {
|
||||
memory-diag rejected [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag missing [--workspace <path>] [--verbose] [--json]
|
||||
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]
|
||||
|
||||
Global options:
|
||||
@@ -103,12 +104,12 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
|
||||
if (command === "status") {
|
||||
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`);
|
||||
} else {
|
||||
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`);
|
||||
}
|
||||
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
|
||||
@@ -120,7 +121,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
if (command !== "audit" && options.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`);
|
||||
}
|
||||
if (command !== "revert" && options.event) return error(`${command} does not accept --event`);
|
||||
|
||||
@@ -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 type VisibleCommand = typeof VISIBLE_COMMANDS[number];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { runCommands } from "./commands/commands.ts";
|
||||
import { runCoverage } from "./commands/coverage.ts";
|
||||
import { runExplain } from "./commands/explain.ts";
|
||||
import { runMissing } from "./commands/missing.ts";
|
||||
import { runQuality } from "./commands/quality.ts";
|
||||
import { runRejected } from "./commands/rejected.ts";
|
||||
import { runRevert } from "./commands/revert.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 "explain": return runExplain(options);
|
||||
case "commands": return runCommands(options);
|
||||
case "quality": return runQuality(options);
|
||||
case "revert": return runRevert(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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 type { CliOptions, CommandResult } from "../types.ts";
|
||||
import { normalizedStore } from "../workspace-snapshot.ts";
|
||||
|
||||
type CommandKind = "reinforce" | "replace";
|
||||
|
||||
@@ -33,6 +38,47 @@ 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;
|
||||
elapsedMs?: number;
|
||||
requiredElapsedMs?: number;
|
||||
sameSession?: boolean;
|
||||
legacyMissingTimestamp?: boolean;
|
||||
reinforcementMode?: string;
|
||||
crossUtcDay?: boolean | "unknown";
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const INVALID_COMMAND_REASONS = new Set([
|
||||
"invalid_memory_command",
|
||||
"invalid_memory_ref",
|
||||
@@ -66,6 +112,139 @@ function refFromEvent(event: EvidenceEventV1): string | 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 numberDetail(event: EvidenceEventV1, key: string): number | undefined {
|
||||
const value = event.details?.[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function booleanDetail(event: EvidenceEventV1, key: string): boolean | undefined {
|
||||
const value = event.details?.[key];
|
||||
return typeof value === "boolean" ? 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,
|
||||
elapsedMs: numberDetail(event, "elapsedMs"),
|
||||
requiredElapsedMs: numberDetail(event, "requiredElapsedMs"),
|
||||
sameSession: booleanDetail(event, "sameSession"),
|
||||
legacyMissingTimestamp: booleanDetail(event, "legacyMissingTimestamp") === true ? true : undefined,
|
||||
reinforcementMode: stringDetail(event, "reinforcementMode"),
|
||||
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] {
|
||||
return {
|
||||
eventId: event.eventId,
|
||||
@@ -145,6 +324,82 @@ 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 formatBoolean(value: boolean): string {
|
||||
return value ? "yes" : "no";
|
||||
}
|
||||
|
||||
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 reinforcementMode = event.reinforcementMode ? ` reinforcementMode=${event.reinforcementMode}` : "";
|
||||
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
|
||||
const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : "";
|
||||
const elapsedMs = event.elapsedMs !== undefined ? ` elapsedMs=${event.elapsedMs}` : "";
|
||||
const requiredElapsedMs = event.requiredElapsedMs !== undefined ? ` requiredElapsedMs=${event.requiredElapsedMs}` : "";
|
||||
const sameSession = event.sameSession !== undefined ? ` sameSession=${formatBoolean(event.sameSession)}` : "";
|
||||
const legacyMissingTimestamp = event.legacyMissingTimestamp === true ? " legacyMissingTimestamp=yes" : "";
|
||||
const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : "";
|
||||
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${reinforcementMode}${attemptedAt}${lastReinforcedAt}${elapsedMs}${requiredElapsedMs}${sameSession}${legacyMissingTimestamp}${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 {
|
||||
const warning = options.noEmoji ? "!" : "⚠";
|
||||
const lines = [
|
||||
@@ -176,6 +431,17 @@ export function formatMemoryCommandSummary(summary: MemoryCommandSummary, option
|
||||
|
||||
export async function runCommands(options: CliOptions): Promise<CommandResult> {
|
||||
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 summary = buildMemoryCommandSummary(events);
|
||||
|
||||
|
||||
@@ -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 }) };
|
||||
}
|
||||
@@ -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
@@ -27,6 +27,11 @@ export function normalizeRejection(record: RejectionLogRecord): NormalizedReject
|
||||
source: record.source,
|
||||
origin,
|
||||
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,
|
||||
reasons: record.reasons,
|
||||
};
|
||||
|
||||
@@ -93,6 +93,11 @@ export type RejectionLogRecord = {
|
||||
fromTrigger?: boolean;
|
||||
text?: string;
|
||||
reasons?: string[];
|
||||
producerName?: string;
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
decisionLogicName?: string;
|
||||
decisionLogicVersion?: number;
|
||||
};
|
||||
|
||||
export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
|
||||
@@ -102,6 +107,11 @@ export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp"
|
||||
source?: string;
|
||||
origin: Origin;
|
||||
fromTrigger: boolean;
|
||||
producerName?: string;
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
decisionLogicName?: string;
|
||||
decisionLogicVersion?: number;
|
||||
};
|
||||
|
||||
export type MigrationLogRecord = {
|
||||
|
||||
+8
-1
@@ -4,6 +4,7 @@ import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } fr
|
||||
import { dirname, join } from "node:path";
|
||||
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import { producerFields } from "./instrumentation.ts";
|
||||
|
||||
export type EvidenceEventType =
|
||||
| "extraction_candidate_accepted"
|
||||
@@ -95,6 +96,9 @@ export type EvidenceEventV1 = {
|
||||
workspaceRootHash: string;
|
||||
sessionHash?: string;
|
||||
messageHash?: string;
|
||||
producerName?: string;
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
type: EvidenceEventType;
|
||||
phase: EvidencePhase;
|
||||
outcome: EvidenceOutcome;
|
||||
@@ -273,7 +277,10 @@ function buildEvidenceEvent(
|
||||
if (details) event.details = details;
|
||||
if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax);
|
||||
|
||||
return event;
|
||||
return {
|
||||
...event,
|
||||
...producerFields(),
|
||||
};
|
||||
}
|
||||
|
||||
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import { extractionRejectionLogPath } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import type { EvidenceEventInput } from "./evidence-log.ts";
|
||||
import { producerFields } from "./instrumentation.ts";
|
||||
|
||||
function id(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -329,6 +330,11 @@ type ExtractionRejectionLogEntry = {
|
||||
source: "compaction";
|
||||
workspaceKey?: string;
|
||||
workspaceRootHash?: string;
|
||||
producerName?: string;
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
decisionLogicName?: string;
|
||||
decisionLogicVersion?: number;
|
||||
};
|
||||
|
||||
type WorkspaceMemoryCandidateParseOptions = {
|
||||
@@ -381,6 +387,9 @@ function evaluateWorkspaceMemoryCandidate(
|
||||
source: "compaction",
|
||||
workspaceKey: options.workspaceKey,
|
||||
workspaceRootHash: options.workspaceRootHash,
|
||||
...producerFields(),
|
||||
decisionLogicName: "assessMemoryQuality",
|
||||
decisionLogicVersion: 1,
|
||||
});
|
||||
return { accepted: false, reasons: quality.reasons };
|
||||
}
|
||||
|
||||
@@ -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 = 3;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
+38
-6
@@ -44,7 +44,7 @@ import {
|
||||
workspaceMemoryExactKey,
|
||||
workspaceMemoryIdentityKey,
|
||||
} from "./workspace-memory.ts";
|
||||
import { reinforceMemory } from "./retention.ts";
|
||||
import { REINFORCEMENT_MAX_COUNT, tryReinforceMemory, type ReinforcementDecision } from "./retention.ts";
|
||||
import {
|
||||
appendPendingMemories,
|
||||
clearPendingMemories,
|
||||
@@ -311,6 +311,21 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
};
|
||||
}
|
||||
|
||||
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
|
||||
return {
|
||||
attemptedAtMs: decision.attemptedAt,
|
||||
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
|
||||
...(decision.lastReinforcedAt !== undefined ? {
|
||||
lastReinforcedAtMs: decision.lastReinforcedAt,
|
||||
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
|
||||
} : {}),
|
||||
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
|
||||
requiredElapsedMs: decision.requiredElapsedMs,
|
||||
sameSession: decision.sameSession,
|
||||
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function replacementMemoryId(): string {
|
||||
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -429,17 +444,33 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
|
||||
const { refSnapshot, target, targetIndex } = resolution;
|
||||
if (command.kind === "REINFORCE") {
|
||||
const reinforced = reinforceMemory(target, sessionID, now);
|
||||
if (reinforced === target) {
|
||||
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked"], {
|
||||
const decision = tryReinforceMemory(target, sessionID, now);
|
||||
if (decision.outcome === "blocked") {
|
||||
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
blockReason: decision.blockReason,
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
reinforcementCount: decision.reinforcementCount,
|
||||
maxReinforcementCount: decision.maxReinforcementCount,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const reinforced = decision.memory;
|
||||
workspaceMemory.entries[targetIndex] = reinforced;
|
||||
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
|
||||
const reasonCodes = ["numbered_ref_reinforce", "reinforcement_window_allowed"];
|
||||
if (decision.reinforcementMode === "refresh_only") {
|
||||
reasonCodes.push("reinforcement_saturation_refresh");
|
||||
}
|
||||
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", reasonCodes, {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
|
||||
reinforcementMode: decision.reinforcementMode,
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
previousReinforcementCount: decision.previousReinforcementCount,
|
||||
newReinforcementCount: decision.newReinforcementCount,
|
||||
reinforcementCount: decision.newReinforcementCount,
|
||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
@@ -662,11 +693,12 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
const key = memoryKey(memory);
|
||||
const existing = existingByKey.get(key);
|
||||
if (existing) {
|
||||
const reinforced = reinforceMemory(
|
||||
const decision = tryReinforceMemory(
|
||||
existing.memory,
|
||||
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
|
||||
promotedAt,
|
||||
);
|
||||
const reinforced = decision.memory;
|
||||
if (reinforced !== existing.memory) {
|
||||
workspaceMemory.entries[existing.index] = reinforced;
|
||||
existingByKey.set(key, { memory: reinforced, index: existing.index });
|
||||
|
||||
+96
-29
@@ -1,13 +1,52 @@
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
|
||||
export type ReinforcementBlockReason =
|
||||
| "min_elapsed_window"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "same_session"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "same_utc_day"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "min_interval"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "max_count";
|
||||
|
||||
export type ReinforcementMode = "increment" | "refresh_only";
|
||||
|
||||
type ReinforcementDecisionMetadata = {
|
||||
attemptedAt: number;
|
||||
lastReinforcedAt?: number;
|
||||
elapsedMs?: number;
|
||||
requiredElapsedMs: number;
|
||||
sameSession: boolean;
|
||||
legacyMissingTimestamp?: boolean;
|
||||
};
|
||||
|
||||
export type ReinforcementDecision =
|
||||
| ({
|
||||
outcome: "reinforced";
|
||||
memory: LongTermMemoryEntry;
|
||||
previousReinforcementCount: number;
|
||||
newReinforcementCount: number;
|
||||
reinforcementMode: ReinforcementMode;
|
||||
} & ReinforcementDecisionMetadata)
|
||||
| ({
|
||||
outcome: "blocked";
|
||||
memory: LongTermMemoryEntry;
|
||||
blockReason: ReinforcementBlockReason;
|
||||
reinforcementCount: number;
|
||||
maxReinforcementCount: number;
|
||||
} & ReinforcementDecisionMetadata);
|
||||
|
||||
// Retention decay model constants (v1.5)
|
||||
export const BASE_HALF_LIFE_DAYS = 45;
|
||||
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
||||
export const REINFORCEMENT_MAX_COUNT = 6;
|
||||
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
export const REINFORCEMENT_MIN_ELAPSED_MS = 7 * DAY_MS;
|
||||
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // Deprecated compatibility constant; new policy uses REINFORCEMENT_MIN_ELAPSED_MS.
|
||||
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
||||
export const DORMANT_DECAY_MULTIPLIER = 0.25;
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const TYPE_FACTOR = {
|
||||
reference: 1.0,
|
||||
@@ -108,41 +147,69 @@ export function calculateEffectiveAgeDays(
|
||||
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
|
||||
}
|
||||
|
||||
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
|
||||
const d1 = new Date(ts1);
|
||||
const d2 = new Date(ts2);
|
||||
return d1.getUTCFullYear() === d2.getUTCFullYear()
|
||||
&& d1.getUTCMonth() === d2.getUTCMonth()
|
||||
&& d1.getUTCDate() === d2.getUTCDate();
|
||||
}
|
||||
|
||||
export function reinforceMemory(
|
||||
export function tryReinforceMemory(
|
||||
memory: LongTermMemoryEntry,
|
||||
sessionId: string,
|
||||
now: number,
|
||||
): LongTermMemoryEntry {
|
||||
if (memory.lastReinforcedSessionID === sessionId) {
|
||||
return memory;
|
||||
): ReinforcementDecision {
|
||||
const count = memory.reinforcementCount ?? 0;
|
||||
const lastAt = validLastReinforcedAt(memory.lastReinforcedAt);
|
||||
const lastSession = memory.lastReinforcedSessionID;
|
||||
const sameSession = lastSession === sessionId;
|
||||
const legacyMissingTimestamp = count > 0 && lastAt === undefined;
|
||||
const metadata: ReinforcementDecisionMetadata = {
|
||||
attemptedAt: now,
|
||||
...(lastAt !== undefined ? {
|
||||
lastReinforcedAt: lastAt,
|
||||
elapsedMs: now - lastAt,
|
||||
} : {}),
|
||||
requiredElapsedMs: REINFORCEMENT_MIN_ELAPSED_MS,
|
||||
sameSession,
|
||||
...(legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
|
||||
};
|
||||
|
||||
if (lastAt !== undefined && now - lastAt < REINFORCEMENT_MIN_ELAPSED_MS) {
|
||||
return blockedDecision(memory, "min_elapsed_window", count, metadata);
|
||||
}
|
||||
|
||||
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
|
||||
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
return {
|
||||
const reinforcementMode: ReinforcementMode = count >= REINFORCEMENT_MAX_COUNT
|
||||
? "refresh_only"
|
||||
: "increment";
|
||||
const newReinforcementCount = reinforcementMode === "refresh_only" ? count : count + 1;
|
||||
const reinforced: LongTermMemoryEntry = {
|
||||
...memory,
|
||||
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
|
||||
reinforcementCount: newReinforcementCount,
|
||||
lastReinforcedAt: now,
|
||||
lastReinforcedSessionID: sessionId,
|
||||
retentionClock: now,
|
||||
};
|
||||
return {
|
||||
outcome: "reinforced",
|
||||
memory: reinforced,
|
||||
previousReinforcementCount: count,
|
||||
newReinforcementCount,
|
||||
reinforcementMode,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function validLastReinforcedAt(value: unknown): number | undefined {
|
||||
if (typeof value !== "number") return undefined;
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function blockedDecision(
|
||||
memory: LongTermMemoryEntry,
|
||||
blockReason: ReinforcementBlockReason,
|
||||
reinforcementCount: number,
|
||||
metadata: ReinforcementDecisionMetadata,
|
||||
): ReinforcementDecision {
|
||||
return {
|
||||
outcome: "blocked",
|
||||
memory,
|
||||
blockReason,
|
||||
reinforcementCount,
|
||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
+97
-11
@@ -7,9 +7,11 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import {
|
||||
REINFORCEMENT_MAX_COUNT,
|
||||
RETENTION_TYPE_MAX,
|
||||
calculateRetentionStrength,
|
||||
reinforceMemory,
|
||||
tryReinforceMemory,
|
||||
type ReinforcementDecision,
|
||||
} from "./retention.ts";
|
||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
import { appendEvidenceEvents } from "./evidence-log.ts";
|
||||
@@ -559,7 +561,13 @@ function consolidationEvent(
|
||||
|
||||
function capacityRemovalEvidence(
|
||||
memory: LongTermMemoryEntry,
|
||||
reason: "type_cap" | "global_cap" | "capacity",
|
||||
reason: "type_cap" | "global_cap",
|
||||
details: {
|
||||
strengthAtRemoval: number;
|
||||
rankAtRemoval: number;
|
||||
typeRankAtRemoval: number;
|
||||
ageDaysAtRemoval: number;
|
||||
},
|
||||
): EvidenceEventInput {
|
||||
return {
|
||||
type: "memory_removed_capacity",
|
||||
@@ -578,6 +586,7 @@ function capacityRemovalEvidence(
|
||||
...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}),
|
||||
...(memory.createdAt ? { createdAt: memory.createdAt } : {}),
|
||||
...(memory.source ? { source: memory.source } : {}),
|
||||
...details,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -664,8 +673,8 @@ export function enforceLongTermLimitsWithAccounting(
|
||||
const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id));
|
||||
const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id));
|
||||
const capacityEvidence: EvidenceEventInput[] = [
|
||||
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap")),
|
||||
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap")),
|
||||
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
|
||||
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
|
||||
];
|
||||
const capacityDropped = sorted
|
||||
.filter(entry => !keptIds.has(entry.id))
|
||||
@@ -680,6 +689,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[] {
|
||||
return applyTypeMaxCapsWithOmissions(entries).kept;
|
||||
}
|
||||
@@ -732,12 +763,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||
? "absorbed_exact" as const
|
||||
: "absorbed_identity" as const;
|
||||
const reinforced = reinforceMemory(
|
||||
const decision = tryReinforceMemory(
|
||||
retained,
|
||||
reinforcementSessionId(retained, dropped),
|
||||
now,
|
||||
);
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
|
||||
const reinforced = decision.memory;
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
|
||||
if (reinforcedEvent) evidence.push(reinforcedEvent);
|
||||
|
||||
absorbed.push(consolidationEvent(dropped, reason, reinforced));
|
||||
@@ -760,12 +792,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||
? "absorbed_exact" as const
|
||||
: "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs
|
||||
const reinforced = reinforceMemory(
|
||||
const decision = tryReinforceMemory(
|
||||
retained,
|
||||
reinforcementSessionId(retained, dropped),
|
||||
now,
|
||||
);
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
|
||||
const reinforced = decision.memory;
|
||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
|
||||
if (reinforcedEvent) evidence.push(reinforcedEvent);
|
||||
|
||||
if (reason === "superseded_existing") {
|
||||
@@ -807,11 +840,38 @@ function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
|
||||
function reinforcementEvidence(
|
||||
retained: LongTermMemoryEntry,
|
||||
dropped: LongTermMemoryEntry,
|
||||
reinforced: LongTermMemoryEntry,
|
||||
decision: ReinforcementDecision,
|
||||
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
|
||||
): EvidenceEventInput | undefined {
|
||||
if ((reinforced.reinforcementCount ?? 0) <= (retained.reinforcementCount ?? 0)) return undefined;
|
||||
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,
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
reinforcementCount: decision.reinforcementCount,
|
||||
maxReinforcementCount: decision.maxReinforcementCount,
|
||||
},
|
||||
textPreview: retained.text,
|
||||
};
|
||||
}
|
||||
|
||||
const reinforced = decision.memory;
|
||||
const reasonCodes = [duplicateReason, "reinforcement_window_allowed"];
|
||||
if (decision.reinforcementMode === "refresh_only") {
|
||||
reasonCodes.push("reinforcement_saturation_refresh");
|
||||
}
|
||||
return {
|
||||
type: "memory_reinforced",
|
||||
phase: "reinforcement",
|
||||
@@ -821,11 +881,37 @@ function reinforcementEvidence(
|
||||
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
|
||||
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
|
||||
],
|
||||
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
|
||||
reasonCodes,
|
||||
details: {
|
||||
memoryId: reinforced.id,
|
||||
droppedMemoryId: dropped.id,
|
||||
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
|
||||
reinforcementMode: decision.reinforcementMode,
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
previousReinforcementCount: decision.previousReinforcementCount,
|
||||
newReinforcementCount: decision.newReinforcementCount,
|
||||
reinforcementCount: decision.newReinforcementCount,
|
||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
},
|
||||
textPreview: reinforced.text,
|
||||
};
|
||||
}
|
||||
|
||||
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
|
||||
return {
|
||||
attemptedAtMs: decision.attemptedAt,
|
||||
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
|
||||
...(decision.lastReinforcedAt !== undefined ? {
|
||||
lastReinforcedAtMs: decision.lastReinforcedAt,
|
||||
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
|
||||
} : {}),
|
||||
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
|
||||
requiredElapsedMs: decision.requiredElapsedMs,
|
||||
sameSession: decision.sameSession,
|
||||
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string {
|
||||
return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe";
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ test("help returns usage without exposing hidden or removed commands", () => {
|
||||
assert.equal("help" in parsed && parsed.help, true);
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
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 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));
|
||||
}
|
||||
});
|
||||
@@ -35,7 +36,7 @@ test("unknown command returns usage error", () => {
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
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", () => {
|
||||
const coverage = parseArgs(["coverage"]);
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const byMemory = parseArgs(["revert", "--memory", "mem-new", "--workspace", "/tmp/workspace", "--apply"]);
|
||||
assert.equal(byMemory.ok, true);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+265
-1
@@ -123,6 +123,153 @@ 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: "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_min_elapsed_window"],
|
||||
details: {
|
||||
ref: "M3",
|
||||
blockReason: "min_elapsed_window",
|
||||
attemptedAtIso: "2026-05-20T00:05:00.000Z",
|
||||
lastReinforcedAtIso: "2026-05-13T00:05:00.001Z",
|
||||
elapsedMs: 604_799_999,
|
||||
requiredElapsedMs: 604_800_000,
|
||||
sameSession: true,
|
||||
sessionID: "raw-session-secret",
|
||||
lastReinforcedSessionID: "raw-last-session-secret",
|
||||
},
|
||||
sessionHash: "command-session-three",
|
||||
messageHash: "command-message-four",
|
||||
}),
|
||||
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",
|
||||
reinforcementOutcome: "reinforced",
|
||||
reinforcementMode: "increment",
|
||||
attemptedAtIso: "2026-05-20T00:05:00.001Z",
|
||||
lastReinforcedAtIso: "2026-05-13T00:05:00.001Z",
|
||||
elapsedMs: 604_800_000,
|
||||
requiredElapsedMs: 604_800_000,
|
||||
sameSession: false,
|
||||
sessionID: "raw-increment-session-secret",
|
||||
},
|
||||
sessionHash: "command-session-four",
|
||||
messageHash: "command-message-five",
|
||||
}),
|
||||
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", "reinforcement_saturation_refresh"],
|
||||
details: {
|
||||
ref: "M3",
|
||||
reinforcementOutcome: "refreshed",
|
||||
reinforcementMode: "refresh_only",
|
||||
attemptedAtIso: "2026-05-27T00:05:00.001Z",
|
||||
lastReinforcedAtIso: "2026-05-20T00:05:00.001Z",
|
||||
elapsedMs: 604_800_000,
|
||||
requiredElapsedMs: 604_800_000,
|
||||
sameSession: true,
|
||||
lastReinforcedSessionID: "raw-refresh-last-session-secret",
|
||||
},
|
||||
sessionHash: "command-session-five",
|
||||
messageHash: "command-message-six",
|
||||
}),
|
||||
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",
|
||||
reinforcementOutcome: "reinforced",
|
||||
reinforcementMode: "increment",
|
||||
attemptedAtIso: "2026-05-28T00:05:00.001Z",
|
||||
requiredElapsedMs: 604_800_000,
|
||||
sameSession: false,
|
||||
legacyMissingTimestamp: true,
|
||||
sessionID: "raw-legacy-session-secret",
|
||||
},
|
||||
sessionHash: "command-session-six",
|
||||
messageHash: "command-message-seven",
|
||||
}),
|
||||
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 () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-"));
|
||||
try {
|
||||
@@ -214,7 +361,7 @@ test("memory-diag defaults to status when no subcommand is supplied", async () =
|
||||
test("removed legacy aliases return unknown subcommand", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-"));
|
||||
try {
|
||||
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
|
||||
for (const command of ["health", "rejections", "disappearances", "trace"]) {
|
||||
await assert.rejects(
|
||||
runMemoryDiagResult([command, "--workspace", root]),
|
||||
(error: unknown) => {
|
||||
@@ -402,6 +549,123 @@ 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: 7/);
|
||||
assert.match(stdout, /reinforced: 4/);
|
||||
assert.match(stdout, /rejected\/blocked: 3/);
|
||||
assert.match(stdout, /window blocked: 3/);
|
||||
assert.match(stdout, /block reasons: min_elapsed_window=1, 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, /blockReason=min_elapsed_window/);
|
||||
assert.match(stdout, /elapsedMs=604799999/);
|
||||
assert.match(stdout, /requiredElapsedMs=604800000/);
|
||||
assert.match(stdout, /sameSession=yes/);
|
||||
assert.match(stdout, /sameSession=no/);
|
||||
assert.match(stdout, /reinforcementMode=refresh_only/);
|
||||
assert.match(stdout, /legacyMissingTimestamp=yes/);
|
||||
assert.match(stdout, /crossUtcDay=yes/);
|
||||
assert.doesNotMatch(stdout, /render_selected/);
|
||||
assert.doesNotMatch(stdout, /raw-session-secret/);
|
||||
assert.doesNotMatch(stdout, /raw-last-session-secret/);
|
||||
assert.doesNotMatch(stdout, /raw-refresh-last-session-secret/);
|
||||
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";
|
||||
elapsedMs?: number;
|
||||
requiredElapsedMs?: number;
|
||||
sameSession?: boolean;
|
||||
legacyMissingTimestamp?: boolean;
|
||||
reinforcementMode?: string;
|
||||
instrumentationVersion?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
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, 7);
|
||||
assert.equal(parsed.summary.reinforced, 4);
|
||||
assert.equal(parsed.summary.rejectedOrBlocked, 3);
|
||||
assert.equal(parsed.summary.blocksByReason.min_elapsed_window, 1);
|
||||
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 === "min_elapsed_window" && event.elapsedMs === 604_799_999 && event.requiredElapsedMs === 604_800_000 && event.sameSession === true), true);
|
||||
assert.equal(parsed.events.some(event => event.reinforcementMode === "increment" && event.elapsedMs === 604_800_000 && event.sameSession === false), true);
|
||||
assert.equal(parsed.events.some(event => event.reinforcementMode === "refresh_only" && event.elapsedMs === 604_800_000 && event.sameSession === true), true);
|
||||
assert.equal(parsed.events.some(event => event.legacyMissingTimestamp === true), true);
|
||||
assert.equal(parsed.events.every(event => event.instrumentationVersion === 3), true);
|
||||
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);
|
||||
assert.equal(JSON.stringify(parsed).includes("raw-session-secret"), false);
|
||||
assert.equal(JSON.stringify(parsed).includes("raw-last-session-secret"), false);
|
||||
assert.equal(JSON.stringify(parsed).includes("raw-refresh-last-session-secret"), 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 () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-dry-run-"));
|
||||
try {
|
||||
|
||||
+240
-15
@@ -100,6 +100,60 @@ function createSessionWithError(sessionID: string, error: OpenError) {
|
||||
};
|
||||
}
|
||||
|
||||
async function withMockedDateNow<T>(now: number, fn: () => Promise<T>): Promise<T> {
|
||||
const originalDateNow = Date.now;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
}
|
||||
|
||||
async function withNumberedReinforceScenario(
|
||||
options: { sessionID: string; nowMs: number; existing: LongTermMemoryEntry; summary?: string },
|
||||
assertions: (context: {
|
||||
workspace: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
|
||||
events: Awaited<ReturnType<typeof queryEvidenceEvents>>;
|
||||
}) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
try {
|
||||
await updateWorkspaceMemory(tmpDir, store => {
|
||||
store.entries.push(options.existing);
|
||||
return store;
|
||||
});
|
||||
await saveSessionState(tmpDir, {
|
||||
version: 1,
|
||||
sessionID: options.sessionID,
|
||||
turn: 0,
|
||||
updatedAt: new Date(options.nowMs).toISOString(),
|
||||
activeFiles: [],
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [],
|
||||
compactionMemoryRefs: [compactionRefFor(options.existing)],
|
||||
});
|
||||
|
||||
const plugin = await MemoryV2Plugin({
|
||||
directory: tmpDir,
|
||||
client: mockClientWithCompactionSummary(options.summary ?? "Memory candidates:\nREINFORCE [M1]"),
|
||||
});
|
||||
await withMockedDateNow(options.nowMs, async () => {
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: options.sessionID } },
|
||||
});
|
||||
});
|
||||
|
||||
await assertions({
|
||||
workspace: await loadWorkspaceMemory(tmpDir),
|
||||
events: await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }),
|
||||
});
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("tool.execute.after: undefined exitCode does NOT create open error", async () => {
|
||||
// 1. Temp directory for isolated file I/O
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
@@ -1032,8 +1086,9 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000;
|
||||
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const lastReinforcedAt = nowMs - 8 * 24 * 60 * 60 * 1000;
|
||||
const existing: LongTermMemoryEntry = {
|
||||
id: "numbered-reinforce-memory",
|
||||
type: "decision",
|
||||
@@ -1043,7 +1098,10 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
retentionClock: oldRetentionClock,
|
||||
retentionClock: lastReinforcedAt,
|
||||
reinforcementCount: 1,
|
||||
lastReinforcedAt,
|
||||
lastReinforcedSessionID: "numbered-reinforce-session",
|
||||
};
|
||||
await updateWorkspaceMemory(tmpDir, store => {
|
||||
store.entries.push(existing);
|
||||
@@ -1065,28 +1123,174 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
|
||||
directory: tmpDir,
|
||||
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
|
||||
});
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
|
||||
await withMockedDateNow(nowMs, async () => {
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
|
||||
});
|
||||
});
|
||||
|
||||
const workspace = await loadWorkspaceMemory(tmpDir);
|
||||
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
|
||||
assert.equal(reinforced?.reinforcementCount, 1);
|
||||
assert.equal(reinforced?.reinforcementCount, 2);
|
||||
assert.equal(reinforced?.lastReinforcedSessionID, "numbered-reinforce-session");
|
||||
assert.ok((reinforced?.retentionClock ?? 0) > oldRetentionClock);
|
||||
assert.equal(reinforced?.lastReinforcedAt, nowMs);
|
||||
assert.equal(reinforced?.retentionClock, nowMs);
|
||||
|
||||
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
|
||||
assert.ok(events.some(event =>
|
||||
const event = events.find(event =>
|
||||
event.outcome === "reinforced" &&
|
||||
event.reasonCodes.includes("numbered_ref_reinforce") &&
|
||||
event.reasonCodes.includes("reinforcement_window_allowed") &&
|
||||
event.memory?.memoryId === existing.id
|
||||
));
|
||||
);
|
||||
assert.ok(event, "numbered REINFORCE should emit allowed evidence");
|
||||
assert.equal(event.instrumentationVersion, 3);
|
||||
assert.equal(event.details?.reinforcementOutcome, "reinforced");
|
||||
assert.equal(event.details?.reinforcementMode, "increment");
|
||||
assert.equal(event.details?.sameSession, true);
|
||||
assert.equal(event.details?.elapsedMs, 8 * 24 * 60 * 60 * 1000);
|
||||
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
|
||||
assert.equal(event.details?.attemptedAtMs, nowMs);
|
||||
assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt);
|
||||
assert.equal(event.details?.previousReinforcementCount, 1);
|
||||
assert.equal(event.details?.newReinforcementCount, 2);
|
||||
assert.equal(JSON.stringify(event.details).includes("numbered-reinforce-session"), false);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session.compacted allows numbered REINFORCE at exact 7-day boundary", async () => {
|
||||
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000;
|
||||
const existing: LongTermMemoryEntry = {
|
||||
id: "numbered-reinforce-exact-window",
|
||||
type: "decision",
|
||||
text: "Use exact rolling windows for memory reinforcement.",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
status: "active",
|
||||
createdAt: new Date(nowMs).toISOString(),
|
||||
updatedAt: new Date(nowMs).toISOString(),
|
||||
retentionClock: lastReinforcedAt,
|
||||
reinforcementCount: 5,
|
||||
lastReinforcedAt,
|
||||
lastReinforcedSessionID: "previous-session",
|
||||
};
|
||||
|
||||
await withNumberedReinforceScenario({ sessionID: "exact-window-session", nowMs, existing }, ({ workspace, events }) => {
|
||||
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
|
||||
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
|
||||
|
||||
assert.equal(reinforced?.reinforcementCount, 6);
|
||||
assert.equal(reinforced?.retentionClock, nowMs);
|
||||
assert.ok(event, "exact 7-day boundary should emit reinforced evidence");
|
||||
assert.equal(event.details?.reinforcementMode, "increment");
|
||||
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000);
|
||||
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
|
||||
assert.equal(event.instrumentationVersion, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test("session.compacted blocks different-session numbered REINFORCE below 7-day window", async () => {
|
||||
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 1);
|
||||
const existing: LongTermMemoryEntry = {
|
||||
id: "numbered-reinforce-below-window",
|
||||
type: "feedback",
|
||||
text: "User prefers below-window reinforcement blocks.",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
status: "active",
|
||||
createdAt: new Date(nowMs).toISOString(),
|
||||
updatedAt: new Date(nowMs).toISOString(),
|
||||
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
|
||||
reinforcementCount: 1,
|
||||
lastReinforcedAt,
|
||||
lastReinforcedSessionID: "previous-session",
|
||||
};
|
||||
|
||||
await withNumberedReinforceScenario({ sessionID: "different-session", nowMs, existing }, ({ workspace, events }) => {
|
||||
const blocked = workspace.entries.find(entry => entry.id === existing.id);
|
||||
const event = events.find(event => event.outcome === "rejected" && event.memory?.memoryId === existing.id);
|
||||
|
||||
assert.equal(blocked?.reinforcementCount, 1);
|
||||
assert.ok(event, "below-window different-session attempt should emit rejected evidence");
|
||||
assert.ok(event.reasonCodes.includes("reinforcement_block_min_elapsed_window"));
|
||||
assert.equal(event.details?.blockReason, "min_elapsed_window");
|
||||
assert.equal(event.details?.sameSession, false);
|
||||
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 1);
|
||||
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
|
||||
assert.equal(event.instrumentationVersion, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test("session.compacted refreshes saturated numbered REINFORCE after rolling window", async () => {
|
||||
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000;
|
||||
const existing: LongTermMemoryEntry = {
|
||||
id: "numbered-reinforce-refresh-only",
|
||||
type: "decision",
|
||||
text: "Use refresh-only mode for saturated reinforcement.",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
status: "active",
|
||||
createdAt: new Date(nowMs).toISOString(),
|
||||
updatedAt: new Date(nowMs).toISOString(),
|
||||
retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000,
|
||||
reinforcementCount: 6,
|
||||
lastReinforcedAt,
|
||||
lastReinforcedSessionID: "previous-session",
|
||||
};
|
||||
|
||||
await withNumberedReinforceScenario({ sessionID: "refresh-only-session", nowMs, existing }, ({ workspace, events }) => {
|
||||
const refreshed = workspace.entries.find(entry => entry.id === existing.id);
|
||||
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
|
||||
|
||||
assert.equal(refreshed?.reinforcementCount, 6);
|
||||
assert.equal(refreshed?.retentionClock, nowMs);
|
||||
assert.equal(refreshed?.lastReinforcedAt, nowMs);
|
||||
assert.equal(refreshed?.lastReinforcedSessionID, "refresh-only-session");
|
||||
assert.ok(event, "saturated after-window attempt should emit refreshed evidence");
|
||||
assert.equal(event.details?.reinforcementOutcome, "refreshed");
|
||||
assert.equal(event.details?.reinforcementMode, "refresh_only");
|
||||
assert.equal(event.details?.previousReinforcementCount, 6);
|
||||
assert.equal(event.details?.newReinforcementCount, 6);
|
||||
assert.ok(event.reasonCodes.includes("reinforcement_saturation_refresh"));
|
||||
assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false);
|
||||
assert.equal(event.instrumentationVersion, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test("session.compacted emits legacy missing timestamp details for numbered REINFORCE", async () => {
|
||||
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const existing: LongTermMemoryEntry = {
|
||||
id: "numbered-reinforce-legacy-missing",
|
||||
type: "feedback",
|
||||
text: "User prefers legacy timestamp anomalies to be visible.",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
status: "active",
|
||||
createdAt: new Date(nowMs).toISOString(),
|
||||
updatedAt: new Date(nowMs).toISOString(),
|
||||
retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000,
|
||||
reinforcementCount: 2,
|
||||
lastReinforcedSessionID: "previous-session",
|
||||
};
|
||||
|
||||
await withNumberedReinforceScenario({ sessionID: "legacy-missing-session", nowMs, existing }, ({ workspace, events }) => {
|
||||
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
|
||||
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
|
||||
|
||||
assert.equal(reinforced?.reinforcementCount, 3);
|
||||
assert.equal(reinforced?.lastReinforcedAt, nowMs);
|
||||
assert.equal(reinforced?.retentionClock, nowMs);
|
||||
assert.equal(event?.details?.legacyMissingTimestamp, true);
|
||||
assert.equal(event?.details?.reinforcementMode, "increment");
|
||||
assert.equal(event?.instrumentationVersion, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test("session.compacted rejects invalid unavailable and stale REINFORCE refs without mutation", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
@@ -1163,8 +1367,9 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const nowMs = Date.now();
|
||||
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000);
|
||||
const existing: LongTermMemoryEntry = {
|
||||
id: "blocked-numbered-reinforce",
|
||||
type: "feedback",
|
||||
@@ -1176,7 +1381,7 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
|
||||
updatedAt: now,
|
||||
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
|
||||
reinforcementCount: 1,
|
||||
lastReinforcedAt: nowMs - 2 * 60 * 60 * 1000,
|
||||
lastReinforcedAt,
|
||||
lastReinforcedSessionID: "blocked-reinforce-session",
|
||||
};
|
||||
await updateWorkspaceMemory(tmpDir, store => {
|
||||
@@ -1199,8 +1404,10 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
|
||||
directory: tmpDir,
|
||||
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
|
||||
});
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
|
||||
await withMockedDateNow(nowMs, async () => {
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
|
||||
});
|
||||
});
|
||||
|
||||
const workspace = await loadWorkspaceMemory(tmpDir);
|
||||
@@ -1209,13 +1416,31 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
|
||||
assert.equal(blocked?.reinforcementCount, 1);
|
||||
|
||||
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
|
||||
assert.ok(events.some(event =>
|
||||
const event = events.find(event =>
|
||||
event.outcome === "rejected" &&
|
||||
event.reasonCodes.includes("reinforcement_window_blocked") &&
|
||||
event.reasonCodes.includes("reinforcement_block_min_elapsed_window") &&
|
||||
event.memory?.memoryId === existing.id &&
|
||||
event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) &&
|
||||
!event.relations?.some(relation => relation.role === "reinforced")
|
||||
));
|
||||
);
|
||||
assert.ok(event, "blocked numbered REINFORCE should emit elapsed-window evidence");
|
||||
assert.equal(event.instrumentationVersion, 3);
|
||||
assert.equal(event.reasonCodes.includes("reinforcement_block_same_session"), false);
|
||||
assert.equal(event.reasonCodes.includes("reinforcement_block_same_utc_day"), false);
|
||||
assert.equal(event.reasonCodes.includes("reinforcement_block_min_interval"), false);
|
||||
assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false);
|
||||
assert.equal(event.details?.blockReason, "min_elapsed_window");
|
||||
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000);
|
||||
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
|
||||
assert.equal(event.details?.sameSession, true);
|
||||
assert.equal(event.details?.attemptedAtMs, nowMs);
|
||||
assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt);
|
||||
assert.equal(event.details?.reinforcementCount, 1);
|
||||
assert.equal(event.details?.maxReinforcementCount, 6);
|
||||
assert.equal("minIntervalMs" in (event.details ?? {}), false);
|
||||
assert.equal("reinforcementMode" in (event.details ?? {}), false);
|
||||
assert.equal(JSON.stringify(event.details).includes("blocked-reinforce-session"), false);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
BASE_HALF_LIFE_DAYS,
|
||||
REINFORCEMENT_MAX_COUNT,
|
||||
tryReinforceMemory,
|
||||
} from "../src/retention.ts";
|
||||
import type { LongTermMemoryEntry } from "../src/types.ts";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const ROLLING_WINDOW_MS = 7 * DAY_MS;
|
||||
|
||||
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 allows same session after rolling 8 days", () => {
|
||||
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
|
||||
const now = lastAt + 8 * DAY_MS;
|
||||
const memory = baseMemory({
|
||||
reinforcementCount: 1,
|
||||
lastReinforcedAt: lastAt,
|
||||
lastReinforcedSessionID: "session-a",
|
||||
retentionClock: lastAt,
|
||||
});
|
||||
|
||||
const decision = tryReinforceMemory(memory, "session-a", now);
|
||||
|
||||
assert.equal(decision.outcome, "reinforced");
|
||||
assert.equal(decision.reinforcementMode, "increment");
|
||||
assert.equal(decision.previousReinforcementCount, 1);
|
||||
assert.equal(decision.newReinforcementCount, 2);
|
||||
assert.equal(decision.memory.reinforcementCount, 2);
|
||||
assert.equal(decision.memory.retentionClock, now);
|
||||
assert.equal(decision.memory.lastReinforcedAt, now);
|
||||
assert.equal(decision.memory.lastReinforcedSessionID, "session-a");
|
||||
assert.equal(decision.sameSession, true);
|
||||
assert.equal(decision.elapsedMs, 8 * DAY_MS);
|
||||
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
|
||||
});
|
||||
|
||||
test("tryReinforceMemory allows exactly 7 rolling days after last reinforcement", () => {
|
||||
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
|
||||
const now = lastAt + ROLLING_WINDOW_MS;
|
||||
const memory = baseMemory({
|
||||
reinforcementCount: 5,
|
||||
lastReinforcedAt: lastAt,
|
||||
lastReinforcedSessionID: "session-a",
|
||||
retentionClock: lastAt,
|
||||
});
|
||||
|
||||
const decision = tryReinforceMemory(memory, "session-b", now);
|
||||
|
||||
assert.equal(decision.outcome, "reinforced");
|
||||
assert.equal(decision.reinforcementMode, "increment");
|
||||
assert.equal(decision.memory.reinforcementCount, 6);
|
||||
assert.equal(decision.memory.retentionClock, now);
|
||||
assert.equal(decision.memory.lastReinforcedAt, now);
|
||||
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
|
||||
assert.equal(decision.sameSession, false);
|
||||
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS);
|
||||
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
|
||||
});
|
||||
|
||||
test("tryReinforceMemory blocks 7 rolling days minus 1ms as min_elapsed_window", () => {
|
||||
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
|
||||
const now = lastAt + ROLLING_WINDOW_MS - 1;
|
||||
const memory = baseMemory({
|
||||
reinforcementCount: 1,
|
||||
lastReinforcedAt: lastAt,
|
||||
lastReinforcedSessionID: "session-a",
|
||||
});
|
||||
|
||||
const decision = tryReinforceMemory(memory, "session-a", now);
|
||||
|
||||
assert.equal(decision.outcome, "blocked");
|
||||
assert.equal(decision.blockReason, "min_elapsed_window");
|
||||
assert.equal(decision.memory, memory);
|
||||
assert.equal(decision.sameSession, true);
|
||||
assert.equal(decision.lastReinforcedAt, lastAt);
|
||||
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS - 1);
|
||||
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
|
||||
});
|
||||
|
||||
test("tryReinforceMemory blocks different session below rolling window", () => {
|
||||
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
|
||||
const now = lastAt + 3 * DAY_MS;
|
||||
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_elapsed_window");
|
||||
assert.equal(decision.sameSession, false);
|
||||
assert.equal(decision.elapsedMs, 3 * DAY_MS);
|
||||
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
|
||||
});
|
||||
|
||||
test("tryReinforceMemory blocks UTC midnight crossing below rolling window", () => {
|
||||
const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0);
|
||||
const now = Date.UTC(2026, 4, 13, 2, 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_elapsed_window");
|
||||
assert.notEqual(decision.blockReason, "same_utc_day");
|
||||
assert.equal(decision.elapsedMs, now - lastAt);
|
||||
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
|
||||
});
|
||||
|
||||
test("tryReinforceMemory refreshes saturated count after rolling window", () => {
|
||||
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
|
||||
const now = lastAt + ROLLING_WINDOW_MS;
|
||||
const memory = baseMemory({
|
||||
reinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
lastReinforcedAt: lastAt,
|
||||
lastReinforcedSessionID: "session-a",
|
||||
retentionClock: lastAt,
|
||||
});
|
||||
|
||||
const decision = tryReinforceMemory(memory, "session-b", now);
|
||||
|
||||
assert.equal(decision.outcome, "reinforced");
|
||||
assert.equal(decision.reinforcementMode, "refresh_only");
|
||||
assert.equal(decision.previousReinforcementCount, REINFORCEMENT_MAX_COUNT);
|
||||
assert.equal(decision.newReinforcementCount, REINFORCEMENT_MAX_COUNT);
|
||||
assert.notEqual(decision.memory, memory);
|
||||
assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT);
|
||||
assert.equal(decision.memory.lastReinforcedAt, now);
|
||||
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
|
||||
assert.equal(decision.memory.retentionClock, now);
|
||||
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS);
|
||||
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
|
||||
});
|
||||
|
||||
test("tryReinforceMemory blocks saturated count below rolling window as min_elapsed_window", () => {
|
||||
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
|
||||
const now = lastAt + ROLLING_WINDOW_MS - 1;
|
||||
const memory = baseMemory({
|
||||
reinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
lastReinforcedAt: lastAt,
|
||||
lastReinforcedSessionID: "session-a",
|
||||
});
|
||||
|
||||
const decision = tryReinforceMemory(memory, "session-b", now);
|
||||
|
||||
assert.equal(decision.outcome, "blocked");
|
||||
assert.equal(decision.blockReason, "min_elapsed_window");
|
||||
assert.notEqual(decision.blockReason, "max_count");
|
||||
assert.equal(decision.reinforcementCount, REINFORCEMENT_MAX_COUNT);
|
||||
assert.equal(decision.maxReinforcementCount, REINFORCEMENT_MAX_COUNT);
|
||||
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
|
||||
});
|
||||
|
||||
test("tryReinforceMemory normalizes missing legacy timestamp while incrementing", () => {
|
||||
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
|
||||
const memory = baseMemory({
|
||||
reinforcementCount: 2,
|
||||
lastReinforcedSessionID: "session-a",
|
||||
});
|
||||
|
||||
const decision = tryReinforceMemory(memory, "session-b", now);
|
||||
|
||||
assert.equal(decision.outcome, "reinforced");
|
||||
assert.equal(decision.reinforcementMode, "increment");
|
||||
assert.equal(decision.legacyMissingTimestamp, true);
|
||||
assert.equal(decision.memory.reinforcementCount, 3);
|
||||
assert.equal(decision.memory.lastReinforcedAt, now);
|
||||
assert.equal(decision.memory.retentionClock, now);
|
||||
});
|
||||
|
||||
test("tryReinforceMemory normalizes invalid legacy timestamp while refresh-only saturated", () => {
|
||||
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
|
||||
const memory = baseMemory({
|
||||
reinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
lastReinforcedAt: Number.NaN,
|
||||
lastReinforcedSessionID: "session-a",
|
||||
retentionClock: Date.UTC(2026, 4, 8, 12, 0, 0),
|
||||
});
|
||||
|
||||
const decision = tryReinforceMemory(memory, "session-b", now);
|
||||
|
||||
assert.equal(decision.outcome, "reinforced");
|
||||
assert.equal(decision.reinforcementMode, "refresh_only");
|
||||
assert.equal(decision.legacyMissingTimestamp, true);
|
||||
assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT);
|
||||
assert.equal(decision.memory.lastReinforcedAt, now);
|
||||
assert.equal(decision.memory.retentionClock, now);
|
||||
});
|
||||
|
||||
test("BASE_HALF_LIFE_DAYS remains 45", () => {
|
||||
assert.equal(BASE_HALF_LIFE_DAYS, 45);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const maxBuffer = 10 * 1024 * 1024;
|
||||
|
||||
function executable(name: "npm" | "npx"): string {
|
||||
return process.platform === "win32" ? `${name}.cmd` : name;
|
||||
}
|
||||
|
||||
test("packed memory-diag bin runs from a temp consumer project", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-packaging-"));
|
||||
const packDir = join(tempRoot, "pack");
|
||||
const consumerDir = join(tempRoot, "consumer");
|
||||
|
||||
try {
|
||||
await mkdir(packDir, { recursive: true });
|
||||
await mkdir(consumerDir, { recursive: true });
|
||||
|
||||
const packResult = await execFileAsync(executable("npm"), [
|
||||
"pack",
|
||||
repoRoot,
|
||||
"--pack-destination",
|
||||
packDir,
|
||||
"--silent",
|
||||
], { cwd: tempRoot, maxBuffer });
|
||||
const tarballName = packResult.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
||||
assert.ok(tarballName, `npm pack did not report a tarball name. stdout:\n${packResult.stdout}\nstderr:\n${packResult.stderr}`);
|
||||
|
||||
const tarballPath = join(packDir, tarballName);
|
||||
const runResult = await execFileAsync(executable("npx"), [
|
||||
"--yes",
|
||||
"--package",
|
||||
tarballPath,
|
||||
"memory-diag",
|
||||
"--help",
|
||||
], { cwd: consumerDir, maxBuffer });
|
||||
|
||||
assert.match(runResult.stdout, /Usage:/);
|
||||
assert.match(runResult.stdout, /memory-diag \[status\]/);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+171
-30
@@ -30,7 +30,7 @@ import {
|
||||
calculateRetentionStrength,
|
||||
calculateDormantDays,
|
||||
calculateEffectiveAgeDays,
|
||||
reinforceMemory,
|
||||
tryReinforceMemory,
|
||||
} from "../src/retention.ts";
|
||||
import { redactCredentials } from "../src/redaction.ts";
|
||||
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
|
||||
@@ -72,6 +72,16 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function withMockedDateNow<T>(now: number, fn: () => T): T {
|
||||
const originalDateNow = Date.now;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an entry with a createdAt offset from now (negative = in the past) */
|
||||
function agedEntry(
|
||||
id: string,
|
||||
@@ -580,10 +590,10 @@ test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for str
|
||||
assert.deepEqual(result.kept.map(memory => memory.id), ["reinforced-old", "fresh"]);
|
||||
});
|
||||
|
||||
test("reinforceMemory enforces session interval and max guards", () => {
|
||||
test("reinforceMemory enforces rolling elapsed window and saturation refresh", () => {
|
||||
const now = Date.UTC(2026, 3, 29);
|
||||
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.equal(reinforced.reinforcementCount, 1);
|
||||
@@ -591,21 +601,29 @@ test("reinforceMemory enforces session interval and max guards", () => {
|
||||
assert.equal(reinforced.lastReinforcedSessionID, "session-a");
|
||||
assert.equal(reinforced.retentionClock, now);
|
||||
|
||||
assert.equal(reinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000), reinforced);
|
||||
assert.equal(reinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000), reinforced);
|
||||
assert.equal(tryReinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000).memory, reinforced);
|
||||
assert.equal(tryReinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000).memory, reinforced);
|
||||
|
||||
const reinforcedAfterWindow = tryReinforceMemory(reinforced, "session-a", now + 7 * DAY_MS).memory;
|
||||
assert.notEqual(reinforcedAfterWindow, reinforced);
|
||||
assert.equal(reinforcedAfterWindow.reinforcementCount, 2);
|
||||
|
||||
const atMax: LongTermMemoryEntry = {
|
||||
...base,
|
||||
reinforcementCount: 6,
|
||||
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
|
||||
lastReinforcedAt: now - 7 * DAY_MS,
|
||||
};
|
||||
assert.equal(reinforceMemory(atMax, "session-c", now), atMax);
|
||||
const refreshedAtMax = tryReinforceMemory(atMax, "session-c", now).memory;
|
||||
assert.notEqual(refreshedAtMax, atMax);
|
||||
assert.equal(refreshedAtMax.reinforcementCount, 6);
|
||||
assert.equal(refreshedAtMax.retentionClock, now);
|
||||
});
|
||||
|
||||
test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => {
|
||||
test("reinforceMemory uses rolling elapsed window instead of UTC calendar days", () => {
|
||||
const firstReinforcedAt = Date.UTC(2026, 3, 29, 0, 15);
|
||||
const sameUtcDayMuchLater = Date.UTC(2026, 3, 29, 23, 30);
|
||||
const nextUtcDayAfterInterval = Date.UTC(2026, 3, 30, 1, 30);
|
||||
const afterWindow = firstReinforcedAt + 7 * DAY_MS;
|
||||
const base: LongTermMemoryEntry = {
|
||||
...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"),
|
||||
reinforcementCount: 1,
|
||||
@@ -613,20 +631,21 @@ test("reinforceMemory requires distinct UTC calendar days between reinforcements
|
||||
lastReinforcedSessionID: "session-a",
|
||||
};
|
||||
|
||||
assert.equal(reinforceMemory(base, "session-b", sameUtcDayMuchLater), base);
|
||||
assert.equal(tryReinforceMemory(base, "session-b", sameUtcDayMuchLater).memory, base);
|
||||
assert.equal(tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory, base);
|
||||
|
||||
const reinforcedNextDay = reinforceMemory(base, "session-b", nextUtcDayAfterInterval);
|
||||
assert.notEqual(reinforcedNextDay, base);
|
||||
assert.equal(reinforcedNextDay.reinforcementCount, 2);
|
||||
assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval);
|
||||
assert.equal(reinforcedNextDay.lastReinforcedSessionID, "session-b");
|
||||
assert.equal(reinforcedNextDay.retentionClock, nextUtcDayAfterInterval);
|
||||
const reinforcedAfterWindow = tryReinforceMemory(base, "session-b", afterWindow).memory;
|
||||
assert.notEqual(reinforcedAfterWindow, base);
|
||||
assert.equal(reinforcedAfterWindow.reinforcementCount, 2);
|
||||
assert.equal(reinforcedAfterWindow.lastReinforcedAt, afterWindow);
|
||||
assert.equal(reinforcedAfterWindow.lastReinforcedSessionID, "session-b");
|
||||
assert.equal(reinforcedAfterWindow.retentionClock, afterWindow);
|
||||
|
||||
const atMax: LongTermMemoryEntry = {
|
||||
...base,
|
||||
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", () => {
|
||||
@@ -716,37 +735,75 @@ test("reinforced memory with same initial strength and age ranks above unreinfor
|
||||
assert.deepEqual(kept.map(memory => memory.id), ["reinforced", "unreinforced"]);
|
||||
});
|
||||
|
||||
test("dedupe reinforcement does not increment for same session", () => {
|
||||
const now = Date.now();
|
||||
test("dedupe reinforcement allows same session after rolling 8 days with increment evidence", () => {
|
||||
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const existing: LongTermMemoryEntry = {
|
||||
...entry("existing", "Use pnpm for package management", "decision"),
|
||||
source: "manual",
|
||||
pendingOwnerSessionID: "same-session",
|
||||
reinforcementCount: 1,
|
||||
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
|
||||
lastReinforcedAt: now - 8 * DAY_MS,
|
||||
lastReinforcedSessionID: "same-session",
|
||||
retentionClock: now - 8 * DAY_MS,
|
||||
};
|
||||
const duplicate: LongTermMemoryEntry = {
|
||||
...entry("duplicate", "use pnpm for package management!!!", "decision"),
|
||||
pendingOwnerSessionID: "same-session",
|
||||
};
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
|
||||
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
|
||||
const retained = result.kept.find(memory => memory.id === "existing");
|
||||
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
|
||||
|
||||
assert.ok(retained, "existing manual memory should be retained");
|
||||
assert.equal(retained.reinforcementCount, 1);
|
||||
assert.equal(retained.reinforcementCount, 2);
|
||||
assert.equal(retained.lastReinforcedSessionID, "same-session");
|
||||
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
|
||||
assert.equal(retained.retentionClock, now);
|
||||
assert.ok(reinforced, "same-session after window should emit reinforced evidence");
|
||||
assert.equal(reinforced.details?.reinforcementOutcome, "reinforced");
|
||||
assert.equal(reinforced.details?.reinforcementMode, "increment");
|
||||
assert.equal(reinforced.details?.sameSession, true);
|
||||
assert.equal(reinforced.details?.elapsedMs, 8 * DAY_MS);
|
||||
assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS);
|
||||
assert.equal(reinforced.details?.attemptedAtMs, now);
|
||||
assert.equal(reinforced.details?.lastReinforcedAtMs, now - 8 * DAY_MS);
|
||||
assert.equal(reinforced.details?.previousReinforcementCount, 1);
|
||||
assert.equal(reinforced.details?.newReinforcementCount, 2);
|
||||
assert.equal(JSON.stringify(reinforced.details).includes("same-session"), false);
|
||||
});
|
||||
|
||||
test("dedupe reinforcement does not increment under one hour", () => {
|
||||
const now = Date.now();
|
||||
test("dedupe reinforcement allows exactly 7 rolling days", () => {
|
||||
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const existing: LongTermMemoryEntry = {
|
||||
...entry("existing-exact-window", "Prefer deterministic consolidation accounting", "feedback"),
|
||||
source: "manual",
|
||||
reinforcementCount: 5,
|
||||
lastReinforcedAt: now - 7 * DAY_MS,
|
||||
lastReinforcedSessionID: "old-session",
|
||||
};
|
||||
const duplicate: LongTermMemoryEntry = {
|
||||
...entry("duplicate-exact-window", "prefer deterministic consolidation accounting!!!", "feedback"),
|
||||
pendingOwnerSessionID: "new-session",
|
||||
};
|
||||
|
||||
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
|
||||
const retained = result.kept.find(memory => memory.id === "existing-exact-window");
|
||||
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
|
||||
|
||||
assert.equal(retained?.reinforcementCount, 6);
|
||||
assert.ok(reinforced, "exact 7-day window should emit reinforced evidence");
|
||||
assert.equal(reinforced.details?.reinforcementMode, "increment");
|
||||
assert.equal(reinforced.details?.elapsedMs, 7 * DAY_MS);
|
||||
assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS);
|
||||
});
|
||||
|
||||
test("dedupe reinforcement blocks 7 rolling days minus 1ms with elapsed details", () => {
|
||||
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const existing: LongTermMemoryEntry = {
|
||||
...entry("existing", "Prefer deterministic consolidation accounting", "feedback"),
|
||||
source: "manual",
|
||||
reinforcementCount: 1,
|
||||
lastReinforcedAt: now - 30 * 60 * 1000,
|
||||
lastReinforcedAt: now - 7 * DAY_MS + 1,
|
||||
lastReinforcedSessionID: "old-session",
|
||||
};
|
||||
const duplicate: LongTermMemoryEntry = {
|
||||
@@ -754,29 +811,109 @@ test("dedupe reinforcement does not increment under one hour", () => {
|
||||
pendingOwnerSessionID: "new-session",
|
||||
};
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
|
||||
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
|
||||
const retained = result.kept.find(memory => memory.id === "existing");
|
||||
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
|
||||
|
||||
assert.ok(retained, "existing manual memory should be retained");
|
||||
assert.equal(retained.reinforcementCount, 1);
|
||||
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(blocked, "below-window duplicate reinforcement should emit rejected evidence");
|
||||
assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked"));
|
||||
assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_elapsed_window"));
|
||||
assert.equal(blocked.reasonCodes.includes("reinforcement_block_min_interval"), false);
|
||||
assert.equal(blocked.details?.blockReason, "min_elapsed_window");
|
||||
assert.equal(blocked.details?.sameSession, false);
|
||||
assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 1);
|
||||
assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS);
|
||||
assert.equal(blocked.details?.reinforcementCount, 1);
|
||||
assert.equal(blocked.details?.maxReinforcementCount, 6);
|
||||
assert.equal("minIntervalMs" in (blocked.details ?? {}), false);
|
||||
assert.equal("reinforcementMode" in (blocked.details ?? {}), false);
|
||||
});
|
||||
|
||||
test("dedupe reinforcement does not emit evidence at max reinforcement count", () => {
|
||||
test("dedupe reinforcement blocks same session at 6d23h as min elapsed window", () => {
|
||||
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const existing: LongTermMemoryEntry = {
|
||||
...entry("existing-same-session-window", "Prefer deterministic consolidation accounting", "feedback"),
|
||||
source: "manual",
|
||||
pendingOwnerSessionID: "same-session-window",
|
||||
reinforcementCount: 1,
|
||||
lastReinforcedAt: now - (7 * DAY_MS - 60 * 60 * 1000),
|
||||
lastReinforcedSessionID: "same-session-window",
|
||||
};
|
||||
const duplicate: LongTermMemoryEntry = {
|
||||
...entry("duplicate-same-session-window", "prefer deterministic consolidation accounting!!!", "feedback"),
|
||||
pendingOwnerSessionID: "same-session-window",
|
||||
};
|
||||
|
||||
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
|
||||
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
|
||||
|
||||
assert.ok(blocked, "6d23h same-session attempt should be rejected");
|
||||
assert.equal(blocked.details?.blockReason, "min_elapsed_window");
|
||||
assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 60 * 60 * 1000);
|
||||
assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS);
|
||||
assert.equal(blocked.details?.sameSession, true);
|
||||
assert.equal(blocked.reasonCodes.includes("reinforcement_block_same_session"), false);
|
||||
});
|
||||
|
||||
test("dedupe reinforcement refreshes saturated count after rolling window", () => {
|
||||
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const oldRetentionClock = now - 30 * DAY_MS;
|
||||
const existing: LongTermMemoryEntry = {
|
||||
...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"),
|
||||
source: "manual",
|
||||
reinforcementCount: 6,
|
||||
lastReinforcedAt: now - 7 * DAY_MS,
|
||||
lastReinforcedSessionID: "old-session",
|
||||
retentionClock: oldRetentionClock,
|
||||
};
|
||||
const duplicate: LongTermMemoryEntry = {
|
||||
...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"),
|
||||
pendingOwnerSessionID: "new-session",
|
||||
};
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
|
||||
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
|
||||
const retained = result.kept.find(memory => memory.id === "existing-max");
|
||||
const refreshed = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
|
||||
|
||||
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
|
||||
assert.equal(retained?.reinforcementCount, 6);
|
||||
assert.equal(retained?.retentionClock, now);
|
||||
assert.equal(retained?.lastReinforcedAt, now);
|
||||
assert.equal(retained?.lastReinforcedSessionID, "new-session");
|
||||
assert.ok(refreshed, "saturated after-window attempt should emit refreshed evidence");
|
||||
assert.equal(refreshed.details?.reinforcementOutcome, "refreshed");
|
||||
assert.equal(refreshed.details?.reinforcementMode, "refresh_only");
|
||||
assert.equal(refreshed.details?.previousReinforcementCount, 6);
|
||||
assert.equal(refreshed.details?.newReinforcementCount, 6);
|
||||
assert.ok(refreshed.reasonCodes.includes("reinforcement_saturation_refresh"));
|
||||
assert.equal(refreshed.reasonCodes.includes("reinforcement_block_max_count"), false);
|
||||
});
|
||||
|
||||
test("dedupe reinforcement reports legacy missing timestamp and normalizes it", () => {
|
||||
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
|
||||
const existing: LongTermMemoryEntry = {
|
||||
...entry("existing-legacy-missing", "Prefer deterministic consolidation accounting", "feedback"),
|
||||
source: "manual",
|
||||
reinforcementCount: 2,
|
||||
lastReinforcedSessionID: "old-session",
|
||||
};
|
||||
const duplicate: LongTermMemoryEntry = {
|
||||
...entry("duplicate-legacy-missing", "prefer deterministic consolidation accounting!!!", "feedback"),
|
||||
pendingOwnerSessionID: "new-session",
|
||||
};
|
||||
|
||||
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
|
||||
const retained = result.kept.find(memory => memory.id === "existing-legacy-missing");
|
||||
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
|
||||
|
||||
assert.equal(retained?.reinforcementCount, 3);
|
||||
assert.equal(retained?.lastReinforcedAt, now);
|
||||
assert.equal(retained?.retentionClock, now);
|
||||
assert.equal(reinforced?.details?.legacyMissingTimestamp, true);
|
||||
assert.equal(reinforced?.details?.reinforcementMode, "increment");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits orders entries by retention strength", () => {
|
||||
@@ -2644,6 +2781,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.identityKeyHash, "removed relation should include identity key hash");
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"declaration": false,
|
||||
"sourceMap": false,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"include": ["scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts", "src/**/*.ts"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"tests",
|
||||
"src/plugin.ts",
|
||||
"src/tui-plugin.ts",
|
||||
"src/opencode.ts",
|
||||
"src/session-state.ts",
|
||||
"src/extractors.ts",
|
||||
"src/pending-journal.ts",
|
||||
"src/promotion-accounting.ts",
|
||||
"src/memory-visibility.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user