mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9bdcdd785 | |||
| cf05b9fa69 | |||
| aaa4016ae8 | |||
| ff5c568cb7 | |||
| 0ed61d1ba2 | |||
| 2dbfb50080 | |||
| d569297c30 | |||
| 4f1c0348b4 | |||
| bc0847e3ed | |||
| 6a81fc384c | |||
| ed4590ca18 | |||
| 09cc4a2ffb | |||
| c0ebd84d7e | |||
| 20a6cfe1a6 | |||
| 36b78ea91c | |||
| 406c160c9f | |||
| 968aedd5c5 | |||
| d4053b2d35 | |||
| 85e11be2b9 | |||
| 5b0083efae | |||
| 60b9ca75c8 | |||
| 8da39c7a9d | |||
| e8c95a62ec | |||
| 56d7ef9a68 | |||
| 7427221640 | |||
| 9991c95ff6 | |||
| f7139f0844 | |||
| 465edfabf1 | |||
| 6a80f4b047 | |||
| b21347c12b | |||
| ffb0477251 | |||
| a1b9bf4fbc | |||
| a762e863d1 | |||
| 222bae28de | |||
| 53aa6d3c31 | |||
| 034dfe8d91 | |||
| e3ebdbfe9f | |||
| fdebd304f6 | |||
| 77d60abf5f | |||
| 560f63f96b | |||
| 11361abc91 | |||
| e071095422 | |||
| 909d6c7767 | |||
| c697f63c67 | |||
| 25b673fbb7 | |||
| acaa829df4 | |||
| fe6ce36e09 | |||
| 3cc6dff7ae | |||
| 1c748f3ee2 | |||
| ca68b7f55c | |||
| 023589a905 | |||
| 24f807fed0 | |||
| 097235e43b | |||
| 14bbb76cf1 |
@@ -5,74 +5,6 @@ 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.0] - 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
- Numbered compaction memory references (`[M1]`, `[M2]`, ...) for existing rendered workspace memories.
|
||||
- Compaction memory commands: `REINFORCE [M#]` for retention reinforcement and `REPLACE [M#] [type] text` for protected replacement.
|
||||
- `CompactionMemoryRef` session-state snapshots with optional compaction IDs for overlap detection.
|
||||
- Evidence events for numbered memory command outcomes: `memory_reinforced`, `memory_replaced_numbered_ref`, and `memory_reverted_numbered_ref`.
|
||||
- Public `memory-diag commands` report for command counts, outcomes, rejection reasons, protected replacement blocks, malformed commands, and latest command events.
|
||||
- Public dry-run-first `memory-diag revert` command for manually reverting successful numbered replacements by replacement memory ID or evidence event ID.
|
||||
- Hard quality rejection reasons for unresolved questions, transient bug/debug state, and deployment snapshots.
|
||||
- Soft `terse_label` diagnostic for very short label-like candidates.
|
||||
- Regression tests for command parsing, REINFORCE, protected REPLACE, revert behavior, compaction ref validation, overlap protection, and fallback behavior when the model omits the compaction snapshot ID.
|
||||
|
||||
### Changed
|
||||
|
||||
- Compaction prompts now include numbered memory refs and concise memory-operation rules instead of asking the model to reuse existing wording exactly.
|
||||
- Compaction no longer duplicates hot session state inside the compaction prompt; hot state remains available in normal prompt context.
|
||||
- Duplicate maintenance now prefers explicit REINFORCE or protected REPLACE evidence over silent duplicate restatement.
|
||||
- Rendered decision memory cap increased from 10 to 12 while keeping the global rendered cap at 28.
|
||||
- Rejected memory command evidence now uses neutral `target` relations instead of lifecycle-mutating `reinforced` or `superseded` relation roles.
|
||||
- `memory-diag` public command metadata now includes `commands` and `revert` alongside `status`, `rejected`, `missing`, and `explain`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Overlapping same-session compactions can no longer silently apply numbered commands against the wrong snapshot when the snapshot ID is present.
|
||||
- Numbered command resolution now rejects stale refs whose memory ID, status, or exact key no longer match the current workspace memory entry.
|
||||
- Protected replacements are surfaced as first-class diagnostics instead of being buried in generic rejection counts.
|
||||
|
||||
### Recovery note
|
||||
|
||||
Successful numbered replacements supersede the original memory and add a replacement. To inspect or recover one, run `memory-diag commands --verbose`, then dry-run `memory-diag revert --memory <replacement-memory-id>` or `memory-diag revert --event <event-id>` before adding `--apply`.
|
||||
|
||||
## [1.5.5] - 2026-05-05
|
||||
|
||||
### Added
|
||||
|
||||
- Section-aware greedy line accumulator for hot session state rendering that fits whole lines instead of truncating mid-line.
|
||||
- `accountHotSessionStateRender()` accounting function returning prompt text, omitted items, and char budget for v2 evidence extension.
|
||||
- Exported types for render accounting diagnostics: `HotStateSection`, `HotStateOmissionReason`, `HotStateOmittedItem`, `HotSessionStateRenderAccounting`.
|
||||
- 9 direct unit tests for hot session state rendering covering empty state, ranking, section caps, char budget, boundary conditions, header suppression, wrapper parity, and newline counting.
|
||||
- Two-layer omission model: section caps first (`section_cap`), then char budget (`char_budget`), with clear per-item omission reasons.
|
||||
|
||||
### Changed
|
||||
|
||||
- Hot session state rendering no longer uses blunt `.slice(0, maxRenderedChars)` prompt truncation.
|
||||
- Header-only sections are suppressed: a section is only rendered if at least one entry line fits the char budget.
|
||||
- `renderHotSessionState()` is now a delegation wrapper to `accountHotSessionStateRender().prompt`, preserving backward compatibility.
|
||||
- Fixed `docs/configuration.md` active-file ranking formula: was `count * action_weight * recency_decay` with weights 4/3/2/1, now correctly `ACTION_WEIGHT[action] + count * 3` with weights edit 50, write 45, grep 30, read 20, tie-break by `lastSeen` descending.
|
||||
- Clarified `README.md` system prompt injection order: workspace memory and hot state index positions depend on whether workspace memory state is available.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hot session state prompt could be truncated mid-line under the old `.slice()` budget enforcement.
|
||||
|
||||
## [1.5.4] - 2026-05-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Centralized the supported `memory-diag` command metadata for the official public commands: `status`, `rejected`, `missing`, and `explain`.
|
||||
- Removed pre-public legacy aliases so unsupported spellings now use the standard unknown-subcommand path instead of carrying v2.0 compatibility debt.
|
||||
- Kept `coverage` and `audit` as hidden maintainer diagnostics with neutral maintainer-only notices.
|
||||
- Removed public npm script exposure for the internal workspace cleanup tool while keeping the development tool in the repository.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Moved missing-memory disappearance detail formatting into the official `missing` formatter before deleting legacy disappearance formatter code.
|
||||
|
||||
## [1.5.3] - 2026-05-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -29,7 +29,6 @@ Use it when you want your agent to remember things like:
|
||||
- **Hot session state** — active files, open errors, and current working context for the current session.
|
||||
- **Explicit memory triggers** — users can say “remember this”, “記住”, “覚えて”, or “기억해” to save durable facts.
|
||||
- **Compaction-based extraction** — memory extraction piggybacks on OpenCode’s existing compaction flow.
|
||||
- **Numbered memory refs** — compaction can `REINFORCE [M#]` useful memories or safely `REPLACE [M#]` obsolete compaction memories.
|
||||
- **No manual tools** — memory is injected automatically into the system prompt.
|
||||
- **Quality guards** — filters noisy memories, temporary progress snapshots, stack traces, raw errors, and credentials.
|
||||
- **Retention decay** — keeps the strongest memories in prompt context while older or weaker memories fade out naturally; important and reinforced memories decay more slowly.
|
||||
@@ -86,13 +85,11 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls.
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ ⚡ Prompt Context │
|
||||
│ system[1]*: frozen workspace memory │
|
||||
│ system[2+]*: hot session state │
|
||||
│ system[1]: frozen workspace memory │
|
||||
│ system[2+]: hot session state │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
\* Conceptually, workspace memory is pushed first when it is non-empty, and hot session state is pushed after workspace memory. If workspace memory is empty, hot state may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
|
||||
|
||||
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
|
||||
|
||||
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
|
||||
@@ -203,22 +200,6 @@ The goal is to remember durable facts, not every detail.
|
||||
|
||||
Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y".
|
||||
|
||||
### Numbered Memory Refs
|
||||
|
||||
During compaction, existing workspace memories may be shown as numbered refs such as `[M1]` or `[M2]`. The model can use these refs to maintain memory without duplicating it:
|
||||
|
||||
```md
|
||||
REINFORCE [M1]
|
||||
REPLACE [M2] project Updated durable project fact.
|
||||
```
|
||||
|
||||
- `REINFORCE [M#]` strengthens an existing memory's retention signal without changing its text.
|
||||
- `REPLACE [M#] [type] text` supersedes a safe compaction-sourced memory and adds a replacement.
|
||||
- Manual, explicit, and already-reinforced memories are protected from automatic replacement.
|
||||
- Stale or mismatched numbered refs are rejected instead of mutating the wrong memory.
|
||||
|
||||
Use `memory-diag commands` to inspect command outcomes and `memory-diag revert` to dry-run and apply manual recovery for successful numbered replacements.
|
||||
|
||||
### Memory Diagnostics CLI
|
||||
|
||||
Use the read-only diagnostics CLI when you want to understand what memory is doing for the current workspace.
|
||||
@@ -229,8 +210,6 @@ Use the read-only diagnostics CLI when you want to understand what memory is doi
|
||||
| Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` |
|
||||
| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` |
|
||||
| Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain <memory-id>` |
|
||||
| How are numbered memory commands behaving? | `npx --package opencode-working-memory memory-diag commands` |
|
||||
| Revert a numbered replacement? | `npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>` |
|
||||
|
||||
Global options:
|
||||
|
||||
@@ -245,12 +224,8 @@ npx --package opencode-working-memory memory-diag status
|
||||
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 revert --memory <replacement-memory-id>
|
||||
```
|
||||
|
||||
`memory-diag revert` is dry-run by default. Add `--apply` only after reviewing the planned original/replacement status changes.
|
||||
|
||||
The npm package is opencode-working-memory; the installed bin is memory-diag, so package-qualified npx avoids resolving a different package named memory-diag.
|
||||
|
||||
Maintainer-only diagnostics and cleanup commands are intentionally not documented here. Future work: move those internal commands to `docs/development.md`.
|
||||
@@ -274,7 +249,7 @@ See [Configuration](docs/configuration.md) for customization options.
|
||||
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.
|
||||
- Enforce explicit `supersedes` chains for safer replacement of obsolete memories.
|
||||
- Explore tiered hot/warm/cold storage after the retention model has more real-world data.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -1,170 +1,5 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.6.0 (2026-05-08)
|
||||
|
||||
### Numbered Memory Refs
|
||||
|
||||
This release turns compaction from a one-way memory extractor into a memory maintenance loop. The model now sees numbered references for existing workspace memories and can explicitly reinforce a still-useful memory or propose a protected replacement when compaction reveals that old memory is obsolete.
|
||||
|
||||
The goal is not to make memory more aggressive. It is to make memory more accountable: old facts should be strengthened when they keep proving useful, replaced only when the target is safe, and diagnosable when the model tries something risky.
|
||||
|
||||
> **Good memory is selective memory.**
|
||||
> v1.6 lets memory say “this still matters” without copying it again — and lets obsolete compaction memories fade behind a safer replacement trail.
|
||||
|
||||
```text
|
||||
compaction summary
|
||||
│
|
||||
▼
|
||||
Memory candidates:
|
||||
Memory ref snapshot id: <uuid>
|
||||
[M1] decision · reinforced=2 · source=explicit
|
||||
[M2] project · reinforced=0 · source=compaction
|
||||
│
|
||||
├─ REINFORCE [M1]
|
||||
│ ↑ slows decay, no text mutation
|
||||
│
|
||||
└─ REPLACE [M2] project Updated durable fact
|
||||
↑ only allowed for safe compaction targets
|
||||
```
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Numbered memory refs**: compaction prompts now render existing workspace memories as `[M1]`, `[M2]`, ... references so the model can target a known memory instead of restating it as a duplicate candidate.
|
||||
- **REINFORCE commands**: `REINFORCE [M#]` increments the target memory's reinforcement count and updates its retention clock without changing its text.
|
||||
- **Protected REPLACE commands**: `REPLACE [M#] [type] text` supersedes the old memory and appends a replacement only when the target is safe: active, compaction-sourced, and not already reinforced.
|
||||
- **Reinforce + append workflow**: when a memory is mostly right but needs more context, compaction can reinforce the old memory and emit a new candidate for the new durable fact instead of mutating history.
|
||||
- **Compaction prompt restructure**: verbose type definitions and the old “reuse existing wording exactly” instruction were replaced with shorter command rules, categorization guidance, and concrete memory-operation examples.
|
||||
- **Hot state removed from compaction context**: active files, current errors, and pending session state remain in normal prompt context but are no longer duplicated inside the compaction prompt, saving budget and reducing accidental promotion of transient state.
|
||||
- **New hard quality gates**: unresolved questions, transient bug/debug state, and deployment snapshots are rejected as durable memory candidates.
|
||||
- **Soft terse-label diagnostic**: very short label-like candidates are reported for tuning without being hard-rejected in v1.6.
|
||||
- **Decision cap raised**: rendered decision memories now have a per-type cap of 12 instead of 10, while the global rendered cap remains 28.
|
||||
- **Overlap guard for compaction refs**: memory ref snapshots are tagged with a compaction ID when available, so overlapping same-session compactions cannot silently apply commands against the wrong numbered snapshot.
|
||||
- **Safer evidence semantics**: rejected memory command events use a neutral `target` relation role instead of lifecycle roles such as `reinforced` or `superseded`.
|
||||
|
||||
### Why This Helps
|
||||
|
||||
- Useful memories can become stronger through real reuse instead of duplicate extraction.
|
||||
- Obsolete compaction-sourced memories can be replaced with an explicit evidence trail rather than left to drift.
|
||||
- Manual, explicit, and already-reinforced memories are protected from automatic replacement.
|
||||
- Compaction prompt budget is spent on durable memory maintenance, not on duplicated hot session state.
|
||||
- Command outcomes are visible enough to tune the feature after release instead of guessing from reinforcement counts alone.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Inspect command behavior with:
|
||||
|
||||
```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 --json
|
||||
```
|
||||
|
||||
The command report includes:
|
||||
|
||||
- compactions with command evidence
|
||||
- REINFORCE and REPLACE counts
|
||||
- reinforced, superseded, rejected, and blocked outcomes
|
||||
- invalid or malformed command counts
|
||||
- same-type vs cross-type replacements
|
||||
- protected REPLACE blocks, split by reinforced target and protected source
|
||||
- latest command events in verbose mode
|
||||
|
||||
If a numbered replacement needs manual recovery, use the dry-run-first revert command:
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>
|
||||
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id> --apply
|
||||
```
|
||||
|
||||
You can also target a replacement evidence event directly:
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory memory-diag revert --event <event-id>
|
||||
```
|
||||
|
||||
### Safety Model
|
||||
|
||||
- REINFORCE never edits memory text.
|
||||
- REPLACE is rejected for manual or explicit memories.
|
||||
- REPLACE is rejected for already reinforced targets.
|
||||
- REPLACE is rejected if the numbered ref no longer matches the current memory ID, status, and exact key.
|
||||
- If a compaction snapshot ID is present and mismatched, all numbered commands from that summary are rejected with `missing_memory_ref_snapshot`.
|
||||
- If the model omits the snapshot ID, v1.6 falls back to exact memory ref validation for compatibility and command effectiveness.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes required.
|
||||
- Existing workspace memory files remain compatible.
|
||||
- Existing session state remains compatible; old sessions without compaction ref snapshots fall back safely.
|
||||
- Existing evidence logs remain compatible; new command events are appended only after v1.6 runs.
|
||||
- `memory-diag` now exposes two additional public commands: `commands` and `revert`.
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 405 tests passing, `TEST_PASS`
|
||||
|
||||
---
|
||||
|
||||
## 1.5.5 (2026-05-05)
|
||||
|
||||
### Hot State Rendering Health
|
||||
|
||||
This release replaces blunt prompt truncation with a section-aware greedy renderer that fits whole lines and suppresses empty sections, plus render accounting types for future diagnostics.
|
||||
|
||||
> **Good rendering is selective too.** If a section doesn't fit, omit it — don't cut it in half.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Whole-line rendering**: the hot session state prompt no longer truncates mid-line with `.slice(0, maxRenderedChars)`. A greedy line accumulator fits complete lines and stops when the next line would exceed the 700-char budget.
|
||||
- **Header-only sections suppressed**: a section heading is only rendered if at least one entry fits alongside it. No more orphaned `active_files:` headers with no content underneath.
|
||||
- **Render accounting**: `accountHotSessionStateRender()` now returns `prompt`, `omitted[]`, and `maxRenderedChars` so future v2 diagnostics can surface which items fell out and why.
|
||||
- **Two-layer omission model**: section caps (`section_cap`) trim per-type overflow first, then the char budget (`char_budget`) trims anything that doesn't fit. Each omitted item carries its reason and section.
|
||||
- **Backward-compatible wrapper**: `renderHotSessionState()` delegates to the accounting function and returns just the prompt string, preserving existing plugin behavior.
|
||||
|
||||
### Docs Fixes
|
||||
|
||||
- **Active-file ranking formula corrected**: `docs/configuration.md` now matches the actual implementation — `ACTION_WEIGHT[action] + count * 3` with weights edit 50, write 45, grep 30, read 20, tie-break by `lastSeen` descending. The old docs claimed `count * action_weight * recency_decay` with weights 4/3/2/1.
|
||||
- **Injection order clarified**: README now explains that workspace memory and hot state system-prompt positions depend on whether workspace memory state is available.
|
||||
|
||||
### Not Included Yet
|
||||
|
||||
- Evidence events for omitted items are deferred to v2, pending a `memory-diag` consumer to avoid unused storage litter.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes required.
|
||||
- Existing workspace memory files remain compatible.
|
||||
- Hot session state rendering behavior is intentionally different (cleaner output, no mid-line cuts), but the same information is presented when it fits within the budget.
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 356 tests passing, `TEST_PASS`
|
||||
|
||||
---
|
||||
|
||||
## 1.5.4 (2026-05-02)
|
||||
|
||||
### Memory Diagnostics Surface Cleanup
|
||||
|
||||
This cleanup release keeps the newly published `memory-diag` CLI small before legacy spellings become compatibility debt.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Official commands only**: the public CLI surface is `status`, `rejected`, `missing`, and `explain`.
|
||||
- **Pre-public aliases removed**: old spellings such as `health`, `quality`, `rejections`, `disappearances`, and `trace` are no longer recognized.
|
||||
- **Maintainer diagnostics clarified**: hidden `coverage` and `audit` commands remain available as maintainer-only diagnostics and stay out of public usage output.
|
||||
- **Cleaner internals**: current command metadata now has one source of truth, and legacy command/formatter wrappers were removed.
|
||||
- **Internal cleanup script hidden**: the workspace cleanup helper is no longer exposed as an npm script; the underlying development tool remains in `scripts/dev/`.
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 347 tests passing, `TEST_PASS`
|
||||
|
||||
---
|
||||
|
||||
## 1.5.3 (2026-05-02)
|
||||
|
||||
### Published Memory Diagnostics CLI
|
||||
|
||||
+6
-23
@@ -87,33 +87,16 @@ const HOT_STATE_LIMITS = {
|
||||
|
||||
## Active File Scoring
|
||||
|
||||
Files are ranked by action type plus repeated access count:
|
||||
Files are ranked by action type:
|
||||
|
||||
| Action | Weight | Description |
|
||||
|--------|--------|-------------|
|
||||
| `edit` | 50 | File modified |
|
||||
| `write` | 45 | File created/overwritten |
|
||||
| `grep` | 30 | Grep searched in file |
|
||||
| `read` | 20 | File read |
|
||||
| `write` | 4 | File created/overwritten |
|
||||
| `edit` | 3 | File modified |
|
||||
| `read` | 2 | File read |
|
||||
| `grep` | 1 | Grep searched in file |
|
||||
|
||||
Score formula: `ACTION_WEIGHT[action] + count * 3`
|
||||
|
||||
When scores tie, the most recent `lastSeen` timestamp sorts first. There is no recency decay factor in the score itself.
|
||||
|
||||
## Hot Session State Truncation
|
||||
|
||||
Hot state rendering applies section caps before the character budget:
|
||||
|
||||
| Section | Rendered cap |
|
||||
|---------|--------------|
|
||||
| `active_files` | 8 |
|
||||
| `open_errors` | 3 |
|
||||
| `recent_decisions` | 8 |
|
||||
| `pending_memories` | 6 |
|
||||
|
||||
After section caps, the renderer applies the 700-character budget with whole-line inclusion only. The prefix line is counted against the budget, and a section heading is emitted only when at least one entry from that section fits; header-only sections are suppressed.
|
||||
|
||||
`accountHotSessionStateRender()` returns prompt and omission accounting for future diagnostics. Evidence event integration for hot-state render omissions is planned for v2.
|
||||
Score formula: `count * action_weight * recency_decay`
|
||||
|
||||
## Error Categories
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.6.0",
|
||||
"version": "1.5.3",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
@@ -24,6 +24,7 @@
|
||||
"diag": "node --experimental-strip-types scripts/memory-diag.ts",
|
||||
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
|
||||
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
|
||||
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
|
||||
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
+68
-30
@@ -1,5 +1,4 @@
|
||||
import { HIDDEN_COMMAND_NOTICES, isCommand } from "./command-metadata.ts";
|
||||
import type { CliOptions, Command, ParsedArgs } from "./types.ts";
|
||||
import type { CliOptions, Command, LegacyCommand, ParsedArgs } from "./types.ts";
|
||||
|
||||
export type { ParsedArgs } from "./types.ts";
|
||||
|
||||
@@ -9,8 +8,6 @@ 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 revert (--memory <replacement-id> | --event <event-id>) [--workspace <path>] [--apply]
|
||||
|
||||
Global options:
|
||||
--workspace <path> Workspace path (default: current directory)
|
||||
@@ -24,6 +21,23 @@ function error(message: string): ParsedArgs {
|
||||
return { ok: false, message, usage: usage(), exitCode: 1 };
|
||||
}
|
||||
|
||||
function isCommand(value: string | undefined): value is Command {
|
||||
return value === "status"
|
||||
|| value === "rejected"
|
||||
|| value === "missing"
|
||||
|| value === "explain"
|
||||
|| value === "coverage"
|
||||
|| value === "audit";
|
||||
}
|
||||
|
||||
function isLegacyCommand(value: string | undefined): value is LegacyCommand {
|
||||
return value === "health"
|
||||
|| value === "quality"
|
||||
|| value === "rejections"
|
||||
|| value === "disappearances"
|
||||
|| value === "trace";
|
||||
}
|
||||
|
||||
function isValidSince(rawSince: string): boolean {
|
||||
if (/^(\d+)([dhm])$/i.test(rawSince)) return true;
|
||||
return !Number.isNaN(new Date(rawSince).getTime());
|
||||
@@ -36,19 +50,44 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
}
|
||||
|
||||
let command: Command = "status";
|
||||
let legacyCommand: LegacyCommand | undefined;
|
||||
let deprecationNotice: string | undefined;
|
||||
let rest = argv;
|
||||
if (first && !first.startsWith("--")) {
|
||||
rest = tail;
|
||||
if (isCommand(first)) {
|
||||
command = first;
|
||||
deprecationNotice = HIDDEN_COMMAND_NOTICES[first];
|
||||
if (first === "coverage") {
|
||||
deprecationNotice = "Note: 'coverage' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit evidence'.";
|
||||
} else if (first === "audit") {
|
||||
deprecationNotice = "Note: 'audit' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit migrations'.";
|
||||
}
|
||||
} else if (isLegacyCommand(first)) {
|
||||
legacyCommand = first;
|
||||
if (first === "health") {
|
||||
command = "status";
|
||||
deprecationNotice = "Note: 'health' is now 'status'. This alias will be removed in v2.0.";
|
||||
} else if (first === "quality") {
|
||||
command = "status";
|
||||
deprecationNotice = "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0.";
|
||||
} else if (first === "rejections") {
|
||||
command = "rejected";
|
||||
deprecationNotice = "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0.";
|
||||
} else if (first === "disappearances") {
|
||||
command = "missing";
|
||||
deprecationNotice = "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0.";
|
||||
} else {
|
||||
command = "explain";
|
||||
deprecationNotice = "Note: 'trace --memory <id>' is now 'explain <memory-id>'. This alias will be removed in v2.0.";
|
||||
}
|
||||
} else {
|
||||
return error(`Unknown subcommand: ${first}`);
|
||||
}
|
||||
}
|
||||
|
||||
const options: CliOptions = { raw: false, positional: [] };
|
||||
if (legacyCommand) options.legacyCommand = legacyCommand;
|
||||
if (legacyCommand === "quality") options.verbose = true;
|
||||
for (let i = 0; i < rest.length; i += 1) {
|
||||
const arg = rest[i];
|
||||
if (arg === "--raw") options.raw = true;
|
||||
@@ -59,8 +98,9 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
else if (arg === "--soft-only") options.softOnly = true;
|
||||
else if (arg === "--trigger-only") options.triggerOnly = true;
|
||||
else if (arg === "--include-historical") options.includeHistorical = true;
|
||||
else if (arg === "--quality") options.quality = true;
|
||||
else if (arg === "--unique") options.unique = true;
|
||||
else if (arg === "--explain") options.explain = true;
|
||||
else if (arg === "--apply") options.apply = true;
|
||||
else if (arg === "--workspace") {
|
||||
const value = rest[++i];
|
||||
if (!value) return error("--workspace requires a path");
|
||||
@@ -81,17 +121,14 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
const value = rest[++i];
|
||||
if (!value) return error("--memory requires an id");
|
||||
options.memory = value;
|
||||
} else if (arg === "--event") {
|
||||
const value = rest[++i];
|
||||
if (!value) return error("--event requires an id");
|
||||
options.event = value;
|
||||
} else if (!arg.startsWith("--") && command === "explain") {
|
||||
} else if (!arg.startsWith("--") && command === "explain" && legacyCommand !== "trace") {
|
||||
options.positional?.push(arg);
|
||||
} else {
|
||||
return error(`Unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const displayCommand = legacyCommand ?? command;
|
||||
if (command === "explain") {
|
||||
const positional = options.positional ?? [];
|
||||
if (positional.length > 1) return error("explain accepts at most one memory id");
|
||||
@@ -101,33 +138,34 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
return error(`Unknown option: ${options.positional?.[0]}`);
|
||||
}
|
||||
|
||||
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") {
|
||||
if (options.all) return error(`${command} does not accept --all`);
|
||||
if (legacyCommand === "health") {
|
||||
if (options.all && options.workspace) return error("Use either --all or --workspace, not both");
|
||||
if (options.json && options.all) return error("health --json does not support --all");
|
||||
} else if (command === "status") {
|
||||
if (options.all) return error(`${displayCommand} does not accept --all`);
|
||||
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain") {
|
||||
if (options.all) return error(`${displayCommand} does not accept --all`);
|
||||
} else {
|
||||
if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`);
|
||||
if (options.all || options.workspace) return error(`${displayCommand} does not accept --all or --workspace`);
|
||||
}
|
||||
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands") {
|
||||
return error(`${command} does not accept --json`);
|
||||
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage") {
|
||||
return error(`${displayCommand} does not accept --json`);
|
||||
}
|
||||
if (legacyCommand === "rejections" && options.json && !options.quality) return error("rejections --json requires --quality");
|
||||
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
|
||||
return error(`${command} does not accept rejection filters`);
|
||||
return error(`${displayCommand} does not accept rejection filters`);
|
||||
}
|
||||
if (command !== "coverage" && options.includeHistorical) return error(`${command} does not accept --include-historical`);
|
||||
if (command !== "rejected" && options.reason) return error(`${command} does not accept rejection filters`);
|
||||
if (command !== "missing" && options.explain) return error(`${command} does not accept --explain`);
|
||||
if (command !== "coverage" && options.includeHistorical) return error(`${displayCommand} does not accept --include-historical`);
|
||||
if (command !== "rejected" && (options.quality || options.reason || options.unique)) return error(`${displayCommand} does not accept rejection quality filters`);
|
||||
if (command !== "missing" && options.explain) return error(`${displayCommand} does not accept --explain`);
|
||||
if (command !== "audit" && options.migration) {
|
||||
return error(`${command} does not accept --migration`);
|
||||
return error(`${displayCommand} does not accept --migration`);
|
||||
}
|
||||
if (command !== "explain" && command !== "revert" && options.memory) {
|
||||
return error(`${command} does not accept --memory`);
|
||||
if (legacyCommand === "trace" && !options.memory) {
|
||||
return error("--memory requires an id");
|
||||
}
|
||||
if (command !== "revert" && options.event) return error(`${command} does not accept --event`);
|
||||
if (command !== "revert" && options.apply) return error(`${command} does not accept --apply`);
|
||||
if (command === "revert") {
|
||||
if (!options.memory && !options.event) return error("revert requires --memory or --event");
|
||||
if (options.memory && options.event) return error("Use either --memory or --event, not both");
|
||||
if (command !== "explain" && options.memory) {
|
||||
return error(`${displayCommand} does not accept --memory`);
|
||||
}
|
||||
if (command === "rejected" && options.since && !isValidSince(options.since)) {
|
||||
return error(`Invalid --since value: ${options.since}`);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "revert"] as const;
|
||||
export const HIDDEN_COMMANDS = ["coverage", "audit"] as const;
|
||||
|
||||
export type VisibleCommand = typeof VISIBLE_COMMANDS[number];
|
||||
export type HiddenCommand = typeof HIDDEN_COMMANDS[number];
|
||||
export type Command = VisibleCommand | HiddenCommand;
|
||||
|
||||
export const HIDDEN_COMMAND_NOTICES: Partial<Record<Command, string>> = {
|
||||
coverage: "Note: 'coverage' is a maintainer-only diagnostic and is not part of the public CLI surface.",
|
||||
audit: "Note: 'audit' is a maintainer-only diagnostic and is not part of the public CLI surface.",
|
||||
};
|
||||
|
||||
export function isCommand(value: string | undefined): value is Command {
|
||||
return value !== undefined
|
||||
&& ([...VISIBLE_COMMANDS, ...HIDDEN_COMMANDS] as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import { runAudit } from "./commands/audit.ts";
|
||||
import { runCommands } from "./commands/commands.ts";
|
||||
import { runCoverage } from "./commands/coverage.ts";
|
||||
import { runDisappearances } from "./commands/disappearances.ts";
|
||||
import { runExplain } from "./commands/explain.ts";
|
||||
import { runHealth } from "./commands/health.ts";
|
||||
import { runMissing } from "./commands/missing.ts";
|
||||
import { runQuality } from "./commands/quality.ts";
|
||||
import { runRejected } from "./commands/rejected.ts";
|
||||
import { runRevert } from "./commands/revert.ts";
|
||||
import { runRejections } from "./commands/rejections.ts";
|
||||
import { runStatus } from "./commands/status.ts";
|
||||
import { runTrace } from "./commands/trace.ts";
|
||||
import type { CliOptions, Command, CommandResult } from "./types.ts";
|
||||
|
||||
export async function dispatch(command: Command, options: CliOptions): Promise<CommandResult> {
|
||||
if (options.legacyCommand === "health") return runHealth(options);
|
||||
if (options.legacyCommand === "quality") return runQuality(options);
|
||||
if (options.legacyCommand === "rejections") return runRejections(options);
|
||||
if (options.legacyCommand === "disappearances") return runDisappearances(options);
|
||||
if (options.legacyCommand === "trace") return runTrace(options);
|
||||
|
||||
switch (command) {
|
||||
case "status": return runStatus(options);
|
||||
case "rejected": return runRejected(options);
|
||||
@@ -16,7 +25,5 @@ export async function dispatch(command: Command, options: CliOptions): Promise<C
|
||||
case "coverage": return runCoverage(options);
|
||||
case "audit": return runAudit(options);
|
||||
case "explain": return runExplain(options);
|
||||
case "commands": return runCommands(options);
|
||||
case "revert": return runRevert(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { queryEvidenceEvents, type EvidenceEventV1, type EvidenceOutcome } from "../../../src/evidence-log.ts";
|
||||
import { objectFromCounts, sortedCounts } from "../text.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
|
||||
type CommandKind = "reinforce" | "replace";
|
||||
|
||||
type MemoryCommandSummary = {
|
||||
version: 1;
|
||||
generatedAt: string;
|
||||
compactionsWithCommandEvidence: number;
|
||||
commands: Record<CommandKind, number>;
|
||||
outcomes: Record<"reinforced" | "superseded" | "rejected" | "blocked", number>;
|
||||
invalidMalformedCommands: number;
|
||||
replacements: {
|
||||
sameType: number;
|
||||
crossType: number;
|
||||
};
|
||||
protectedReplacements: {
|
||||
total: number;
|
||||
protectedReinforcedTarget: number;
|
||||
protectedMemorySource: number;
|
||||
};
|
||||
rejectionReasons: Record<string, number>;
|
||||
latestEvents: Array<{
|
||||
eventId: string;
|
||||
createdAt: string;
|
||||
type: string;
|
||||
outcome: EvidenceOutcome;
|
||||
ref?: string;
|
||||
memoryId?: string;
|
||||
reasonCodes: string[];
|
||||
textPreview?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const INVALID_COMMAND_REASONS = new Set([
|
||||
"invalid_memory_command",
|
||||
"invalid_memory_ref",
|
||||
"invalid_memory_type",
|
||||
"empty_replacement_text",
|
||||
]);
|
||||
|
||||
function hasReason(event: EvidenceEventV1, reason: string): boolean {
|
||||
return event.reasonCodes.includes(reason);
|
||||
}
|
||||
|
||||
function isInvalidMalformedCommandEvent(event: EvidenceEventV1): boolean {
|
||||
return event.type === "extraction_candidate_rejected"
|
||||
&& event.reasonCodes.some(reason => INVALID_COMMAND_REASONS.has(reason));
|
||||
}
|
||||
|
||||
function isParsedCommandEvent(event: EvidenceEventV1): boolean {
|
||||
return event.type === "memory_reinforced" || event.type === "memory_replaced_numbered_ref";
|
||||
}
|
||||
|
||||
function isManualRevertEvent(event: EvidenceEventV1): boolean {
|
||||
return event.type === "memory_reverted_numbered_ref";
|
||||
}
|
||||
|
||||
function isCommandEvidenceEvent(event: EvidenceEventV1): boolean {
|
||||
return isParsedCommandEvent(event) || isInvalidMalformedCommandEvent(event) || isManualRevertEvent(event);
|
||||
}
|
||||
|
||||
function refFromEvent(event: EvidenceEventV1): string | undefined {
|
||||
const ref = event.details?.ref;
|
||||
return typeof ref === "string" ? ref : undefined;
|
||||
}
|
||||
|
||||
function latestEventJSON(event: EvidenceEventV1): MemoryCommandSummary["latestEvents"][number] {
|
||||
return {
|
||||
eventId: event.eventId,
|
||||
createdAt: event.createdAt,
|
||||
type: event.type,
|
||||
outcome: event.outcome,
|
||||
ref: refFromEvent(event),
|
||||
memoryId: event.memory?.memoryId,
|
||||
reasonCodes: event.reasonCodes,
|
||||
textPreview: event.textPreview,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMemoryCommandSummary(events: EvidenceEventV1[], generatedAt = new Date().toISOString()): MemoryCommandSummary {
|
||||
const commandEvents = events.filter(isCommandEvidenceEvent);
|
||||
const compactionCommandEvents = commandEvents.filter(event => !isManualRevertEvent(event));
|
||||
const parsedEvents = compactionCommandEvents.filter(isParsedCommandEvent);
|
||||
const invalidEvents = compactionCommandEvents.filter(isInvalidMalformedCommandEvent);
|
||||
const sessions = new Set(compactionCommandEvents.map(event => event.sessionHash).filter((value): value is string => typeof value === "string" && value.length > 0));
|
||||
const replacementSuccesses = parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && event.outcome === "superseded");
|
||||
const rejectedCommandEvents = commandEvents.filter(event => event.outcome === "rejected");
|
||||
const rejectionReasonCounts = new Map<string, number>();
|
||||
|
||||
for (const event of rejectedCommandEvents) {
|
||||
for (const reason of event.reasonCodes) {
|
||||
rejectionReasonCounts.set(reason, (rejectionReasonCounts.get(reason) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const protectedReinforcedTarget = parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && hasReason(event, "protected_reinforced_target")).length;
|
||||
const protectedMemorySource = parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && hasReason(event, "protected_memory_source")).length;
|
||||
const parsedRejected = parsedEvents.filter(event => event.outcome === "rejected").length;
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt,
|
||||
compactionsWithCommandEvidence: sessions.size > 0 ? sessions.size : compactionCommandEvents.length > 0 ? 1 : 0,
|
||||
commands: {
|
||||
reinforce: parsedEvents.filter(event => event.type === "memory_reinforced").length,
|
||||
replace: parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref").length,
|
||||
},
|
||||
outcomes: {
|
||||
reinforced: parsedEvents.filter(event => event.outcome === "reinforced").length,
|
||||
superseded: parsedEvents.filter(event => event.outcome === "superseded").length,
|
||||
rejected: parsedRejected,
|
||||
blocked: parsedRejected,
|
||||
},
|
||||
invalidMalformedCommands: invalidEvents.length,
|
||||
replacements: {
|
||||
sameType: replacementSuccesses.filter(event => hasReason(event, "same_type_replace")).length,
|
||||
crossType: replacementSuccesses.filter(event => hasReason(event, "cross_type_replace")).length,
|
||||
},
|
||||
protectedReplacements: {
|
||||
total: parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && (hasReason(event, "protected_reinforced_target") || hasReason(event, "protected_memory_source"))).length,
|
||||
protectedReinforcedTarget,
|
||||
protectedMemorySource,
|
||||
},
|
||||
rejectionReasons: objectFromCounts(rejectionReasonCounts),
|
||||
latestEvents: commandEvents.slice(-10).reverse().map(latestEventJSON),
|
||||
};
|
||||
}
|
||||
|
||||
function formatReasonCounts(rejectionReasons: Record<string, number>): string[] {
|
||||
const counts = new Map(Object.entries(rejectionReasons));
|
||||
const rows = sortedCounts(counts);
|
||||
if (rows.length === 0) return [" (none)"];
|
||||
return rows.map(([reason, count]) => ` - ${reason}: ${count}`);
|
||||
}
|
||||
|
||||
function formatLatestEvents(events: MemoryCommandSummary["latestEvents"]): string[] {
|
||||
if (events.length === 0) return [" (none)"];
|
||||
return events.map(event => {
|
||||
const ref = event.ref ? ` ref=${event.ref}` : "";
|
||||
const memoryId = event.memoryId ? ` memory=${event.memoryId}` : "";
|
||||
const textPreview = event.textPreview ? ` text=${JSON.stringify(event.textPreview)}` : "";
|
||||
return ` - ${event.createdAt} ${event.type} ${event.outcome}${ref}${memoryId} reasons=${event.reasonCodes.join(",") || "none"}${textPreview}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMemoryCommandSummary(summary: MemoryCommandSummary, options: Pick<CliOptions, "verbose" | "noEmoji"> = {}): string {
|
||||
const warning = options.noEmoji ? "!" : "⚠";
|
||||
const lines = [
|
||||
"Memory command diagnostics",
|
||||
"",
|
||||
"Key metrics:",
|
||||
` - compactions with command evidence: ${summary.compactionsWithCommandEvidence}`,
|
||||
` - reinforce: ${summary.commands.reinforce}`,
|
||||
` - replace: ${summary.commands.replace}`,
|
||||
` - reinforced: ${summary.outcomes.reinforced}`,
|
||||
` - superseded: ${summary.outcomes.superseded}`,
|
||||
` - rejected: ${summary.outcomes.rejected}`,
|
||||
` - blocked: ${summary.outcomes.blocked}`,
|
||||
` - invalid/malformed commands: ${summary.invalidMalformedCommands}`,
|
||||
` - same-type replacements: ${summary.replacements.sameType}`,
|
||||
` - cross-type replacements: ${summary.replacements.crossType}`,
|
||||
` - ${warning} Protected REPLACE blocked: ${summary.protectedReplacements.total} (reinforced: ${summary.protectedReplacements.protectedReinforcedTarget}, source: ${summary.protectedReplacements.protectedMemorySource})`,
|
||||
"",
|
||||
"Rejection reasons:",
|
||||
...formatReasonCounts(summary.rejectionReasons),
|
||||
];
|
||||
|
||||
if (options.verbose) {
|
||||
lines.push("", "Latest command events:", ...formatLatestEvents(summary.latestEvents));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function runCommands(options: CliOptions): Promise<CommandResult> {
|
||||
const root = options.workspace ?? process.cwd();
|
||||
const events = await queryEvidenceEvents(root);
|
||||
const summary = buildMemoryCommandSummary(events);
|
||||
|
||||
if (options.json) {
|
||||
return { stdout: JSON.stringify(summary, null, 2) };
|
||||
}
|
||||
|
||||
return { stdout: formatMemoryCommandSummary(summary, options) };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
|
||||
import { buildDisappearancesJSON, formatDisappearances } from "../formatters/disappearances.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
|
||||
export async function runDisappearances(options: CliOptions): Promise<CommandResult> {
|
||||
const model = await buildInspectionReadModel(options);
|
||||
const rows = disappearanceRows(model);
|
||||
|
||||
if (options.json) {
|
||||
return { stdout: JSON.stringify(buildDisappearancesJSON(rows, { explain: options.explain }), null, 2) };
|
||||
}
|
||||
|
||||
return { stdout: formatDisappearances(rows, { explain: options.explain }) };
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
import { traceMemoryLifecycle } from "../../../src/evidence-log.ts";
|
||||
import { formatExplain } from "../formatters/explain.ts";
|
||||
import { formatTrace } from "../formatters/trace.ts";
|
||||
import { snapshotForOptions } from "../workspace-snapshot.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
import { runTrace } from "./trace.ts";
|
||||
|
||||
export async function runExplain(options: CliOptions): Promise<CommandResult> {
|
||||
if (options.memory) {
|
||||
const root = options.workspace ?? process.cwd();
|
||||
const [snapshot, trace] = await Promise.all([
|
||||
snapshotForOptions(options),
|
||||
traceMemoryLifecycle(root, { memoryId: options.memory }),
|
||||
]);
|
||||
return { stdout: formatTrace(options.memory, snapshot, trace) };
|
||||
}
|
||||
if (options.memory) return runTrace(options);
|
||||
|
||||
const snapshot = await snapshotForOptions(options);
|
||||
return { stdout: formatExplain(snapshot) };
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { join } from "node:path";
|
||||
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../../../src/paths.ts";
|
||||
import { scanWorkspaceResidues } from "../../../src/workspace-cleanup.ts";
|
||||
import type { PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts";
|
||||
import { formatWorkspaceHealth, type WorkspaceHealthInput } from "../formatters/health.ts";
|
||||
import { pathExists, readJSONFile } from "../io.ts";
|
||||
import { buildMemoryDiagJSON } from "../workspace-snapshot.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
|
||||
type WorkspaceHealthCommandInput = Omit<WorkspaceHealthInput, "now">;
|
||||
|
||||
async function workspaceHealthOutput(input: WorkspaceHealthCommandInput): Promise<string> {
|
||||
return formatWorkspaceHealth({ ...input, now: Date.now() }, {
|
||||
rawStore: await readJSONFile<WorkspaceMemoryStore>(input.memoryPath),
|
||||
rawJournal: await readJSONFile<PendingMemoryJournalStore>(input.pendingPath),
|
||||
pendingExists: pathExists(input.pendingPath),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runHealth(options: CliOptions): Promise<CommandResult> {
|
||||
if (options.json) {
|
||||
const root = options.workspace ?? process.cwd();
|
||||
return { stdout: JSON.stringify(await buildMemoryDiagJSON(root), null, 2) };
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 });
|
||||
const lines: string[] = ["Workspace memory health", ""];
|
||||
if (scan.results.length === 0) {
|
||||
lines.push("No workspace stores found.");
|
||||
return { stdout: lines.join("\n") };
|
||||
}
|
||||
for (let i = 0; i < scan.results.length; i += 1) {
|
||||
const result = scan.results[i];
|
||||
if (i > 0) lines.push("");
|
||||
lines.push(await workspaceHealthOutput({
|
||||
root: result.root,
|
||||
key: result.workspaceKey,
|
||||
memoryPath: join(result.workspaceDir, "workspace-memory.json"),
|
||||
pendingPath: join(result.workspaceDir, "workspace-pending-journal.json"),
|
||||
raw: options.raw,
|
||||
}));
|
||||
}
|
||||
return { stdout: lines.join("\n") };
|
||||
}
|
||||
|
||||
const root = options.workspace ?? process.cwd();
|
||||
const key = await workspaceKey(root);
|
||||
return {
|
||||
stdout: await workspaceHealthOutput({
|
||||
root,
|
||||
key,
|
||||
memoryPath: await workspaceMemoryPath(root),
|
||||
pendingPath: await workspacePendingJournalPath(root),
|
||||
raw: options.raw,
|
||||
includeTitle: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { buildInspectionReadModel } from "../inspection-model.ts";
|
||||
import { buildQualityJSON, formatQuality } from "../formatters/quality.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
|
||||
export async function runQuality(options: CliOptions): Promise<CommandResult> {
|
||||
const model = await buildInspectionReadModel(options);
|
||||
const now = Date.now();
|
||||
|
||||
if (options.json) {
|
||||
return { stdout: JSON.stringify(buildQualityJSON(model, new Date(now).toISOString(), now), null, 2) };
|
||||
}
|
||||
|
||||
return { stdout: formatQuality(model, now) };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { formatRejections, formatRejectionQuality, buildRejectionQualityJSON } from "../formatters/rejections.ts";
|
||||
import { loadRejectionRecords, rejectionFalsePositiveRisk, rejectionQualitySummary, uniqueByCanonicalText } from "../rejections-model.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
|
||||
export async function runRejections(options: CliOptions): Promise<CommandResult> {
|
||||
const { path, invalidLines, records } = await loadRejectionRecords(options);
|
||||
const normalized = options.unique ? uniqueByCanonicalText(records) : records;
|
||||
|
||||
if (options.quality) {
|
||||
const summary = rejectionQualitySummary(records);
|
||||
if (options.json) {
|
||||
return { stdout: JSON.stringify({ ...buildRejectionQualityJSON(summary), falsePositiveRisk: rejectionFalsePositiveRisk(summary) }, null, 2) };
|
||||
}
|
||||
|
||||
return { stdout: formatRejectionQuality({ path, invalidLines, summary, raw: options.raw }) };
|
||||
}
|
||||
|
||||
return { stdout: formatRejections({ path, invalidLines, records: normalized, raw: options.raw }) };
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { appendEvidenceEvents, queryEvidenceEvents, type EvidenceEventInput, type EvidenceEventV1, type MemoryEvidenceRef } from "../../../src/evidence-log.ts";
|
||||
import { workspaceMemoryPath } from "../../../src/paths.ts";
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../../../src/types.ts";
|
||||
import { updateWorkspaceMemoryWithAccounting } from "../../../src/workspace-memory.ts";
|
||||
import { readJSONFile } from "../io.ts";
|
||||
import { cleanText, truncate } from "../text.ts";
|
||||
import { CliInputError, type CliOptions, type CommandResult } from "../types.ts";
|
||||
|
||||
type ReplacementLink = {
|
||||
event: EvidenceEventV1;
|
||||
originalId: string;
|
||||
replacementId: string;
|
||||
};
|
||||
|
||||
type RevertPlan = ReplacementLink & {
|
||||
original: LongTermMemoryEntry;
|
||||
replacement: LongTermMemoryEntry;
|
||||
};
|
||||
|
||||
function reject(message: string): never {
|
||||
throw new CliInputError(`revert rejected: ${message}`);
|
||||
}
|
||||
|
||||
function memoryRef(memory: LongTermMemoryEntry, status: LongTermMemoryEntry["status"] = memory.status): MemoryEvidenceRef {
|
||||
return {
|
||||
memoryId: memory.id,
|
||||
type: memory.type,
|
||||
source: memory.source,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function replacementIdFromEvent(event: EvidenceEventV1): string | undefined {
|
||||
return event.relations?.find(relation => relation.role === "superseded_by")?.memory?.memoryId;
|
||||
}
|
||||
|
||||
function originalIdFromEvent(event: EvidenceEventV1): string | undefined {
|
||||
return event.memory?.memoryId
|
||||
?? event.relations?.find(relation => relation.role === "superseded")?.memory?.memoryId;
|
||||
}
|
||||
|
||||
function replacementLinkFromEvent(event: EvidenceEventV1): ReplacementLink {
|
||||
if (event.type !== "memory_replaced_numbered_ref") {
|
||||
reject(`event ${event.eventId} is not a memory_replaced_numbered_ref event`);
|
||||
}
|
||||
if (event.outcome !== "superseded") {
|
||||
reject(`event ${event.eventId} is not a successful numbered replacement`);
|
||||
}
|
||||
if (!event.reasonCodes.includes("numbered_ref_replace")) {
|
||||
reject(`event ${event.eventId} is not a numbered replacement`);
|
||||
}
|
||||
|
||||
const originalId = originalIdFromEvent(event);
|
||||
const replacementId = replacementIdFromEvent(event);
|
||||
if (!originalId || !replacementId) {
|
||||
reject(`event ${event.eventId} does not identify original and replacement memories`);
|
||||
}
|
||||
|
||||
return { event, originalId, replacementId };
|
||||
}
|
||||
|
||||
function selectReplacementLink(events: EvidenceEventV1[], options: CliOptions): ReplacementLink {
|
||||
if (options.event) {
|
||||
const event = events.find(item => item.eventId === options.event);
|
||||
if (!event) reject(`event ${options.event} was not found`);
|
||||
return replacementLinkFromEvent(event);
|
||||
}
|
||||
|
||||
const memoryId = options.memory;
|
||||
if (!memoryId) reject("missing --memory or --event selector");
|
||||
const matches = events
|
||||
.filter(event => event.type === "memory_replaced_numbered_ref")
|
||||
.filter(event => replacementIdFromEvent(event) === memoryId);
|
||||
|
||||
if (matches.length === 0) {
|
||||
reject(`replacement memory ${memoryId} was not created by memory_replaced_numbered_ref`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
reject(`replacement memory ${memoryId} has ${matches.length} replacement events; use --event`);
|
||||
}
|
||||
|
||||
return replacementLinkFromEvent(matches[0]);
|
||||
}
|
||||
|
||||
function validatePlan(link: ReplacementLink, store: WorkspaceMemoryStore): RevertPlan {
|
||||
const byId = new Map(store.entries.map(entry => [entry.id, entry]));
|
||||
const original = byId.get(link.originalId);
|
||||
const replacement = byId.get(link.replacementId);
|
||||
|
||||
if (!original) reject(`original memory ${link.originalId} is missing`);
|
||||
if (!replacement) reject(`replacement memory ${link.replacementId} is missing`);
|
||||
if (original.status !== "superseded") reject(`original memory ${original.id} is not superseded`);
|
||||
if (replacement.status !== "active") reject(`replacement memory ${replacement.id} is not active`);
|
||||
|
||||
const laterSuperseder = store.entries.find(entry =>
|
||||
entry.status === "active"
|
||||
&& entry.id !== original.id
|
||||
&& entry.id !== replacement.id
|
||||
&& (entry.supersedes ?? []).includes(replacement.id)
|
||||
);
|
||||
if (laterSuperseder) {
|
||||
reject(`replacement memory ${replacement.id} is superseded by active memory ${laterSuperseder.id}`);
|
||||
}
|
||||
|
||||
return { ...link, original, replacement };
|
||||
}
|
||||
|
||||
async function dryRunPlan(root: string, link: ReplacementLink): Promise<RevertPlan> {
|
||||
const rawStore = await readJSONFile<WorkspaceMemoryStore>(await workspaceMemoryPath(root));
|
||||
const store: WorkspaceMemoryStore = rawStore ?? {
|
||||
version: 1,
|
||||
workspace: { root, key: "" },
|
||||
limits: { maxRenderedChars: 0, maxEntries: 0 },
|
||||
entries: [],
|
||||
migrations: [],
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
};
|
||||
return validatePlan(link, store);
|
||||
}
|
||||
|
||||
function revertEvidence(plan: RevertPlan): EvidenceEventInput {
|
||||
const replacement = { ...plan.replacement, status: "superseded" as const };
|
||||
const original = { ...plan.original, status: "active" as const };
|
||||
return {
|
||||
type: "memory_reverted_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome: "recovered",
|
||||
memory: memoryRef(replacement, "superseded"),
|
||||
relations: [
|
||||
{ role: "superseded", memory: memoryRef(replacement, "superseded") },
|
||||
{ role: "recovered", memory: memoryRef(original, "active") },
|
||||
],
|
||||
reasonCodes: ["manual_revert_numbered_ref"],
|
||||
details: {
|
||||
replacementEventId: plan.event.eventId,
|
||||
replacementMemoryId: plan.replacementId,
|
||||
restoredMemoryId: plan.originalId,
|
||||
},
|
||||
textPreview: original.text,
|
||||
};
|
||||
}
|
||||
|
||||
async function applyPlan(root: string, link: ReplacementLink): Promise<RevertPlan> {
|
||||
let applied: RevertPlan | undefined;
|
||||
const updateResult = await updateWorkspaceMemoryWithAccounting(root, store => {
|
||||
const plan = validatePlan(link, store);
|
||||
const nowIso = new Date().toISOString();
|
||||
applied = {
|
||||
...plan,
|
||||
original: { ...plan.original, status: "active", updatedAt: nowIso },
|
||||
replacement: { ...plan.replacement, status: "superseded", updatedAt: nowIso },
|
||||
};
|
||||
|
||||
return {
|
||||
...store,
|
||||
entries: store.entries.map(entry => {
|
||||
if (entry.id === plan.originalId) return applied!.original;
|
||||
if (entry.id === plan.replacementId) return applied!.replacement;
|
||||
return entry;
|
||||
}),
|
||||
updatedAt: nowIso,
|
||||
lastActivityAt: nowIso,
|
||||
};
|
||||
});
|
||||
|
||||
if (!applied) reject("unable to apply revert");
|
||||
await appendEvidenceEvents(root, [...updateResult.evidence, revertEvidence(applied)]);
|
||||
return applied;
|
||||
}
|
||||
|
||||
function formatPlan(plan: RevertPlan, applied: boolean): string {
|
||||
const heading = applied ? "Memory revert applied" : "Memory revert dry run";
|
||||
const nextStep = applied ? "Changes applied." : "No changes applied. Re-run with --apply to mutate workspace memory.";
|
||||
return [
|
||||
heading,
|
||||
"",
|
||||
"Planned changes:",
|
||||
` - replacement: ${plan.replacementId} active -> superseded`,
|
||||
` - original: ${plan.originalId} superseded -> active`,
|
||||
` - replacement event: ${plan.event.eventId}`,
|
||||
` - restored text: ${truncate(cleanText(plan.original.text, false), 100)}`,
|
||||
"",
|
||||
nextStep,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function runRevert(options: CliOptions): Promise<CommandResult> {
|
||||
const root = options.workspace ?? process.cwd();
|
||||
const events = await queryEvidenceEvents(root);
|
||||
const link = selectReplacementLink(events, options);
|
||||
const plan = options.apply ? await applyPlan(root, link) : await dryRunPlan(root, link);
|
||||
return { stdout: formatPlan(plan, options.apply === true) };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { retentionCandidatesForDiag, retentionClockSummary, daysSinceIso } from
|
||||
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
|
||||
import { isWithinDays, memoryDiagJSONFromSnapshot } from "../workspace-snapshot.ts";
|
||||
import type { CliOptions, CommandResult, MemoryInspectionReadModel } from "../types.ts";
|
||||
import { runHealth } from "./health.ts";
|
||||
|
||||
export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.now()): StatusReadout {
|
||||
const active = model.store.entries.filter(entry => entry.status !== "superseded");
|
||||
@@ -80,6 +81,8 @@ export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.
|
||||
}
|
||||
|
||||
export async function runStatus(options: CliOptions): Promise<CommandResult> {
|
||||
if (options.legacyCommand === "health" && options.all) return runHealth(options);
|
||||
|
||||
const now = Date.now();
|
||||
const model = await buildInspectionReadModel(options);
|
||||
const readout = buildStatusReadout(model, now);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { traceMemoryLifecycle } from "../../../src/evidence-log.ts";
|
||||
import { formatTrace } from "../formatters/trace.ts";
|
||||
import { snapshotForOptions } from "../workspace-snapshot.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
|
||||
export async function runTrace(options: CliOptions): Promise<CommandResult> {
|
||||
const root = options.workspace ?? process.cwd();
|
||||
const memoryId = options.memory;
|
||||
if (!memoryId) return { stdout: "", stderr: "--memory requires an id", exitCode: 1 };
|
||||
|
||||
const [snapshot, trace] = await Promise.all([
|
||||
snapshotForOptions(options),
|
||||
traceMemoryLifecycle(root, { memoryId }),
|
||||
]);
|
||||
return { stdout: formatTrace(memoryId, snapshot, trace) };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { disappearanceRows } from "../inspection-model.ts";
|
||||
import { eventCounts } from "../inspection-model.ts";
|
||||
import { formatDetails } from "../text.ts";
|
||||
|
||||
export type DisappearanceRows = ReturnType<typeof disappearanceRows>;
|
||||
|
||||
export function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
||||
disappearances: rows.map(row => ({
|
||||
id: row.id,
|
||||
classification: row.classification,
|
||||
terminalType: row.terminalType,
|
||||
reasonCodes: row.reasonCodes,
|
||||
eventCounts: eventCounts(row.events),
|
||||
details: options.explain ? row.event?.details : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("Memory disappearances");
|
||||
lines.push("");
|
||||
if (rows.length === 0) {
|
||||
lines.push("No evidence-only memories found.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
for (const row of rows) {
|
||||
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
|
||||
lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`);
|
||||
if (options.explain) {
|
||||
lines.push(` events: ${row.events.map(event => event.type).join(", ")}`);
|
||||
if (row.event?.type === "memory_removed_capacity") {
|
||||
lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`);
|
||||
}
|
||||
const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"));
|
||||
if (renderTypeCap) {
|
||||
lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { assessMemoryQuality } from "../../../src/memory-quality.ts";
|
||||
import {
|
||||
DORMANT_DECAY_MULTIPLIER,
|
||||
RETENTION_TYPE_MAX,
|
||||
WORKSPACE_DORMANT_AFTER_DAYS,
|
||||
} from "../../../src/retention.ts";
|
||||
import { renderWorkspaceMemory } from "../../../src/workspace-memory.ts";
|
||||
import type { LongTermSource, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts";
|
||||
import { LONG_TERM_LIMITS } from "../../../src/types.ts";
|
||||
import { SUSPICIOUS_REASONS, TYPES } from "../constants.ts";
|
||||
import {
|
||||
ageDays,
|
||||
daysSinceIso,
|
||||
formatStrength,
|
||||
isSafetyCriticalForDiag,
|
||||
promotionLimit,
|
||||
retentionCandidatesForDiag,
|
||||
} from "../retention-model.ts";
|
||||
import {
|
||||
canonicalMemoryText,
|
||||
cleanPath,
|
||||
cleanText,
|
||||
countBy,
|
||||
formatPercent,
|
||||
formatWorkspaceIdentity,
|
||||
truncate,
|
||||
} from "../text.ts";
|
||||
import { normalizedJournal, normalizedStore } from "../workspace-snapshot.ts";
|
||||
|
||||
export type WorkspaceHealthInput = {
|
||||
root?: string;
|
||||
key: string;
|
||||
memoryPath: string;
|
||||
pendingPath: string;
|
||||
raw: boolean;
|
||||
now: number;
|
||||
includeTitle?: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceHealthLoadedData = {
|
||||
rawStore: WorkspaceMemoryStore | null;
|
||||
rawJournal: PendingMemoryJournalStore | null;
|
||||
pendingExists: boolean;
|
||||
};
|
||||
|
||||
export function formatWorkspaceHealth(input: WorkspaceHealthInput, loadedData: WorkspaceHealthLoadedData): string {
|
||||
const lines: string[] = [];
|
||||
if (input.includeTitle) {
|
||||
lines.push("Workspace memory health");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const rawStore = loadedData.rawStore;
|
||||
const storeRoot = rawStore?.workspace?.root ?? input.root ?? "";
|
||||
const storeKey = rawStore?.workspace?.key ?? input.key;
|
||||
const store = normalizedStore(rawStore, storeRoot, storeKey);
|
||||
const journal = normalizedJournal(loadedData.rawJournal);
|
||||
|
||||
const identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw);
|
||||
if (identity) lines.push(identity);
|
||||
lines.push(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`);
|
||||
lines.push(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`);
|
||||
if (!rawStore) lines.push("memory store: missing or unreadable (treated as empty)");
|
||||
if (!loadedData.pendingExists) lines.push("pending journal: missing (treated as empty)");
|
||||
lines.push("");
|
||||
|
||||
const active = store.entries.filter(entry => entry.status !== "superseded");
|
||||
const superseded = store.entries.filter(entry => entry.status === "superseded");
|
||||
const retention = retentionCandidatesForDiag(store, input.now);
|
||||
const renderedEntries = retention.rendered.map(item => item.entry);
|
||||
const renderedEstimate = renderWorkspaceMemory(store).length;
|
||||
|
||||
lines.push(`Stored active memories: ${active.length}`);
|
||||
lines.push(`Superseded memories: ${superseded.length}`);
|
||||
lines.push(`Rendered candidates: ${renderedEntries.length}`);
|
||||
lines.push(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
|
||||
lines.push("");
|
||||
|
||||
const pendingEntries = journal.entries;
|
||||
const retryable = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) < promotionLimit(entry.source)).length;
|
||||
const nearRetryLimit = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) >= promotionLimit(entry.source) - 1).length;
|
||||
const pendingBySource = countBy(pendingEntries.map(entry => entry.source));
|
||||
lines.push("Pending journal:");
|
||||
lines.push(` total: ${pendingEntries.length}`);
|
||||
lines.push(` retryable: ${retryable}`);
|
||||
lines.push(` near retry limit: ${nearRetryLimit}`);
|
||||
lines.push(" by source:");
|
||||
for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) {
|
||||
lines.push(` ${source}: ${pendingBySource.get(source) ?? 0}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("By type:");
|
||||
for (const type of TYPES) {
|
||||
const storedCount = active.filter(entry => entry.type === type).length;
|
||||
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
|
||||
const supersededCount = superseded.filter(entry => entry.type === type).length;
|
||||
lines.push(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${RETENTION_TYPE_MAX[type]} superseded=${supersededCount}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("Retention caps:");
|
||||
lines.push(` type-capped entries: ${retention.typeCapped.length}`);
|
||||
lines.push(` global-cap overflow: ${retention.globalCapped.length}`);
|
||||
lines.push("");
|
||||
|
||||
const olderThan30 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 30).length;
|
||||
const olderThan90 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 90).length;
|
||||
const staleMarked = active.filter(entry => {
|
||||
const days = ageDays(entry, input.now);
|
||||
return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays);
|
||||
}).length;
|
||||
lines.push("Age:");
|
||||
lines.push(` stale-marked: ${staleMarked}`);
|
||||
lines.push(` older than 30d: ${olderThan30}`);
|
||||
lines.push(` older than 90d: ${olderThan90}`);
|
||||
lines.push("");
|
||||
|
||||
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt, input.now);
|
||||
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS;
|
||||
const dormantDaysPastGrace = wallDaysSinceActivity === null
|
||||
? 0
|
||||
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
|
||||
lines.push("Dormancy:");
|
||||
lines.push(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
|
||||
lines.push(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
|
||||
lines.push(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
|
||||
lines.push(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
|
||||
lines.push(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`);
|
||||
lines.push("");
|
||||
|
||||
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
|
||||
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
|
||||
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
|
||||
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
|
||||
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
|
||||
const highImportanceAlert = highImportanceRatio > 0.3;
|
||||
const safetyCriticalWarning = safetyCriticalCount > 0;
|
||||
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
|
||||
lines.push("Retention monitoring:");
|
||||
lines.push(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
|
||||
lines.push(` safety_critical_count: ${safetyCriticalCount} (deprecated field)${safetyCriticalWarning ? " WARNING" : ""}`);
|
||||
lines.push(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
|
||||
lines.push("");
|
||||
|
||||
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
|
||||
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
|
||||
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
|
||||
lines.push("Quality warnings:");
|
||||
lines.push(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`);
|
||||
lines.push(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`);
|
||||
lines.push(` duplicate-ish exact canonical text: ${duplicateExtras}`);
|
||||
lines.push(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Suspicious active memories:");
|
||||
for (const reason of SUSPICIOUS_REASONS) {
|
||||
lines.push(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`);
|
||||
}
|
||||
|
||||
const failingQuality = qualityByEntry.filter(item => !item.quality.accepted);
|
||||
if (failingQuality.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Active memories failing offline quality checks:");
|
||||
for (const item of failingQuality.slice(0, 8)) {
|
||||
lines.push(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Top rendered candidates:");
|
||||
const top = retention.rendered.slice(0, 5);
|
||||
if (top.length === 0) {
|
||||
lines.push(" (none)");
|
||||
} else {
|
||||
for (const item of top) {
|
||||
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Weakest active memories:");
|
||||
const weakest = retention.sorted.slice(-5).reverse();
|
||||
if (weakest.length === 0) {
|
||||
lines.push(" (none)");
|
||||
} else {
|
||||
for (const item of weakest) {
|
||||
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { disappearanceRows } from "../inspection-model.ts";
|
||||
import { eventCounts } from "../inspection-model.ts";
|
||||
import { formatDetails } from "../text.ts";
|
||||
|
||||
export type DisappearanceRows = ReturnType<typeof disappearanceRows>;
|
||||
import { buildDisappearancesJSON, formatDisappearances, type DisappearanceRows } from "./disappearances.ts";
|
||||
|
||||
export type MissingSummary = {
|
||||
total: number;
|
||||
@@ -25,46 +21,6 @@ export function buildMissingJSON(rows: DisappearanceRows, options: { explain?: b
|
||||
};
|
||||
}
|
||||
|
||||
function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
||||
disappearances: rows.map(row => ({
|
||||
id: row.id,
|
||||
classification: row.classification,
|
||||
terminalType: row.terminalType,
|
||||
reasonCodes: row.reasonCodes,
|
||||
eventCounts: eventCounts(row.events),
|
||||
details: options.explain ? row.event?.details : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("Memory disappearances");
|
||||
lines.push("");
|
||||
if (rows.length === 0) {
|
||||
lines.push("No evidence-only memories found.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
for (const row of rows) {
|
||||
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
|
||||
lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`);
|
||||
if (options.explain) {
|
||||
lines.push(` events: ${row.events.map(event => event.type).join(", ")}`);
|
||||
if (row.event?.type === "memory_removed_capacity") {
|
||||
lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`);
|
||||
}
|
||||
const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"));
|
||||
if (renderTypeCap) {
|
||||
lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatMissing(rows: DisappearanceRows, options: { verbose?: boolean; explain?: boolean } = {}): string {
|
||||
const summary = missingSummary(rows);
|
||||
const lines: string[] = [];
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { RETENTION_TYPE_MAX } from "../../../src/retention.ts";
|
||||
import { TYPES } from "../constants.ts";
|
||||
import { disappearanceRows } from "../inspection-model.ts";
|
||||
import { retentionCandidatesForDiag, retentionClockSummary } from "../retention-model.ts";
|
||||
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
|
||||
import type { MemoryInspectionReadModel } from "../types.ts";
|
||||
|
||||
export function buildQualityJSON(model: MemoryInspectionReadModel, generatedAt = new Date().toISOString(), now = new Date(generatedAt).getTime()): Record<string, unknown> {
|
||||
const active = model.store.entries.filter(entry => entry.status !== "superseded");
|
||||
const retention = retentionCandidatesForDiag(model.store, now);
|
||||
const clocks = retentionClockSummary(active);
|
||||
const disappearances = disappearanceRows(model);
|
||||
const evidenceCovered = active.filter(entry => (model.evidenceByMemoryId.get(entry.id) ?? []).length > 0).length;
|
||||
const rejectionSummary = rejectionQualitySummary(model.rejectionRecords);
|
||||
const falsePositiveRisk = rejectionFalsePositiveRisk(rejectionSummary);
|
||||
const typeCounts = Object.fromEntries(TYPES.map(type => [type, active.filter(entry => entry.type === type).length]));
|
||||
const capsFull = active.length >= model.store.limits.maxEntries || TYPES.some(type => (typeCounts[type] ?? 0) >= RETENTION_TYPE_MAX[type]);
|
||||
const unknownDisappearances = disappearances.filter(row => row.classification === "historical_absent_unknown_reason").length;
|
||||
const status = unknownDisappearances > 0 || clocks.invalid > 0
|
||||
? "degraded"
|
||||
: capsFull || rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high"
|
||||
? "warning"
|
||||
: "ok";
|
||||
const summaryText = `Summary: Workspace memory quality is ${status}: ${active.length} active memories, ${evidenceCovered}/${active.length} with evidence, ${disappearances.length} evidence-only disappearances (${unknownDisappearances} unknown), ${clocks.invalid} invalid retention clocks, and ${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records.`;
|
||||
const caps = {
|
||||
active: active.length,
|
||||
maxEntries: model.store.limits.maxEntries,
|
||||
rendered: retention.rendered.length,
|
||||
typeCapped: retention.typeCapped.length,
|
||||
globalCapped: retention.globalCapped.length,
|
||||
typeCounts,
|
||||
capsFull,
|
||||
};
|
||||
const evidence = {
|
||||
currentWithEvidence: evidenceCovered,
|
||||
currentWithoutEvidence: active.length - evidenceCovered,
|
||||
evidenceMemoryIds: model.evidenceByMemoryId.size,
|
||||
disappearances: disappearances.length,
|
||||
unknownDisappearances,
|
||||
withTerminalReason: disappearances.length - unknownDisappearances,
|
||||
};
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt,
|
||||
status,
|
||||
summaryText,
|
||||
store: { active: active.length, pending: model.pending.entries.length, superseded: model.store.entries.length - active.length },
|
||||
caps,
|
||||
retention: clocks,
|
||||
evidence,
|
||||
rejections: { ...rejectionSummary, falsePositiveRisk },
|
||||
};
|
||||
}
|
||||
|
||||
export function formatQuality(model: MemoryInspectionReadModel, now: number): string {
|
||||
const data = buildQualityJSON(model, new Date(now).toISOString(), now) as {
|
||||
summaryText: string;
|
||||
caps: {
|
||||
active: number;
|
||||
maxEntries: number;
|
||||
rendered: number;
|
||||
typeCapped: number;
|
||||
globalCapped: number;
|
||||
typeCounts: Record<string, number>;
|
||||
capsFull: boolean;
|
||||
};
|
||||
retention: { present: number; missing: number; invalid: number };
|
||||
evidence: {
|
||||
currentWithEvidence: number;
|
||||
currentWithoutEvidence: number;
|
||||
evidenceMemoryIds: number;
|
||||
disappearances: number;
|
||||
unknownDisappearances: number;
|
||||
};
|
||||
rejections: {
|
||||
totalRecords: number;
|
||||
workspaceScopedCount: number;
|
||||
legacyUnscopedCount: number;
|
||||
falsePositiveRisk: string;
|
||||
};
|
||||
};
|
||||
const lines: string[] = [];
|
||||
lines.push("Memory quality inspection");
|
||||
lines.push("");
|
||||
lines.push(data.summaryText);
|
||||
lines.push("");
|
||||
lines.push("Caps:");
|
||||
lines.push(` active: ${data.caps.active} / ${data.caps.maxEntries}`);
|
||||
for (const type of TYPES) {
|
||||
const count = data.caps.typeCounts[type] ?? 0;
|
||||
const limit = RETENTION_TYPE_MAX[type];
|
||||
const marker = count >= limit ? " FULL" : "";
|
||||
lines.push(` ${type}: ${count} / ${limit}${marker}`);
|
||||
}
|
||||
lines.push(` rendered: ${data.caps.rendered}`);
|
||||
lines.push(` type-capped entries: ${data.caps.typeCapped}`);
|
||||
lines.push(` global-cap overflow: ${data.caps.globalCapped}`);
|
||||
lines.push(` caps full: ${data.caps.capsFull ? "yes" : "no"}`);
|
||||
lines.push("");
|
||||
lines.push("Retention clocks:");
|
||||
lines.push(` present: ${data.retention.present}`);
|
||||
lines.push(` missing: ${data.retention.missing}`);
|
||||
lines.push(` invalid: ${data.retention.invalid}`);
|
||||
lines.push("");
|
||||
lines.push("Evidence:");
|
||||
lines.push(` current with evidence: ${data.evidence.currentWithEvidence}`);
|
||||
lines.push(` current without evidence: ${data.evidence.currentWithoutEvidence}`);
|
||||
lines.push(` evidence memory ids: ${data.evidence.evidenceMemoryIds}`);
|
||||
lines.push(` disappearances: ${data.evidence.disappearances}`);
|
||||
lines.push(` unknown disappearances: ${data.evidence.unknownDisappearances}`);
|
||||
lines.push("");
|
||||
lines.push("Rejection scoping:");
|
||||
lines.push(` total records: ${data.rejections.totalRecords}`);
|
||||
lines.push(` workspace scoped: ${data.rejections.workspaceScopedCount}`);
|
||||
lines.push(` legacy unscoped: ${data.rejections.legacyUnscopedCount}`);
|
||||
lines.push(` false-positive risk: ${data.rejections.falsePositiveRisk}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { hasSoftReason, rejectionQualitySummary } from "../rejections-model.ts";
|
||||
import { cleanPath, cleanText, countBy, formatWorkspaceIdentity, sortedCounts, truncate } from "../text.ts";
|
||||
import type { NormalizedRejection } from "../types.ts";
|
||||
|
||||
export type RejectionQualitySummary = ReturnType<typeof rejectionQualitySummary>;
|
||||
|
||||
export function buildRejectionQualityJSON(summary: RejectionQualitySummary, generatedAt = new Date().toISOString()): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt,
|
||||
...summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRejections(input: {
|
||||
path: string;
|
||||
invalidLines: number;
|
||||
records: NormalizedRejection[];
|
||||
raw: boolean;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("Extraction rejection summary");
|
||||
lines.push("");
|
||||
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
|
||||
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
|
||||
lines.push("");
|
||||
lines.push(`Total rejected: ${input.records.length}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("By reason:");
|
||||
const byReason = sortedCounts(countBy(input.records.flatMap(record => record.reasons)));
|
||||
if (byReason.length === 0) lines.push(" (none)");
|
||||
else for (const [reason, count] of byReason) lines.push(` ${reason.padEnd(24)} ${count}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("By origin:");
|
||||
const byOrigin = sortedCounts(countBy(input.records.map(record => record.origin)));
|
||||
if (byOrigin.length === 0) lines.push(" (none)");
|
||||
else for (const [origin, count] of byOrigin) lines.push(` ${origin.padEnd(24)} ${count}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Trigger-origin rejections (high priority for v1.5):");
|
||||
const triggerReasons = sortedCounts(countBy(input.records.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons)));
|
||||
if (triggerReasons.length === 0) lines.push(" (none)");
|
||||
else for (const [reason, count] of triggerReasons) lines.push(` ${reason.padEnd(24)} ${count}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Recent suspicious soft rejects:");
|
||||
const suspicious = input.records
|
||||
.filter(hasSoftReason)
|
||||
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
|
||||
.slice(0, 8);
|
||||
if (suspicious.length === 0) {
|
||||
lines.push(" (none)");
|
||||
} else {
|
||||
for (const record of suspicious) {
|
||||
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, input.raw);
|
||||
lines.push(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, input.raw)))}`);
|
||||
lines.push(` reasons: ${record.reasons.join(",")}`);
|
||||
lines.push(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatRejectionQuality(input: {
|
||||
path: string;
|
||||
invalidLines: number;
|
||||
summary: RejectionQualitySummary;
|
||||
raw: boolean;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("Extraction rejection quality inspection");
|
||||
lines.push("");
|
||||
lines.push("Possible false-positive grouping is heuristic, not deterministic truth.");
|
||||
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
|
||||
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
|
||||
lines.push("");
|
||||
lines.push(`Total records: ${input.summary.totalRecords}`);
|
||||
lines.push(`Unique texts: ${input.summary.uniqueTexts}`);
|
||||
lines.push(`Workspace scoped: ${input.summary.workspaceScopedCount}`);
|
||||
lines.push(`Legacy unscoped: ${input.summary.legacyUnscopedCount}`);
|
||||
lines.push("");
|
||||
lines.push("Reason distribution (raw records):");
|
||||
for (const [reason, count] of Object.entries(input.summary.reasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
|
||||
if (Object.keys(input.summary.reasonDistribution).length === 0) lines.push(" (none)");
|
||||
lines.push("");
|
||||
lines.push("Reason distribution (unique text):");
|
||||
for (const [reason, count] of Object.entries(input.summary.uniqueReasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
|
||||
if (Object.keys(input.summary.uniqueReasonDistribution).length === 0) lines.push(" (none)");
|
||||
lines.push("");
|
||||
lines.push("Possible false-positive groups (heuristic, not deterministic):");
|
||||
for (const [group, data] of Object.entries(input.summary.possibleFalsePositiveGroups)) {
|
||||
lines.push(` ${group}: ${data.count}`);
|
||||
for (const sample of data.samples) lines.push(` - ${JSON.stringify(cleanText(sample, input.raw))}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome } from "../../src/evidence-log.ts";
|
||||
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts";
|
||||
import type { Command } from "./command-metadata.ts";
|
||||
|
||||
export type { Command, HiddenCommand, VisibleCommand } from "./command-metadata.ts";
|
||||
|
||||
export type MemoryRenderStatus =
|
||||
| "rendered"
|
||||
@@ -50,6 +47,8 @@ export type MemoryDiagJSON = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type Command = "status" | "rejected" | "missing" | "explain" | "coverage" | "audit";
|
||||
export type LegacyCommand = "health" | "quality" | "rejections" | "disappearances" | "trace";
|
||||
export type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
|
||||
|
||||
export type CliOptions = {
|
||||
@@ -62,13 +61,14 @@ export type CliOptions = {
|
||||
softOnly?: boolean;
|
||||
triggerOnly?: boolean;
|
||||
includeHistorical?: boolean;
|
||||
quality?: boolean;
|
||||
reason?: string;
|
||||
unique?: boolean;
|
||||
explain?: boolean;
|
||||
since?: string;
|
||||
migration?: string;
|
||||
memory?: string;
|
||||
event?: string;
|
||||
apply?: boolean;
|
||||
legacyCommand?: LegacyCommand;
|
||||
positional?: string[];
|
||||
auditMode?: "coverage" | "migrations";
|
||||
};
|
||||
|
||||
@@ -20,8 +20,6 @@ export type EvidenceEventType =
|
||||
| "promotion_retry_scheduled"
|
||||
| "promotion_retry_exhausted"
|
||||
| "memory_reinforced"
|
||||
| "memory_replaced_numbered_ref"
|
||||
| "memory_reverted_numbered_ref"
|
||||
| "memory_migration_superseded"
|
||||
| "render_selected"
|
||||
| "render_omitted"
|
||||
@@ -73,12 +71,10 @@ export type EvidenceRelation = {
|
||||
| "promoted"
|
||||
| "retained"
|
||||
| "absorbed"
|
||||
| "target"
|
||||
| "superseded"
|
||||
| "superseded_by"
|
||||
| "reinforced"
|
||||
| "reinforced_by"
|
||||
| "recovered"
|
||||
| "rendered"
|
||||
| "omitted"
|
||||
| "removed";
|
||||
|
||||
+10
-103
@@ -52,14 +52,9 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
|
||||
export type WorkspaceMemoryParseResult = {
|
||||
entries: LongTermMemoryEntry[];
|
||||
commands: WorkspaceMemoryCommand[];
|
||||
evidence: EvidenceEventInput[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryCommand =
|
||||
| { kind: "REINFORCE"; ref: string }
|
||||
| { kind: "REPLACE"; ref: string; type: LongTermType; text: string };
|
||||
|
||||
function evidenceTextPreview(text: string, maxChars = 120): string {
|
||||
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
|
||||
}
|
||||
@@ -196,7 +191,7 @@ export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemo
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, commands: [], evidence };
|
||||
return { entries, evidence };
|
||||
}
|
||||
|
||||
function classifyExplicitMemory(text: string): LongTermType {
|
||||
@@ -400,76 +395,6 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
||||
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
|
||||
}
|
||||
|
||||
function commandAttemptReason(line: string): string {
|
||||
const normalized = line.replace(/^\s*-\s*/, "").trim();
|
||||
const reinforceMatch = normalized.match(/^REINFORCE\s+(.+)$/i);
|
||||
if (reinforceMatch) {
|
||||
return /^\[M[1-9]\d*\]$/i.test(reinforceMatch[1]?.trim() ?? "")
|
||||
? "invalid_memory_command"
|
||||
: "invalid_memory_ref";
|
||||
}
|
||||
|
||||
const replaceMatch = normalized.match(/^REPLACE\s+(.*)$/i);
|
||||
if (!replaceMatch) return "invalid_memory_command";
|
||||
|
||||
const rest = replaceMatch[1]?.trim() ?? "";
|
||||
const refMatch = rest.match(/^(\[[^\]]+\]|\S+)(?:\s+(.*))?$/);
|
||||
const ref = refMatch?.[1] ?? "";
|
||||
if (!/^\[M[1-9]\d*\]$/i.test(ref)) return "invalid_memory_ref";
|
||||
|
||||
const afterRef = refMatch?.[2]?.trim() ?? "";
|
||||
const typeMatch = afterRef.match(/^(\[[^\]]+\]|\S+)(?:\s+(.*))?$/);
|
||||
const typeToken = typeMatch?.[1] ?? "";
|
||||
if (!/^\[(feedback|project|decision|reference)\]$/i.test(typeToken)) {
|
||||
return "invalid_memory_type";
|
||||
}
|
||||
|
||||
const replacementText = typeMatch?.[2]?.trim() ?? "";
|
||||
return replacementText ? "invalid_memory_command" : "empty_replacement_text";
|
||||
}
|
||||
|
||||
function isCommandAttempt(line: string): boolean {
|
||||
const normalized = line.replace(/^\s*-\s*/, "").trim();
|
||||
return /^(REINFORCE|REPLACE)\b/i.test(normalized)
|
||||
|| /\b(REINFORCE|REPLACE)\b.*\[?\w+\]?/i.test(normalized);
|
||||
}
|
||||
|
||||
function parseWorkspaceMemoryCommand(line: string): WorkspaceMemoryCommand | null {
|
||||
const normalized = line.replace(/^\s*-\s*/, "").trim();
|
||||
const reinforce = normalized.match(/^REINFORCE\s+\[(M[1-9]\d*)\]\s*$/i);
|
||||
if (reinforce) {
|
||||
return { kind: "REINFORCE", ref: reinforce[1].toUpperCase() };
|
||||
}
|
||||
|
||||
const replace = normalized.match(/^REPLACE\s+\[(M[1-9]\d*)\]\s+\[(feedback|project|decision|reference)\]\s+(.+)$/i);
|
||||
if (replace) {
|
||||
const text = replace[3].trim();
|
||||
if (!text) return null;
|
||||
return {
|
||||
kind: "REPLACE",
|
||||
ref: replace[1].toUpperCase(),
|
||||
type: replace[2].toLowerCase() as LongTermType,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCandidateLine(line: string): { type: LongTermType; body: string } | null {
|
||||
const bracketed = line.trim().match(/^\s*-?\s*\[(feedback|project|decision|reference)\]\s+(.+)$/i);
|
||||
if (bracketed) {
|
||||
return { type: bracketed[1].toLowerCase() as LongTermType, body: bracketed[2] };
|
||||
}
|
||||
|
||||
const bracketless = line.trim().match(/^-\s*(feedback|project|decision|reference)\b\s+(.+)$/i);
|
||||
if (bracketless) {
|
||||
return { type: bracketless[1].toLowerCase() as LongTermType, body: bracketless[2] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate block from summary using multiple formats.
|
||||
* Supports: Plain text label, Markdown section, legacy XML.
|
||||
@@ -506,39 +431,21 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
|
||||
options: WorkspaceMemoryCandidateParseOptions = {},
|
||||
): WorkspaceMemoryParseResult {
|
||||
const block = extractCandidateBlock(summary);
|
||||
if (!block) return { entries: [], commands: [], evidence: [] };
|
||||
if (!block) return { entries: [], evidence: [] };
|
||||
|
||||
const nowMs = Date.now();
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
const commands: WorkspaceMemoryCommand[] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
for (const line of block.split("\n")) {
|
||||
if (!line.trim() || /^\s*\(?none\)?\s*$/i.test(line)) continue;
|
||||
|
||||
const command = parseWorkspaceMemoryCommand(line);
|
||||
if (command) {
|
||||
commands.push(command);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
||||
const item = parseCandidateLine(line);
|
||||
if (!item) {
|
||||
if (isCommandAttempt(line)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
reasonCodes: [commandAttemptReason(line)],
|
||||
textPreview: evidenceTextPreview(line, 80),
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const type = item.type;
|
||||
const normalizedBody = normalizeCandidateBody(item.body);
|
||||
const item = line.trim().match(
|
||||
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
|
||||
);
|
||||
if (!item) continue;
|
||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||
const normalizedBody = normalizeCandidateBody(item[3]);
|
||||
if (!normalizedBody) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
@@ -546,7 +453,7 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["negated_request"],
|
||||
memory: { type, source: "compaction" },
|
||||
textPreview: evidenceTextPreview(item.body, 80),
|
||||
textPreview: evidenceTextPreview(item[3], 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
@@ -608,5 +515,5 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
|
||||
}));
|
||||
}
|
||||
|
||||
return { entries, commands, evidence };
|
||||
return { entries, evidence };
|
||||
}
|
||||
|
||||
+1
-69
@@ -7,7 +7,6 @@ export type MemoryQualityInput = Pick<LongTermMemoryEntry, "type" | "text"> & {
|
||||
export type MemoryQualityResult = {
|
||||
accepted: boolean;
|
||||
reasons: string[];
|
||||
diagnostics?: string[];
|
||||
};
|
||||
|
||||
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||
@@ -19,9 +18,6 @@ export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||
"active_file_snapshot",
|
||||
"code_or_api_signature",
|
||||
"path_heavy",
|
||||
"unresolved_question",
|
||||
"transient_bug_state",
|
||||
"deployment_snapshot",
|
||||
]);
|
||||
|
||||
export function isHardQualityReason(reason: string): boolean {
|
||||
@@ -40,18 +36,10 @@ export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityRes
|
||||
if (isTemporaryStatusViolation(text)) reasons.push("temporary_status");
|
||||
if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot");
|
||||
if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature");
|
||||
if (isUnresolvedQuestionViolation(text)) reasons.push("unresolved_question");
|
||||
if (isTransientBugStateViolation(text)) reasons.push("transient_bug_state");
|
||||
if (isDeploymentSnapshotViolation(text)) reasons.push("deployment_snapshot");
|
||||
if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback");
|
||||
if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision");
|
||||
|
||||
const diagnostics = isTerseLabelDiagnostic(text) ? ["terse_label"] : [];
|
||||
return {
|
||||
accepted: reasons.length === 0,
|
||||
reasons,
|
||||
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
||||
};
|
||||
return { accepted: reasons.length === 0, reasons };
|
||||
}
|
||||
|
||||
export function isProgressSnapshotViolation(text: string): boolean {
|
||||
@@ -95,34 +83,6 @@ export function hasFutureRule(text: string): boolean {
|
||||
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
|
||||
}
|
||||
|
||||
function textWithoutUrls(text: string): string {
|
||||
return text.replace(/https?:\/\/[^\s`"'<>]+/gi, "");
|
||||
}
|
||||
|
||||
function hasDurableRuleMarker(text: string): boolean {
|
||||
return /\b(?:must|always|never|use|do\s+not|don't)\b/i.test(text)
|
||||
|| /\bshould\b(?!\s+we\b)/i.test(text)
|
||||
|| /(?:必須|必须|應該|应该|不要|使用|保持)/u.test(text);
|
||||
}
|
||||
|
||||
function isUnresolvedQuestionViolation(text: string): boolean {
|
||||
if (hasDurableRuleMarker(text)) return false;
|
||||
|
||||
const withoutUrls = textWithoutUrls(text).trim();
|
||||
const startsUnresolved = /^(?:question:|open question\b|unresolved\b|pending question\b|todo:\s*decide\b|TBD\b|TODO\b|待確認|未決|待決定)/iu.test(withoutUrls);
|
||||
if (startsUnresolved) return true;
|
||||
|
||||
if (/\b(?:need to decide|needs decision|not decided|whether to|should we|do we need)\b/i.test(withoutUrls)) return true;
|
||||
if (/(?:尚未決定|需要決定|是否要|要不要)/u.test(withoutUrls)) return true;
|
||||
|
||||
if (/[??]\s*$/.test(withoutUrls)) return true;
|
||||
|
||||
const hasQuestion = /[??]/.test(withoutUrls);
|
||||
const hasPlanningPhrase = /\b(?:we need|need to|next|later|follow up)\b/i.test(withoutUrls)
|
||||
|| /(?:確認|决定|決定)/u.test(withoutUrls);
|
||||
return hasQuestion && hasPlanningPhrase;
|
||||
}
|
||||
|
||||
export function isArchitectureLikeDecision(text: string): boolean {
|
||||
if (/\b(?:[A-Z][A-Z0-9]*_[A-Z0-9_]*|[A-Z][A-Z0-9]{3,})\b/.test(text)) return true;
|
||||
if (/\b(?:schema|model|scoring|retention|cap|evidence|normalization|root cause|architecture(?!\s+keywords?)|boundary|rule|memory system)\b/i.test(text)) return true;
|
||||
@@ -177,34 +137,6 @@ function isTemporaryStatusViolation(text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTransientBugStateViolation(text: string): boolean {
|
||||
return /\b(?:currently debugging|still failing|unresolved bug|temporary workaround|next step is to fix|tests are failing)\b/i.test(text)
|
||||
|| /(?:待修|暫時|暂时|目前正在)/u.test(text);
|
||||
}
|
||||
|
||||
function isDeploymentSnapshotViolation(text: string): boolean {
|
||||
const hasDeploymentContext = /\b(?:deployed|current|latest|active|revision|build|release)\b/i.test(text)
|
||||
|| /(?:部署|版本|修訂|修订)/u.test(text);
|
||||
if (!hasDeploymentContext) return false;
|
||||
|
||||
const highEntropyId = /\b(?:rev|build|release|revision)[-_]?[A-Za-z0-9]{10,}\b/i.test(text)
|
||||
|| /\b[A-Za-z0-9]*[A-Z][A-Za-z0-9]*\d[A-Za-z0-9]*[A-Za-z0-9_-]{8,}\b/.test(text)
|
||||
|| /\b[A-Za-z0-9]*\d[A-Za-z0-9]*[A-Z][A-Za-z0-9]*[A-Za-z0-9_-]{8,}\b/.test(text);
|
||||
return highEntropyId;
|
||||
}
|
||||
|
||||
function isTerseLabelDiagnostic(text: string): boolean {
|
||||
if (/[::]/u.test(text)) return false;
|
||||
|
||||
const codePoints = [...text].length;
|
||||
const tokens = text.split(/\s+/u).filter(Boolean);
|
||||
if (codePoints >= 18 && tokens.length >= 4) return false;
|
||||
|
||||
const hasMarker = /\b(?:is|are|was|were|has|have|uses?|keeps?|requires?|prefers?|wants?|supports?|must|should|always|never|do\s+not|don't|because|for|with|when|after|before)\b/i.test(text)
|
||||
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|偏好|要求|支援|支持|因為|因为)/u.test(text);
|
||||
return !hasMarker;
|
||||
}
|
||||
|
||||
function isActiveFileSnapshotViolation(text: string): boolean {
|
||||
return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text);
|
||||
}
|
||||
|
||||
+47
-315
@@ -19,11 +19,11 @@
|
||||
* - Processes explicit memory from latest user text once per message id
|
||||
* - Injects frozen workspace memory and dynamic hot session state into system prompt
|
||||
* - Updates session state after tool execution
|
||||
* - Augments compaction context with numbered memory refs, todos, and instruction
|
||||
* - Augments compaction context with memory, hot state, todos, and instruction
|
||||
* - Parses compaction summaries for memory candidates and merges them
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createHash } from "node:crypto";
|
||||
import { realpath, rm } from "fs/promises";
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import {
|
||||
@@ -31,17 +31,12 @@ import {
|
||||
extractActiveFiles,
|
||||
extractErrorsFromBash,
|
||||
parseWorkspaceMemoryCandidatesWithEvidence,
|
||||
staleAfterDaysFor,
|
||||
type WorkspaceMemoryCommand,
|
||||
} from "./extractors.ts";
|
||||
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import {
|
||||
loadWorkspaceMemory,
|
||||
updateWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
accountWorkspaceMemoryRender,
|
||||
accountWorkspaceMemoryCompactionRefs,
|
||||
workspaceMemoryExactKey,
|
||||
workspaceMemoryIdentityKey,
|
||||
} from "./workspace-memory.ts";
|
||||
import { reinforceMemory } from "./retention.ts";
|
||||
@@ -71,7 +66,7 @@ import {
|
||||
} from "./opencode.ts";
|
||||
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts";
|
||||
import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||
import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Build the complete compaction prompt.
|
||||
@@ -81,14 +76,11 @@ import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, W
|
||||
* Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter,
|
||||
* horizontal rules, and delimiter lines.
|
||||
*
|
||||
* @param privateContext - Background context (numbered workspace memory refs,
|
||||
* @param privateContext - Background context (workspace memory, hot session state,
|
||||
* pending todos) from our plugin and any other plugins. Shown to the model to
|
||||
* inform the summary but not copied verbatim.
|
||||
*/
|
||||
function buildCompactionPrompt(privateContext: string, compactionId?: string): string {
|
||||
const snapshotInstruction = compactionId
|
||||
? `- If you emit any REINFORCE or REPLACE command, include \`Memory ref snapshot id: ${compactionId}\` as the first line under \"Memory candidates:\" so numbered refs match the correct compaction snapshot.`
|
||||
: "";
|
||||
function buildCompactionPrompt(privateContext: string): string {
|
||||
return [
|
||||
"Provide a detailed summary for continuing our conversation above.",
|
||||
"Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.",
|
||||
@@ -103,7 +95,6 @@ function buildCompactionPrompt(privateContext: string, compactionId?: string): s
|
||||
"- Do not output horizontal rules.",
|
||||
"- Do not wrap the summary in delimiter lines such as ---.",
|
||||
"- Do not use code fences around the summary.",
|
||||
...(snapshotInstruction ? [snapshotInstruction] : []),
|
||||
"",
|
||||
"Use this structure:",
|
||||
"",
|
||||
@@ -125,19 +116,19 @@ function buildCompactionPrompt(privateContext: string, compactionId?: string): s
|
||||
"",
|
||||
"CRITICAL MEMORY RULES:",
|
||||
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
|
||||
"- Existing memories are numbered [M#]. If an existing memory is still accurate, emit at most 3 lines like `REINFORCE [M#]`; do not rephrase it.",
|
||||
"- Use `REPLACE [M#] [type] text` only for eligible unreinforced compaction-sourced memories where the old text itself needs correction; this is rarely the right choice.",
|
||||
"- To supplement or correct a memory, REINFORCE the existing [M#] if it is still accurate, and also emit a new complete [type] candidate with the addition or correction. Do not use REPLACE for additions; do not reinforce a memory that is now inaccurate.",
|
||||
"- Existing workspace memory may already contain durable facts. If a fact is already present and still accurate, do not create a rephrased duplicate.",
|
||||
"- If the same durable fact truly needs to be emitted again, reuse the existing memory wording exactly whenever possible.",
|
||||
"- Only emit a new memory when the fact is new, materially corrected, or materially more specific than the existing memory.",
|
||||
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
|
||||
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
|
||||
"- decision = future rule/architecture choice; reference = stable lookup fact; project = stable project fact; feedback = stable user preference.",
|
||||
"- Do not use decision for service names, IDs, URLs, file paths, or one-off session status; use reference/project or skip.",
|
||||
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
|
||||
"- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.",
|
||||
"- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.",
|
||||
"- If unsure, skip it.",
|
||||
"",
|
||||
"Good memory examples:",
|
||||
"- REINFORCE [M1]",
|
||||
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
|
||||
"- [decision] Keep memory dedupe exact-only for decisions.",
|
||||
"- [decision] Do not add semantic merge to memory dedupe.",
|
||||
"- [project] This repository is an OpenCode plugin using local JSON stores.",
|
||||
"- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.",
|
||||
"",
|
||||
@@ -148,13 +139,11 @@ function buildCompactionPrompt(privateContext: string, compactionId?: string): s
|
||||
"- The assistant reviewed code reviewer feedback and updated the plan.",
|
||||
"- Commit a762e86 contains the owner scope fix.",
|
||||
"",
|
||||
"Format when there ARE REINFORCE/REPLACE commands or durable new candidates:",
|
||||
"Format when there ARE durable memories:",
|
||||
"Memory candidates:",
|
||||
"REINFORCE [M#]",
|
||||
"REPLACE [M#] [feedback|decision|project|reference] corrected durable fact",
|
||||
"- [feedback|decision|project|reference] new future-facing durable fact",
|
||||
"- [feedback|decision|project|reference] future-facing durable fact",
|
||||
"",
|
||||
"Format when there are NO REINFORCE/REPLACE commands or durable candidates:",
|
||||
"Format when there are NO durable memories:",
|
||||
"Memory candidates:",
|
||||
"(none)",
|
||||
"",
|
||||
@@ -184,20 +173,6 @@ function safeErrorMessage(error: unknown): string {
|
||||
return message.replace(/\s+/g, " ").slice(0, 240);
|
||||
}
|
||||
|
||||
type CompactionRefResolution =
|
||||
| {
|
||||
ok: true;
|
||||
refSnapshot: CompactionMemoryRef;
|
||||
target: LongTermMemoryEntry;
|
||||
targetIndex: number;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "missing_memory_ref_snapshot" | "invalid_memory_ref" | "memory_ref_target_unavailable" | "memory_ref_target_changed";
|
||||
refSnapshot?: CompactionMemoryRef;
|
||||
target?: LongTermMemoryEntry;
|
||||
};
|
||||
|
||||
async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise<void> {
|
||||
const message = safeErrorMessage(error);
|
||||
console.error(`[memory] ${scope} failed: ${message}`);
|
||||
@@ -225,10 +200,6 @@ async function workspaceIdentity(root: string): Promise<{ workspaceKey: string;
|
||||
return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue };
|
||||
}
|
||||
|
||||
function compactionIdFromSummary(summary: string): string | undefined {
|
||||
return summary.match(/Memory ref snapshot id:\s*([a-zA-Z0-9_-]+)/i)?.[1];
|
||||
}
|
||||
|
||||
export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
const { directory, client } = input;
|
||||
|
||||
@@ -288,227 +259,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
};
|
||||
}
|
||||
|
||||
function memoryReinforcedEvidence(
|
||||
memory: LongTermMemoryEntry | undefined,
|
||||
ref: string,
|
||||
outcome: "reinforced" | "rejected",
|
||||
reasonCodes: string[],
|
||||
details: EvidenceEventInput["details"] = {},
|
||||
): EvidenceEventInput {
|
||||
const relationRole = outcome === "rejected" ? "target" : "reinforced";
|
||||
return {
|
||||
type: "memory_reinforced",
|
||||
phase: "reinforcement",
|
||||
outcome,
|
||||
memory: memory ? memoryEvidenceRef(memory) : undefined,
|
||||
relations: memory ? [{ role: relationRole, memory: memoryEvidenceRef(memory) }] : undefined,
|
||||
reasonCodes,
|
||||
details: {
|
||||
ref,
|
||||
...details,
|
||||
},
|
||||
textPreview: memory?.text,
|
||||
};
|
||||
}
|
||||
|
||||
function replacementMemoryId(): string {
|
||||
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function memoryReplacedEvidence(
|
||||
oldMemory: LongTermMemoryEntry | undefined,
|
||||
newMemory: LongTermMemoryEntry | undefined,
|
||||
ref: string,
|
||||
outcome: "superseded" | "rejected",
|
||||
reasonCodes: string[],
|
||||
details: EvidenceEventInput["details"] = {},
|
||||
): EvidenceEventInput {
|
||||
const relations = outcome === "rejected"
|
||||
? [
|
||||
...(oldMemory ? [{ role: "target" as const, memory: memoryEvidenceRef(oldMemory) }] : []),
|
||||
]
|
||||
: [
|
||||
...(oldMemory ? [{ role: "superseded" as const, memory: memoryEvidenceRef(oldMemory) }] : []),
|
||||
...(newMemory ? [{ role: "superseded_by" as const, memory: memoryEvidenceRef(newMemory) }] : []),
|
||||
];
|
||||
return {
|
||||
type: "memory_replaced_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome,
|
||||
memory: oldMemory ? memoryEvidenceRef(oldMemory) : undefined,
|
||||
relations: relations.length > 0 ? relations : undefined,
|
||||
reasonCodes,
|
||||
details: {
|
||||
ref,
|
||||
...details,
|
||||
},
|
||||
textPreview: newMemory?.text ?? oldMemory?.text,
|
||||
};
|
||||
}
|
||||
|
||||
function compactionRefByLabel(refs: CompactionMemoryRef[]): Map<string, CompactionMemoryRef> {
|
||||
return new Map(refs.map(ref => [ref.ref.toUpperCase(), ref]));
|
||||
}
|
||||
|
||||
function compactionSnapshotStatus(
|
||||
refs: CompactionMemoryRef[],
|
||||
expectedCompactionId: string | undefined,
|
||||
): { ok: true } | { ok: false; storedCompactionId: string } {
|
||||
if (refs.length === 0) return { ok: false, storedCompactionId: "none" };
|
||||
|
||||
const ids = new Set(refs.map(ref => ref.compactionId).filter((id): id is string => typeof id === "string" && id.length > 0));
|
||||
if (!expectedCompactionId) return { ok: true };
|
||||
if (ids.size === 1 && ids.has(expectedCompactionId)) return { ok: true };
|
||||
if (ids.size === 0) return { ok: false, storedCompactionId: "none" };
|
||||
if (ids.size === 1) return { ok: false, storedCompactionId: [...ids][0] };
|
||||
return { ok: false, storedCompactionId: "mixed" };
|
||||
}
|
||||
|
||||
function resolveCompactionMemoryRef(
|
||||
refs: CompactionMemoryRef[],
|
||||
refsByLabel: Map<string, CompactionMemoryRef>,
|
||||
entries: LongTermMemoryEntry[],
|
||||
ref: string,
|
||||
): CompactionRefResolution {
|
||||
if (refs.length === 0) {
|
||||
return { ok: false, reason: "missing_memory_ref_snapshot" };
|
||||
}
|
||||
|
||||
const refSnapshot = refsByLabel.get(ref.toUpperCase());
|
||||
if (!refSnapshot) {
|
||||
return { ok: false, reason: "invalid_memory_ref" };
|
||||
}
|
||||
|
||||
const targetIndex = entries.findIndex(entry => entry.id === refSnapshot.memoryId);
|
||||
const target = targetIndex >= 0 ? entries[targetIndex] : undefined;
|
||||
if (!target || target.status !== "active") {
|
||||
return { ok: false, reason: "memory_ref_target_unavailable", refSnapshot, target };
|
||||
}
|
||||
|
||||
if (workspaceMemoryExactKey(target) !== refSnapshot.exactKey) {
|
||||
return { ok: false, reason: "memory_ref_target_changed", refSnapshot, target };
|
||||
}
|
||||
|
||||
return { ok: true, refSnapshot, target, targetIndex };
|
||||
}
|
||||
|
||||
async function applyCompactionMemoryCommands(
|
||||
sessionID: string,
|
||||
commands: WorkspaceMemoryCommand[],
|
||||
compactionId: string | undefined,
|
||||
): Promise<void> {
|
||||
if (commands.length === 0) return;
|
||||
|
||||
const sessionState = await loadSessionState(directory, sessionID);
|
||||
const snapshotStatus = compactionSnapshotStatus(sessionState.compactionMemoryRefs, compactionId);
|
||||
const refs = snapshotStatus.ok ? sessionState.compactionMemoryRefs : [];
|
||||
const refsByLabel = compactionRefByLabel(refs);
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
const now = Date.now();
|
||||
let snapshotMismatchDetails: EvidenceEventInput["details"] = {};
|
||||
if ("storedCompactionId" in snapshotStatus) {
|
||||
snapshotMismatchDetails = {
|
||||
...(compactionId ? { compactionId } : {}),
|
||||
storedCompactionId: snapshotStatus.storedCompactionId,
|
||||
};
|
||||
}
|
||||
|
||||
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
|
||||
for (const command of commands) {
|
||||
const resolution = resolveCompactionMemoryRef(refs, refsByLabel, workspaceMemory.entries, command.ref);
|
||||
if (resolution.ok === false) {
|
||||
const memoryId = resolution.refSnapshot?.memoryId;
|
||||
const details = memoryId ? { ...snapshotMismatchDetails, memoryId } : snapshotMismatchDetails;
|
||||
if (command.kind === "REINFORCE") {
|
||||
evidence.push(memoryReinforcedEvidence(resolution.target, command.ref, "rejected", [resolution.reason], details));
|
||||
} else {
|
||||
evidence.push(memoryReplacedEvidence(resolution.target, undefined, command.ref, "rejected", [resolution.reason], details));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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"], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
workspaceMemory.entries[targetIndex] = reinforced;
|
||||
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.source !== "compaction") {
|
||||
evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_memory_source"], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
source: target.source,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((target.reinforcementCount ?? 0) > 0) {
|
||||
evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_reinforced_target"], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
reinforcementCount: target.reinforcementCount ?? 0,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const quality = assessMemoryQuality({ type: command.type, text: command.text, source: "compaction" });
|
||||
if (!quality.accepted) {
|
||||
evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", quality.reasons, {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const supersededTarget: LongTermMemoryEntry = {
|
||||
...target,
|
||||
status: "superseded",
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
};
|
||||
const replacement: LongTermMemoryEntry = {
|
||||
id: replacementMemoryId(),
|
||||
type: command.type,
|
||||
text: command.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: new Date(now).toISOString(),
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
retentionClock: now,
|
||||
staleAfterDays: staleAfterDaysFor(command.type),
|
||||
supersedes: [target.id],
|
||||
};
|
||||
|
||||
workspaceMemory.entries[targetIndex] = supersededTarget;
|
||||
workspaceMemory.entries.push(replacement);
|
||||
evidence.push(memoryReplacedEvidence(supersededTarget, replacement, command.ref, "superseded", [
|
||||
"numbered_ref_replace",
|
||||
command.type === target.type ? "same_type_replace" : "cross_type_replace",
|
||||
], {
|
||||
oldMemoryId: target.id,
|
||||
newMemoryId: replacement.id,
|
||||
oldType: target.type,
|
||||
newType: command.type,
|
||||
}));
|
||||
}
|
||||
|
||||
return workspaceMemory;
|
||||
});
|
||||
|
||||
await appendEvidenceEvents(directory, [...updateResult.evidence, ...evidence].map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
}
|
||||
|
||||
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||
@@ -814,13 +564,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
return props?.sessionID ?? props?.info?.id;
|
||||
}
|
||||
|
||||
async function clearCompactionMemoryRefs(sessionID: string): Promise<void> {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.compactionMemoryRefs = [];
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// Inject workspace memory and hot session state into system prompt
|
||||
"experimental.chat.system.transform": async (hookInput, output) => {
|
||||
@@ -949,27 +692,23 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// so we must explicitly carry forward any existing output.context.
|
||||
const otherContext = output.context.filter(Boolean).join("\n\n");
|
||||
|
||||
// Build our private context (numbered workspace memory refs, todos)
|
||||
// Build our private context (workspace memory, hot state, todos)
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. Compaction-only numbered workspace memory snapshot
|
||||
const compactionId = randomUUID();
|
||||
const workspaceStore = await loadWorkspaceMemory(directory);
|
||||
const compactionRefs = accountWorkspaceMemoryCompactionRefs(workspaceStore);
|
||||
const refsWithCompactionId = compactionRefs.refs.map(ref => ({ ...ref, compactionId }));
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.compactionMemoryRefs = refsWithCompactionId;
|
||||
return state;
|
||||
});
|
||||
await appendEvidenceEvents(directory, compactionRefs.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
if (compactionRefs.prompt) {
|
||||
contextParts.push(compactionRefs.prompt);
|
||||
// 1. Frozen workspace memory snapshot
|
||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
if (workspaceSnapshot.renderedPrompt) {
|
||||
contextParts.push(workspaceSnapshot.renderedPrompt);
|
||||
}
|
||||
|
||||
// 2. Pending todos from OpenCode
|
||||
// 2. Hot session state
|
||||
const sessionState = await loadSessionState(directory, sessionID);
|
||||
const hotPrompt = renderHotSessionState(sessionState, directory);
|
||||
if (hotPrompt) {
|
||||
contextParts.push(hotPrompt);
|
||||
}
|
||||
|
||||
// 3. Pending todos from OpenCode
|
||||
const todos = await pendingTodos(client, sessionID);
|
||||
const todosPrompt = renderTodosForCompaction(todos);
|
||||
if (todosPrompt) {
|
||||
@@ -982,7 +721,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
.join("\n\n");
|
||||
|
||||
// Replace the default prompt entirely with our ---free template
|
||||
output.prompt = buildCompactionPrompt(privateContext, compactionId);
|
||||
output.prompt = buildCompactionPrompt(privateContext);
|
||||
|
||||
// Clear context array since we consumed it into output.prompt.
|
||||
// Subsequent plugins that set output.prompt will also need to check
|
||||
@@ -996,40 +735,33 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// Handle session events
|
||||
event: async ({ event }) => {
|
||||
if (event.type === "session.compacted") {
|
||||
let sessionID: string | undefined;
|
||||
try {
|
||||
sessionID = sessionIDFromEventProperties(event.properties);
|
||||
const sessionID = sessionIDFromEventProperties(event.properties);
|
||||
if (!sessionID) return;
|
||||
|
||||
// Sub-agents don't need post-compaction processing
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
try {
|
||||
// Parse latest compaction summary for memory candidates, stage them into
|
||||
// durable pending journal, then promote pending memories.
|
||||
const summary = await latestCompactionSummary(client, sessionID);
|
||||
const compactionId = summary ? compactionIdFromSummary(summary) : undefined;
|
||||
const parseResult = summary
|
||||
? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory))
|
||||
: { entries: [], commands: [], evidence: [] };
|
||||
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
|
||||
...event,
|
||||
// Parse latest compaction summary for memory candidates, stage them into
|
||||
// durable pending journal, then promote pending memories.
|
||||
const summary = await latestCompactionSummary(client, sessionID);
|
||||
const parseResult = summary
|
||||
? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory))
|
||||
: { entries: [], evidence: [] };
|
||||
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
const candidates = parseResult.entries;
|
||||
if (candidates.length > 0) {
|
||||
await appendPendingMemories(directory, candidates);
|
||||
await appendEvidenceEvents(directory, candidates.map(memory => ({
|
||||
...pendingAppendedEvidence(memory),
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
await applyCompactionMemoryCommands(sessionID, parseResult.commands, compactionId);
|
||||
const candidates = parseResult.entries;
|
||||
if (candidates.length > 0) {
|
||||
await appendPendingMemories(directory, candidates);
|
||||
await appendEvidenceEvents(directory, candidates.map(memory => ({
|
||||
...pendingAppendedEvidence(memory),
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
}
|
||||
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
} finally {
|
||||
await clearCompactionMemoryRefs(sessionID);
|
||||
}
|
||||
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
} catch (error) {
|
||||
// Keep pending memories in session/journal for retry on next event/session.
|
||||
await warnMemoryHook("event.session.compacted", error, directory);
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export const USER_IMPORTANCE_FACTOR = {
|
||||
|
||||
export const RETENTION_TYPE_MAX = {
|
||||
feedback: 10,
|
||||
decision: 12,
|
||||
decision: 10,
|
||||
project: 8,
|
||||
reference: 6,
|
||||
} as const;
|
||||
|
||||
+39
-181
@@ -1,14 +1,10 @@
|
||||
import { relative } from "path";
|
||||
import { sessionStatePath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts";
|
||||
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "./types.ts";
|
||||
import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts";
|
||||
import { HOT_STATE_LIMITS } from "./types.ts";
|
||||
import { memoryKey } from "./pending-journal.ts";
|
||||
|
||||
type SessionStateInput = Omit<SessionState, "compactionMemoryRefs"> & {
|
||||
compactionMemoryRefs?: unknown;
|
||||
};
|
||||
|
||||
const ACTION_WEIGHT: Record<ActiveFile["action"], number> = {
|
||||
edit: 50,
|
||||
write: 45,
|
||||
@@ -26,7 +22,6 @@ export function createEmptySessionState(sessionID: string): SessionState {
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [],
|
||||
compactionMemoryRefs: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,11 +33,10 @@ export async function loadSessionState(root: string, sessionID: string): Promise
|
||||
loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : [];
|
||||
loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : [];
|
||||
loaded.pendingMemories = Array.isArray(loaded.pendingMemories) ? loaded.pendingMemories : [];
|
||||
loaded.compactionMemoryRefs = normalizeCompactionMemoryRefs((loaded as SessionStateInput).compactionMemoryRefs);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export async function saveSessionState(root: string, state: SessionState | SessionStateInput): Promise<void> {
|
||||
export async function saveSessionState(root: string, state: SessionState): Promise<void> {
|
||||
await atomicWriteJSON(await sessionStatePath(root, state.sessionID), normalizeSessionState(state));
|
||||
}
|
||||
|
||||
@@ -58,53 +52,18 @@ export async function updateSessionState(
|
||||
current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : [];
|
||||
current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : [];
|
||||
current.pendingMemories = Array.isArray(current.pendingMemories) ? current.pendingMemories : [];
|
||||
current.compactionMemoryRefs = normalizeCompactionMemoryRefs((current as SessionStateInput).compactionMemoryRefs);
|
||||
return normalizeSessionState(await updater(current));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSessionState(state: SessionState | SessionStateInput): SessionState {
|
||||
function normalizeSessionState(state: SessionState): SessionState {
|
||||
state.updatedAt = new Date().toISOString();
|
||||
state.activeFiles = state.activeFiles.slice(0, HOT_STATE_LIMITS.maxActiveFilesStored);
|
||||
state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored);
|
||||
state.recentDecisions = state.recentDecisions.slice(0, HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
||||
state.pendingMemories = dedupePendingMemories(Array.isArray(state.pendingMemories) ? state.pendingMemories : [])
|
||||
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesStored);
|
||||
return {
|
||||
...state,
|
||||
compactionMemoryRefs: normalizeCompactionMemoryRefs(state.compactionMemoryRefs),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCompactionMemoryRefs(value: unknown): CompactionMemoryRef[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
if (value.some(item => !isCompactionMemoryRef(item))) return [];
|
||||
return value.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||
}
|
||||
|
||||
function isCompactionMemoryRef(value: unknown): value is CompactionMemoryRef {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.ref !== "string" || !/^M[1-9]\d*$/.test(value.ref)) return false;
|
||||
if (typeof value.memoryId !== "string" || value.memoryId.trim() === "") return false;
|
||||
if (value.compactionId !== undefined && typeof value.compactionId !== "string") return false;
|
||||
if (!isLongTermType(value.type)) return false;
|
||||
if (!isLongTermSource(value.source)) return false;
|
||||
if (typeof value.exactKey !== "string" || value.exactKey.trim() === "") return false;
|
||||
if (typeof value.identityKey !== "string" || value.identityKey.trim() === "") return false;
|
||||
if (typeof value.textPreview !== "string") return false;
|
||||
return typeof value.capturedAt === "number" && Number.isFinite(value.capturedAt);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isLongTermType(value: unknown): value is CompactionMemoryRef["type"] {
|
||||
return value === "feedback" || value === "project" || value === "decision" || value === "reference";
|
||||
}
|
||||
|
||||
function isLongTermSource(value: unknown): value is CompactionMemoryRef["source"] {
|
||||
return value === "explicit" || value === "compaction" || value === "manual";
|
||||
return state;
|
||||
}
|
||||
|
||||
function dedupePendingMemories(memories: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
@@ -230,150 +189,49 @@ export function clearErrorsForSuccessfulCommand(state: SessionState, command: st
|
||||
state.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
export type HotStateSection = "active_files" | "open_errors" | "recent_decisions" | "pending_memories";
|
||||
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
|
||||
const activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesRendered);
|
||||
const openErrors = [...state.openErrors]
|
||||
.sort((a, b) => b.lastSeen - a.lastSeen)
|
||||
.slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered);
|
||||
const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
||||
const pendingMemories = dedupePendingMemories(state.pendingMemories)
|
||||
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesRendered);
|
||||
|
||||
export type HotStateOmissionReason = "section_cap" | "char_budget";
|
||||
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0 && pendingMemories.length === 0) return "";
|
||||
|
||||
export type HotStateOmittedItem = {
|
||||
section: HotStateSection;
|
||||
reason: HotStateOmissionReason;
|
||||
text: string;
|
||||
memoryId?: string;
|
||||
};
|
||||
const lines: string[] = ["Hot session state (current session):"];
|
||||
|
||||
export type HotSessionStateRenderAccounting = {
|
||||
prompt: string;
|
||||
omitted: HotStateOmittedItem[];
|
||||
maxRenderedChars: number;
|
||||
};
|
||||
|
||||
type HotStateRenderItem = {
|
||||
section: HotStateSection;
|
||||
line: string;
|
||||
memoryId?: string;
|
||||
};
|
||||
|
||||
type HotStateRenderSection = {
|
||||
heading: `${HotStateSection}:`;
|
||||
items: HotStateRenderItem[];
|
||||
};
|
||||
|
||||
const HOT_STATE_PREFIX = "Hot session state (current session):";
|
||||
|
||||
export function accountHotSessionStateRender(state: SessionState, workspaceRoot: string): HotSessionStateRenderAccounting {
|
||||
const maxRenderedChars = HOT_STATE_LIMITS.maxRenderedChars;
|
||||
const omitted: HotStateOmittedItem[] = [];
|
||||
const sections = buildHotStateRenderSections(state, workspaceRoot, omitted);
|
||||
|
||||
if (sections.every(section => section.items.length === 0)) {
|
||||
return { prompt: "", omitted, maxRenderedChars };
|
||||
}
|
||||
|
||||
const lines: string[] = [HOT_STATE_PREFIX];
|
||||
let renderedEntryCount = 0;
|
||||
|
||||
for (const section of sections) {
|
||||
let headingRendered = false;
|
||||
|
||||
for (const item of section.items) {
|
||||
if (headingRendered) {
|
||||
if (wouldFit(lines, item.line, maxRenderedChars)) {
|
||||
lines.push(item.line);
|
||||
renderedEntryCount += 1;
|
||||
} else {
|
||||
omitted.push(omitHotStateItem(item, "char_budget"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wouldFit(lines, section.heading, maxRenderedChars)
|
||||
&& wouldFit([...lines, section.heading], item.line, maxRenderedChars)) {
|
||||
lines.push(section.heading, item.line);
|
||||
headingRendered = true;
|
||||
renderedEntryCount += 1;
|
||||
} else {
|
||||
omitted.push(omitHotStateItem(item, "char_budget"));
|
||||
}
|
||||
if (activeFiles.length > 0) {
|
||||
lines.push("active_files:");
|
||||
for (const item of activeFiles) {
|
||||
const viewPath = displayPath(workspaceRoot, item.path);
|
||||
lines.push(`- ${viewPath} (${item.action}, ${item.count}x)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (renderedEntryCount === 0) return { prompt: "", omitted, maxRenderedChars };
|
||||
if (openErrors.length > 0) {
|
||||
lines.push("open_errors:");
|
||||
for (const err of openErrors) {
|
||||
lines.push(`- [${err.category}] ${err.summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { prompt: lines.join("\n"), omitted, maxRenderedChars };
|
||||
}
|
||||
if (decisions.length > 0) {
|
||||
lines.push("recent_decisions:");
|
||||
for (const decision of decisions) {
|
||||
lines.push(`- ${decision.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
|
||||
return accountHotSessionStateRender(state, workspaceRoot).prompt;
|
||||
}
|
||||
if (pendingMemories.length > 0) {
|
||||
lines.push("pending_memories:");
|
||||
for (const memory of pendingMemories) {
|
||||
lines.push(`- [${memory.type}] ${memory.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildHotStateRenderSections(
|
||||
state: SessionState,
|
||||
workspaceRoot: string,
|
||||
omitted: HotStateOmittedItem[],
|
||||
): HotStateRenderSection[] {
|
||||
const activeFiles = rankActiveFiles(state.activeFiles).map(item => ({
|
||||
section: "active_files" as const,
|
||||
line: `- ${displayPath(workspaceRoot, item.path)} (${item.action}, ${item.count}x)`,
|
||||
}));
|
||||
const openErrors = [...state.openErrors]
|
||||
.sort((a, b) => b.lastSeen - a.lastSeen)
|
||||
.map(err => ({
|
||||
section: "open_errors" as const,
|
||||
line: `- [${err.category}] ${err.summary}`,
|
||||
}));
|
||||
const decisions = state.recentDecisions.map(item => ({
|
||||
section: "recent_decisions" as const,
|
||||
line: `- ${item.text}`,
|
||||
}));
|
||||
const pendingMemories = dedupePendingMemories(state.pendingMemories).map(item => ({
|
||||
section: "pending_memories" as const,
|
||||
line: `- [${item.type}] ${item.text}`,
|
||||
memoryId: item.id,
|
||||
}));
|
||||
|
||||
const renderedActiveFiles = capHotStateItems(activeFiles, HOT_STATE_LIMITS.maxActiveFilesRendered, "start", omitted);
|
||||
const renderedOpenErrors = capHotStateItems(openErrors, HOT_STATE_LIMITS.maxOpenErrorsRendered, "start", omitted);
|
||||
const renderedDecisions = capHotStateItems(decisions, HOT_STATE_LIMITS.maxRecentDecisionsStored, "end", omitted);
|
||||
const renderedPendingMemories = capHotStateItems(
|
||||
pendingMemories,
|
||||
HOT_STATE_LIMITS.maxPendingMemoriesRendered,
|
||||
"end",
|
||||
omitted,
|
||||
);
|
||||
|
||||
return [
|
||||
{ heading: "active_files:", items: renderedActiveFiles },
|
||||
{ heading: "open_errors:", items: renderedOpenErrors },
|
||||
{ heading: "recent_decisions:", items: renderedDecisions },
|
||||
{ heading: "pending_memories:", items: renderedPendingMemories },
|
||||
];
|
||||
}
|
||||
|
||||
function capHotStateItems(
|
||||
items: HotStateRenderItem[],
|
||||
cap: number,
|
||||
keep: "start" | "end",
|
||||
omitted: HotStateOmittedItem[],
|
||||
): HotStateRenderItem[] {
|
||||
if (items.length <= cap) return items;
|
||||
|
||||
const renderedItems = keep === "start" ? items.slice(0, cap) : items.slice(-cap);
|
||||
const omittedItems = keep === "start" ? items.slice(cap) : items.slice(0, items.length - cap);
|
||||
omitted.push(...omittedItems.map(item => omitHotStateItem(item, "section_cap")));
|
||||
return renderedItems;
|
||||
}
|
||||
|
||||
function omitHotStateItem(item: HotStateRenderItem, reason: HotStateOmissionReason): HotStateOmittedItem {
|
||||
return {
|
||||
section: item.section,
|
||||
reason,
|
||||
text: item.line,
|
||||
...(item.memoryId ? { memoryId: item.memoryId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function wouldFit(lines: string[], nextLine: string, maxRenderedChars: number): boolean {
|
||||
return [...lines, nextLine].join("\n").length <= maxRenderedChars;
|
||||
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
|
||||
}
|
||||
|
||||
function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] {
|
||||
|
||||
@@ -28,18 +28,6 @@ export type LongTermMemoryEntry = {
|
||||
safetyCritical?: boolean;
|
||||
};
|
||||
|
||||
export type CompactionMemoryRef = {
|
||||
ref: string;
|
||||
compactionId?: string;
|
||||
memoryId: string;
|
||||
type: LongTermType;
|
||||
source: LongTermSource;
|
||||
exactKey: string;
|
||||
identityKey: string;
|
||||
textPreview: string;
|
||||
capturedAt: number;
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryStore = {
|
||||
version: 1;
|
||||
workspace: {
|
||||
@@ -104,7 +92,6 @@ export type SessionState = {
|
||||
openErrors: OpenError[];
|
||||
recentDecisions: SessionDecision[];
|
||||
pendingMemories: LongTermMemoryEntry[];
|
||||
compactionMemoryRefs: CompactionMemoryRef[];
|
||||
};
|
||||
|
||||
export const LONG_TERM_LIMITS = {
|
||||
|
||||
+3
-81
@@ -1,6 +1,6 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { CompactionMemoryRef, LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
@@ -59,16 +59,6 @@ export type WorkspaceMemoryRenderAccounting = {
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryCompactionRefsAccounting = WorkspaceMemoryRenderAccounting & {
|
||||
refs: CompactionMemoryRef[];
|
||||
};
|
||||
|
||||
type WorkspaceMemoryRenderSelection = {
|
||||
active: LongTermMemoryEntry[];
|
||||
omitted: WorkspaceMemoryRenderAccounting["omitted"];
|
||||
maxChars: number;
|
||||
};
|
||||
|
||||
export type QualityCleanupMigrationLogEntry = {
|
||||
migrationId: string;
|
||||
timestamp: string;
|
||||
@@ -860,13 +850,14 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
|
||||
return accountWorkspaceMemoryRender(store).prompt;
|
||||
}
|
||||
|
||||
function selectWorkspaceMemoryForRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderSelection {
|
||||
export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting {
|
||||
const now = Date.now();
|
||||
const maxChars = Math.min(
|
||||
store.limits.maxRenderedChars,
|
||||
LONG_TERM_LIMITS.maxRenderedChars
|
||||
);
|
||||
const omitted: WorkspaceMemoryRenderAccounting["omitted"] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
for (const entry of store.entries) {
|
||||
if (entry.status === "superseded") {
|
||||
@@ -883,13 +874,6 @@ function selectWorkspaceMemoryForRender(store: WorkspaceMemoryStore): WorkspaceM
|
||||
const active = typeCapResult.kept.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||
for (const memory of typeCapResult.kept.slice(LONG_TERM_LIMITS.maxEntries)) omitted.push({ memory, reason: "global_cap" });
|
||||
|
||||
return { active, omitted, maxChars };
|
||||
}
|
||||
|
||||
export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting {
|
||||
const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store);
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
if (active.length === 0) {
|
||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
||||
return { rendered: [], omitted, evidence, prompt: "" };
|
||||
@@ -934,68 +918,6 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works
|
||||
return { rendered, omitted, evidence, prompt: lines.join("\n") };
|
||||
}
|
||||
|
||||
export function accountWorkspaceMemoryCompactionRefs(store: WorkspaceMemoryStore): WorkspaceMemoryCompactionRefsAccounting {
|
||||
const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store);
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
const originalById = new Map(store.entries.map(entry => [entry.id, entry]));
|
||||
|
||||
if (active.length === 0) {
|
||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
||||
return { rendered: [], omitted, evidence, prompt: "", refs: [] };
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
"Existing workspace memories available for consolidation:",
|
||||
];
|
||||
const rendered: LongTermMemoryEntry[] = [];
|
||||
const refs: CompactionMemoryRef[] = [];
|
||||
const capturedAt = Date.now();
|
||||
|
||||
for (const type of ["feedback", "project", "decision", "reference"] as const) {
|
||||
const items = active.filter(entry => entry.type === type);
|
||||
if (items.length === 0) continue;
|
||||
|
||||
const sectionLines: string[] = [`${type}:`];
|
||||
|
||||
for (const item of items) {
|
||||
const ref = `M${refs.length + 1}`;
|
||||
const line = `[${ref}] ${item.text}`;
|
||||
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
|
||||
const original = originalById.get(item.id) ?? item;
|
||||
sectionLines.push(line);
|
||||
rendered.push(item);
|
||||
refs.push({
|
||||
ref,
|
||||
memoryId: item.id,
|
||||
type: original.type,
|
||||
source: original.source,
|
||||
exactKey: workspaceMemoryExactKey(original),
|
||||
identityKey: workspaceMemoryIdentityKey(original),
|
||||
textPreview: item.text,
|
||||
capturedAt,
|
||||
});
|
||||
} else {
|
||||
omitted.push({ memory: item, reason: "char_budget" });
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionLines.length > 1) {
|
||||
lines.push(...sectionLines);
|
||||
}
|
||||
}
|
||||
|
||||
for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered"));
|
||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
||||
|
||||
return {
|
||||
rendered,
|
||||
omitted,
|
||||
evidence,
|
||||
prompt: rendered.length > 0 ? lines.join("\n") : "",
|
||||
refs,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEvidence(
|
||||
memory: LongTermMemoryEntry,
|
||||
outcome: "rendered" | "omitted",
|
||||
|
||||
@@ -209,39 +209,6 @@ test("memory_migration_superseded event round-trips through append and query", a
|
||||
}
|
||||
});
|
||||
|
||||
test("memory_reverted_numbered_ref event round-trips through append and query", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
await appendEvidenceEvent(root, eventInput({
|
||||
type: "memory_reverted_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome: "recovered",
|
||||
reasonCodes: ["manual_revert_numbered_ref"],
|
||||
memory: { memoryId: "replacement-memory", type: "decision", source: "compaction", status: "superseded" },
|
||||
relations: [
|
||||
{ role: "superseded", memory: { memoryId: "replacement-memory", type: "decision", source: "compaction", status: "superseded" } },
|
||||
{ role: "recovered", memory: { memoryId: "restored-memory", type: "decision", source: "compaction", status: "active" } },
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await queryEvidenceEvents(root, {
|
||||
types: ["memory_reverted_numbered_ref"],
|
||||
phases: ["storage"],
|
||||
outcomes: ["recovered"],
|
||||
memoryId: "restored-memory",
|
||||
});
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].type, "memory_reverted_numbered_ref");
|
||||
assert.equal(result[0].phase, "storage");
|
||||
assert.equal(result[0].outcome, "recovered");
|
||||
assert.ok(result[0].reasonCodes.includes("manual_revert_numbered_ref"));
|
||||
assert.ok(result[0].relations?.some(relation => relation.role === "recovered" && relation.memory?.memoryId === "restored-memory"));
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("queryEvidenceEvents supports newestFirst and limit", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
|
||||
@@ -212,65 +212,6 @@ Memory candidates:
|
||||
assert.match(result.evidence[0].textPreview ?? "", /accounting evidence events/);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidatesWithEvidence returns mixed valid commands and entries", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
REINFORCE [M3]
|
||||
REPLACE [M4] [decision] Use numbered refs for safe memory consolidation.
|
||||
- [feedback] User prefers concise implementation handoffs.
|
||||
`;
|
||||
|
||||
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
|
||||
|
||||
assert.deepEqual(result.commands, [
|
||||
{ kind: "REINFORCE", ref: "M3" },
|
||||
{ kind: "REPLACE", ref: "M4", type: "decision", text: "Use numbered refs for safe memory consolidation." },
|
||||
]);
|
||||
assert.equal(result.entries.length, 1);
|
||||
assert.equal(result.entries[0].type, "feedback");
|
||||
assert.equal(result.entries[0].text, "User prefers concise implementation handoffs.");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidatesWithEvidence rejects malformed command attempts", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
REINFORCE M3
|
||||
REINFORCE [X3]
|
||||
REPLACE [M3] text without type
|
||||
REPLACE [M3] [invalid] text
|
||||
REPLACE [M3] [decision]
|
||||
`;
|
||||
|
||||
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
|
||||
const rejections = result.evidence.filter(event => event.type === "extraction_candidate_rejected");
|
||||
|
||||
assert.equal(result.entries.length, 0);
|
||||
assert.deepEqual(result.commands, []);
|
||||
assert.equal(rejections.length, 5);
|
||||
assert.ok(rejections.every(event => event.phase === "extraction"));
|
||||
assert.ok(rejections.every(event => event.outcome === "rejected"));
|
||||
assert.deepEqual(rejections.map(event => event.reasonCodes[0]), [
|
||||
"invalid_memory_ref",
|
||||
"invalid_memory_ref",
|
||||
"invalid_memory_type",
|
||||
"invalid_memory_type",
|
||||
"empty_replacement_text",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts bracketed candidates without bullets", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
[project] This repository uses local JSON stores for workspace memory.
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].type, "project");
|
||||
assert.equal(items[0].text, "This repository uses local JSON stores for workspace memory.");
|
||||
});
|
||||
|
||||
test("compaction rejected candidate returns rejection evidence without secrets", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
|
||||
@@ -2,18 +2,14 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseArgs } from "../scripts/memory-diag/cli.ts";
|
||||
|
||||
test("help returns usage without exposing hidden or removed commands", () => {
|
||||
test("help returns usage without exiting", () => {
|
||||
const parsed = parseArgs(["--help"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("help" in parsed && parsed.help, true);
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
assert.match(parsed.usage, /memory-diag \[status\]/);
|
||||
assert.match(parsed.usage, /memory-diag commands/);
|
||||
assert.match(parsed.usage, /memory-diag revert/);
|
||||
for (const command of ["health", "quality", "rejections", "disappearances", "trace", "coverage", "audit"]) {
|
||||
assert.doesNotMatch(parsed.usage, new RegExp(command));
|
||||
}
|
||||
assert.doesNotMatch(parsed.usage, /health/);
|
||||
});
|
||||
|
||||
test("status defaults when no subcommand", () => {
|
||||
@@ -34,41 +30,17 @@ test("unknown command returns usage error", () => {
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
});
|
||||
|
||||
test("removed legacy aliases are ordinary unknown subcommands", () => {
|
||||
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
|
||||
const parsed = parseArgs([command]);
|
||||
|
||||
assert.equal(parsed.ok, false, command);
|
||||
if (parsed.ok) continue;
|
||||
assert.equal(parsed.message, `Unknown subcommand: ${command}`);
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
}
|
||||
});
|
||||
|
||||
test("hidden maintainer commands are accepted with neutral notices", () => {
|
||||
const coverage = parseArgs(["coverage"]);
|
||||
assert.equal(coverage.ok, true);
|
||||
assert.equal("command" in coverage && coverage.command, "coverage");
|
||||
assert.equal("deprecationNotice" in coverage && coverage.deprecationNotice, "Note: 'coverage' is a maintainer-only diagnostic and is not part of the public CLI surface.");
|
||||
|
||||
const audit = parseArgs(["audit"]);
|
||||
assert.equal(audit.ok, true);
|
||||
assert.equal("command" in audit && audit.command, "audit");
|
||||
assert.equal("deprecationNotice" in audit && audit.deprecationNotice, "Note: 'audit' is a maintainer-only diagnostic and is not part of the public CLI surface.");
|
||||
});
|
||||
|
||||
test("current command flag validation messages are preserved", () => {
|
||||
const cases: Array<{ args: string[]; message: string }> = [
|
||||
{ args: ["status", "--all"], message: "status does not accept --all" },
|
||||
{ args: ["health", "--json", "--all"], message: "health --json does not support --all" },
|
||||
{ args: ["quality", "--all"], message: "quality does not accept --all" },
|
||||
{ args: ["coverage", "--all"], message: "coverage does not accept --all" },
|
||||
{ args: ["missing", "--all"], message: "missing does not accept --all" },
|
||||
{ args: ["rejected", "--all"], message: "rejected does not accept --all" },
|
||||
{ args: ["disappearances", "--all"], message: "disappearances does not accept --all" },
|
||||
{ args: ["rejections", "--all"], message: "rejections does not accept --all" },
|
||||
{ args: ["audit", "--workspace", "/tmp/workspace"], message: "audit does not accept --all or --workspace" },
|
||||
{ args: ["explain", "--all"], message: "explain does not accept --all" },
|
||||
{ args: ["status", "--since", "forever"], message: "status does not accept rejection filters" },
|
||||
{ args: ["status", "--reason", "bad_decision"], message: "status does not accept rejection filters" },
|
||||
{ args: ["status", "--quality"], message: "Unknown option: --quality" },
|
||||
{ args: ["rejected", "--unique"], message: "Unknown option: --unique" },
|
||||
{ args: ["trace", "--all", "--memory", "mem-1"], message: "trace does not accept --all" },
|
||||
{ args: ["quality", "--since", "forever"], message: "quality does not accept rejection filters" },
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
@@ -80,43 +52,26 @@ test("current command flag validation messages are preserved", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("commands accepts workspace json and verbose flags", () => {
|
||||
const parsed = parseArgs(["commands", "--workspace", "/tmp/workspace", "--json", "--verbose"]);
|
||||
test("trace without memory returns current required id error", () => {
|
||||
const parsed = parseArgs(["trace"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "commands");
|
||||
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(parsed.ok, false);
|
||||
if (parsed.ok) return;
|
||||
assert.equal(parsed.message, "--memory requires an id");
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal("command" in byMemory && byMemory.command, "revert");
|
||||
assert.equal("options" in byMemory && byMemory.options.memory, "mem-new");
|
||||
assert.equal("options" in byMemory && byMemory.options.workspace, "/tmp/workspace");
|
||||
assert.equal("options" in byMemory && byMemory.options.apply, true);
|
||||
test("health with all and workspace returns current conflict error", () => {
|
||||
const parsed = parseArgs(["health", "--all", "--workspace", "/tmp/workspace"]);
|
||||
|
||||
const byEvent = parseArgs(["revert", "--event", "evt_1"]);
|
||||
assert.equal(byEvent.ok, true);
|
||||
assert.equal("command" in byEvent && byEvent.command, "revert");
|
||||
assert.equal("options" in byEvent && byEvent.options.event, "evt_1");
|
||||
assert.equal("options" in byEvent && byEvent.options.apply, undefined);
|
||||
assert.equal(parsed.ok, false);
|
||||
if (parsed.ok) return;
|
||||
assert.equal(parsed.message, "Use either --all or --workspace, not both");
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
});
|
||||
|
||||
test("revert requires exactly one selector", () => {
|
||||
const missing = parseArgs(["revert"]);
|
||||
assert.equal(missing.ok, false);
|
||||
if (!missing.ok) assert.equal(missing.message, "revert requires --memory or --event");
|
||||
|
||||
const both = parseArgs(["revert", "--memory", "mem-new", "--event", "evt_1"]);
|
||||
assert.equal(both.ok, false);
|
||||
if (!both.ok) assert.equal(both.message, "Use either --memory or --event, not both");
|
||||
});
|
||||
|
||||
test("rejected invalid since value returns current error", () => {
|
||||
const parsed = parseArgs(["rejected", "--since", "forever"]);
|
||||
test("rejections invalid since value returns current error", () => {
|
||||
const parsed = parseArgs(["rejections", "--since", "forever"]);
|
||||
|
||||
assert.equal(parsed.ok, false);
|
||||
if (parsed.ok) return;
|
||||
@@ -124,6 +79,53 @@ test("rejected invalid since value returns current error", () => {
|
||||
assert.match(parsed.usage, /Usage:/);
|
||||
});
|
||||
|
||||
test("legacy health alias emits deprecation notice and maps to status", () => {
|
||||
const parsed = parseArgs(["health"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "status");
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "health");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'health' is now 'status'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("legacy quality alias sets verbose and emits deprecation notice", () => {
|
||||
const parsed = parseArgs(["quality"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "status");
|
||||
assert.equal("options" in parsed && parsed.options.verbose, true);
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "quality");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("legacy rejections alias emits deprecation notice", () => {
|
||||
const parsed = parseArgs(["rejections"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "rejected");
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "rejections");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("legacy disappearances alias emits deprecation notice", () => {
|
||||
const parsed = parseArgs(["disappearances"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "missing");
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "disappearances");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("legacy trace alias emits deprecation notice", () => {
|
||||
const parsed = parseArgs(["trace", "--memory", "mem-1"]);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal("command" in parsed && parsed.command, "explain");
|
||||
assert.equal("options" in parsed && parsed.options.legacyCommand, "trace");
|
||||
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
|
||||
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'trace --memory <id>' is now 'explain <memory-id>'. This alias will be removed in v2.0.");
|
||||
});
|
||||
|
||||
test("explain accepts positional memory id", () => {
|
||||
const parsed = parseArgs(["explain", "mem-1", "--workspace", "/tmp/workspace"]);
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
|
||||
import type { MemoryInspectionReadModel, WorkspaceDiagSnapshot } from "../scripts/memory-diag/types.ts";
|
||||
import { formatWorkspaceHealth } from "../scripts/memory-diag/formatters/health.ts";
|
||||
import { formatQuality } from "../scripts/memory-diag/formatters/quality.ts";
|
||||
import { formatCoverage } from "../scripts/memory-diag/formatters/coverage.ts";
|
||||
import { formatDisappearances } from "../scripts/memory-diag/formatters/disappearances.ts";
|
||||
import { formatRejectionQuality } from "../scripts/memory-diag/formatters/rejections.ts";
|
||||
import { formatMigrationAudit } from "../scripts/memory-diag/formatters/audit.ts";
|
||||
import { formatExplain } from "../scripts/memory-diag/formatters/explain.ts";
|
||||
import { buildMissingJSON, formatMissing } from "../scripts/memory-diag/formatters/missing.ts";
|
||||
import { formatTrace } from "../scripts/memory-diag/formatters/trace.ts";
|
||||
import { rejectionQualitySummary } from "../scripts/memory-diag/rejections-model.ts";
|
||||
|
||||
function emptyInspectionModel(): MemoryInspectionReadModel {
|
||||
return {
|
||||
@@ -56,6 +60,36 @@ function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType;
|
||||
};
|
||||
}
|
||||
|
||||
test("health formatter includes existing retention cap label", () => {
|
||||
const output = formatWorkspaceHealth({
|
||||
root: "/tmp/workspace",
|
||||
key: "workspace-key",
|
||||
memoryPath: "/tmp/workspace-memory.json",
|
||||
pendingPath: "/tmp/workspace-pending-journal.json",
|
||||
raw: false,
|
||||
now: new Date("2026-01-01T00:00:00.000Z").getTime(),
|
||||
includeTitle: true,
|
||||
}, { rawStore: null, rawJournal: null, pendingExists: false });
|
||||
|
||||
assert.match(output, /Workspace memory health/);
|
||||
assert.match(output, /Retention caps:/);
|
||||
});
|
||||
|
||||
test("quality formatter includes caps and retention clock sections", () => {
|
||||
const output = formatQuality(emptyInspectionModel(), new Date("2026-01-01T00:00:00.000Z").getTime());
|
||||
|
||||
assert.match(output, /Caps:/);
|
||||
assert.match(output, /Retention clocks:/);
|
||||
});
|
||||
|
||||
test("rejection quality formatter includes reason distribution sections", () => {
|
||||
const summary = rejectionQualitySummary([]);
|
||||
const output = formatRejectionQuality({ path: "/tmp/rejections.jsonl", invalidLines: 0, summary, raw: false });
|
||||
|
||||
assert.match(output, /Reason distribution \(raw records\):/);
|
||||
assert.match(output, /Reason distribution \(unique text\):/);
|
||||
});
|
||||
|
||||
test("coverage formatter includes class counts section", () => {
|
||||
const output = formatCoverage([]);
|
||||
|
||||
@@ -63,27 +97,10 @@ test("coverage formatter includes class counts section", () => {
|
||||
assert.match(output, /Per-memory rows:\n \(none\)/);
|
||||
});
|
||||
|
||||
test("missing formatter verbose output preserves disappearance details", () => {
|
||||
const output = formatMissing([], { verbose: true });
|
||||
test("disappearances formatter preserves empty-state label", () => {
|
||||
const output = formatDisappearances([]);
|
||||
|
||||
assert.match(output, /No missing memories found\./);
|
||||
});
|
||||
|
||||
test("missing JSON includes disappearance rows and summary", () => {
|
||||
const rows = [{
|
||||
id: "historical-1",
|
||||
classification: "historical_absent_unknown_reason" as const,
|
||||
terminalType: "unknown" as const,
|
||||
reasonCodes: [],
|
||||
events: [event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted" })],
|
||||
event: undefined,
|
||||
}];
|
||||
|
||||
const output = buildMissingJSON(rows, { generatedAt: "2026-01-01T00:00:00.000Z" });
|
||||
|
||||
assert.equal(output.version, 1);
|
||||
assert.deepEqual(output.summary, { total: 1, explained: 0, needsReview: 1 });
|
||||
assert.deepEqual((output.disappearances as Array<{ id: string }>)[0]?.id, "historical-1");
|
||||
assert.match(output, /No evidence-only memories found\./);
|
||||
});
|
||||
|
||||
test("trace formatter includes lifecycle section", () => {
|
||||
|
||||
+159
-351
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { appendEvidenceEvents, queryEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts";
|
||||
import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts";
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
||||
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } from "../src/types.ts";
|
||||
import { extractionRejectionLogPath, workspaceEvidenceLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
@@ -47,8 +47,8 @@ async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[],
|
||||
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async function runMemoryDiagStatusVerbose(root: string): Promise<string> {
|
||||
return runMemoryDiag(["status", "--workspace", root, "--verbose"]);
|
||||
async function runMemoryDiagHealth(root: string): Promise<string> {
|
||||
return runMemoryDiag(["health", "--workspace", root]);
|
||||
}
|
||||
|
||||
async function runMemoryDiag(args: string[]): Promise<string> {
|
||||
@@ -96,53 +96,27 @@ function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
|
||||
};
|
||||
}
|
||||
|
||||
function replacementFixture(): { original: LongTermMemoryEntry; replacement: LongTermMemoryEntry } {
|
||||
const original = {
|
||||
...entry("mem-original", "Original compaction memory to restore", "decision"),
|
||||
status: "superseded" as const,
|
||||
};
|
||||
const replacement = {
|
||||
...entry("mem-replacement", "Replacement compaction memory to revert", "decision"),
|
||||
supersedes: [original.id],
|
||||
};
|
||||
return { original, replacement };
|
||||
}
|
||||
|
||||
function replacementEvidence(original: LongTermMemoryEntry, replacement: LongTermMemoryEntry): EvidenceEventInput {
|
||||
return evidence({
|
||||
type: "memory_replaced_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome: "superseded",
|
||||
memory: { memoryId: original.id, type: original.type, source: original.source, status: "superseded" },
|
||||
relations: [
|
||||
{ role: "superseded", memory: { memoryId: original.id, type: original.type, source: original.source, status: "superseded" } },
|
||||
{ role: "superseded_by", memory: { memoryId: replacement.id, type: replacement.type, source: replacement.source, status: "active" } },
|
||||
],
|
||||
reasonCodes: ["numbered_ref_replace", "same_type_replace"],
|
||||
details: { ref: "M1", oldMemoryId: original.id, newMemoryId: replacement.id },
|
||||
});
|
||||
}
|
||||
|
||||
test("status handles missing workspace store as empty", async () => {
|
||||
test("health handles missing workspace store as empty", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-"));
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["status", "--workspace", root]);
|
||||
const stdout = await runMemoryDiag(["health", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /Memory status/);
|
||||
assert.match(stdout, /active memories: 0/);
|
||||
assert.match(stdout, /pending: 0/);
|
||||
assert.match(stdout, /memory store: missing or unreadable \(treated as empty\)/);
|
||||
assert.match(stdout, /pending journal: missing \(treated as empty\)/);
|
||||
assert.match(stdout, /Stored active memories: 0/);
|
||||
assert.match(stdout, /Pending journal:\n\s+total: 0/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status verbose handles missing workspace store as empty", async () => {
|
||||
test("quality handles missing workspace store as empty", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-quality-"));
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
|
||||
const stdout = await runMemoryDiag(["quality", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /Memory status inspection/);
|
||||
assert.match(stdout, /Caps:/);
|
||||
assert.match(stdout, /Memory quality inspection/);
|
||||
assert.match(stdout, /0 active memories/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -162,15 +136,15 @@ test("coverage and disappearances handle missing workspace store as empty", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("status with unsupported all flag shows usage error", async () => {
|
||||
test("health with conflicting flags shows usage error", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-conflicting-flags-"));
|
||||
try {
|
||||
await assert.rejects(
|
||||
runMemoryDiagResult(["status", "--all", "--workspace", root]),
|
||||
runMemoryDiagResult(["health", "--all", "--workspace", root]),
|
||||
(error: unknown) => {
|
||||
const err = error as { code?: number; stderr?: string };
|
||||
assert.notEqual(err.code, 0);
|
||||
assert.match(err.stderr ?? "", /status does not accept --all/);
|
||||
assert.match(err.stderr ?? "", /Use either --all or --workspace, not both/);
|
||||
assert.match(err.stderr ?? "", /Usage:/);
|
||||
return true;
|
||||
},
|
||||
@@ -211,21 +185,26 @@ test("memory-diag defaults to status when no subcommand is supplied", async () =
|
||||
assert.equal(result.stderr, "");
|
||||
});
|
||||
|
||||
test("removed legacy aliases return unknown subcommand", async () => {
|
||||
test("legacy health alias emits deprecation notice and still runs", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-"));
|
||||
try {
|
||||
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
|
||||
await assert.rejects(
|
||||
runMemoryDiagResult([command, "--workspace", root]),
|
||||
(error: unknown) => {
|
||||
const err = error as { code?: number; stderr?: string };
|
||||
assert.notEqual(err.code, 0);
|
||||
assert.match(err.stderr ?? "", new RegExp(`Unknown subcommand: ${command}`));
|
||||
assert.match(err.stderr ?? "", /Usage:/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
const result = await runMemoryDiagResult(["health", "--workspace", root]);
|
||||
|
||||
assert.match(result.stdout, /Workspace memory health/);
|
||||
assert.match(result.stderr, /Note: 'health' is now 'status'\. This alias will be removed in v2\.0\./);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy trace alias emits deprecation notice and still traces memory", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-trace-"));
|
||||
try {
|
||||
const result = await runMemoryDiagResult(["trace", "--memory", "test-id", "--workspace", root]);
|
||||
|
||||
assert.match(result.stdout, /Memory test-id: unknown/);
|
||||
assert.match(result.stdout, /Lifecycle:/);
|
||||
assert.match(result.stderr, /Note: 'trace --memory <id>' is now 'explain <memory-id>'\. This alias will be removed in v2\.0\./);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -240,281 +219,17 @@ test("memory health reports stored vs rendered retention counts", async () => {
|
||||
];
|
||||
await writeWorkspaceStore(root, entries);
|
||||
|
||||
const stdout = await runMemoryDiagStatusVerbose(root);
|
||||
const stdout = await runMemoryDiagHealth(root);
|
||||
|
||||
assert.match(stdout, /Memory status inspection/);
|
||||
assert.match(stdout, /active: 28 \/ 28/);
|
||||
assert.match(stdout, /rendered: 21/);
|
||||
assert.match(stdout, /feedback: 17 \/ 10 FULL/);
|
||||
assert.match(stdout, /Stored active memories:/);
|
||||
assert.match(stdout, /Rendered candidates:/);
|
||||
assert.match(stdout, /feedback\s+stored=17\s+rendered=10/);
|
||||
assert.match(stdout, /Top rendered candidates:\n\s+- strength=/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag commands reports zero counts for empty evidence log", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-empty-"));
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["commands", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /Memory command diagnostics/);
|
||||
assert.match(stdout, /compactions with command evidence: 0/);
|
||||
assert.match(stdout, /reinforce: 0/);
|
||||
assert.match(stdout, /replace: 0/);
|
||||
assert.match(stdout, /invalid\/malformed commands: 0/);
|
||||
assert.match(stdout, /Protected REPLACE blocked: 0 \(reinforced: 0, source: 0\)/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag commands summarizes seeded command evidence", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-"));
|
||||
try {
|
||||
await appendEvidenceEvents(root, [
|
||||
evidence({
|
||||
type: "memory_reinforced",
|
||||
phase: "reinforcement",
|
||||
outcome: "reinforced",
|
||||
memory: { memoryId: "reinforced-1", type: "decision", source: "compaction", status: "active" },
|
||||
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"],
|
||||
sessionHash: "command-session-a",
|
||||
}),
|
||||
evidence({
|
||||
type: "memory_reinforced",
|
||||
phase: "reinforcement",
|
||||
outcome: "rejected",
|
||||
memory: { memoryId: "reinforced-blocked", type: "feedback", source: "compaction", status: "active" },
|
||||
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked"],
|
||||
sessionHash: "command-session-a",
|
||||
}),
|
||||
evidence({
|
||||
type: "memory_replaced_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome: "superseded",
|
||||
memory: { memoryId: "replace-old-same", type: "decision", source: "compaction", status: "superseded" },
|
||||
relations: [
|
||||
{ role: "superseded", memory: { memoryId: "replace-old-same", type: "decision", source: "compaction", status: "superseded" } },
|
||||
{ role: "superseded_by", memory: { memoryId: "replace-new-same", type: "decision", source: "compaction", status: "active" } },
|
||||
],
|
||||
reasonCodes: ["numbered_ref_replace", "same_type_replace"],
|
||||
sessionHash: "command-session-b",
|
||||
}),
|
||||
evidence({
|
||||
type: "memory_replaced_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome: "superseded",
|
||||
memory: { memoryId: "replace-old-cross", type: "project", source: "compaction", status: "superseded" },
|
||||
relations: [
|
||||
{ role: "superseded", memory: { memoryId: "replace-old-cross", type: "project", source: "compaction", status: "superseded" } },
|
||||
{ role: "superseded_by", memory: { memoryId: "replace-new-cross", type: "decision", source: "compaction", status: "active" } },
|
||||
],
|
||||
reasonCodes: ["numbered_ref_replace", "cross_type_replace"],
|
||||
sessionHash: "command-session-b",
|
||||
}),
|
||||
evidence({
|
||||
type: "memory_replaced_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome: "rejected",
|
||||
memory: { memoryId: "replace-protected-reinforced", type: "decision", source: "compaction", status: "active" },
|
||||
reasonCodes: ["protected_reinforced_target"],
|
||||
sessionHash: "command-session-c",
|
||||
}),
|
||||
evidence({
|
||||
type: "memory_replaced_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome: "rejected",
|
||||
memory: { memoryId: "replace-protected-source", type: "feedback", source: "explicit", status: "active" },
|
||||
reasonCodes: ["protected_memory_source"],
|
||||
sessionHash: "command-session-c",
|
||||
}),
|
||||
evidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
memory: { type: "decision", source: "compaction" },
|
||||
reasonCodes: ["invalid_memory_ref"],
|
||||
textPreview: "REINFORCE [X3]",
|
||||
sessionHash: "command-session-c",
|
||||
}),
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["commands", "--workspace", root, "--verbose"]);
|
||||
|
||||
assert.match(stdout, /compactions with command evidence: 3/);
|
||||
assert.match(stdout, /reinforce: 2/);
|
||||
assert.match(stdout, /replace: 4/);
|
||||
assert.match(stdout, /reinforced: 1/);
|
||||
assert.match(stdout, /superseded: 2/);
|
||||
assert.match(stdout, /rejected: 3/);
|
||||
assert.match(stdout, /blocked: 3/);
|
||||
assert.match(stdout, /invalid\/malformed commands: 1/);
|
||||
assert.match(stdout, /same-type replacements: 1/);
|
||||
assert.match(stdout, /cross-type replacements: 1/);
|
||||
assert.match(stdout, /Protected REPLACE blocked: 2 \(reinforced: 1, source: 1\)/);
|
||||
assert.match(stdout, /protected_reinforced_target: 1/);
|
||||
assert.match(stdout, /protected_memory_source: 1/);
|
||||
assert.match(stdout, /Latest command events/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag commands json exposes protected replacement counts", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-json-"));
|
||||
try {
|
||||
await appendEvidenceEvents(root, [
|
||||
evidence({
|
||||
type: "memory_replaced_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome: "rejected",
|
||||
memory: { memoryId: "replace-protected-reinforced-json", type: "decision", source: "compaction", status: "active" },
|
||||
reasonCodes: ["protected_reinforced_target"],
|
||||
sessionHash: "json-command-session",
|
||||
}),
|
||||
evidence({
|
||||
type: "memory_replaced_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome: "rejected",
|
||||
memory: { memoryId: "replace-protected-source-json", type: "feedback", source: "manual", status: "active" },
|
||||
reasonCodes: ["protected_memory_source"],
|
||||
sessionHash: "json-command-session",
|
||||
}),
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["commands", "--workspace", root, "--json"]);
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
protectedReplacements: { total: number; protectedReinforcedTarget: number; protectedMemorySource: number };
|
||||
commands: { reinforce: number; replace: number };
|
||||
outcomes: { rejected: number };
|
||||
};
|
||||
|
||||
assert.deepEqual(parsed.protectedReplacements, {
|
||||
total: 2,
|
||||
protectedReinforcedTarget: 1,
|
||||
protectedMemorySource: 1,
|
||||
});
|
||||
assert.equal(parsed.commands.replace, 2);
|
||||
assert.equal(parsed.commands.reinforce, 0);
|
||||
assert.equal(parsed.outcomes.rejected, 2);
|
||||
} 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 {
|
||||
const { original, replacement } = replacementFixture();
|
||||
await writeWorkspaceStore(root, [original, replacement]);
|
||||
await appendEvidenceEvents(root, [replacementEvidence(original, replacement)]);
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const before = await readFile(path, "utf8");
|
||||
|
||||
const stdout = await runMemoryDiag(["revert", "--workspace", root, "--memory", replacement.id]);
|
||||
const after = await readFile(path, "utf8");
|
||||
const reverts = await queryEvidenceEvents(root, { types: ["memory_reverted_numbered_ref"] });
|
||||
|
||||
assert.match(stdout, /Memory revert dry run/);
|
||||
assert.match(stdout, /replacement: mem-replacement/);
|
||||
assert.match(stdout, /original: mem-original/);
|
||||
assert.match(stdout, /No changes applied/);
|
||||
assert.equal(after, before);
|
||||
assert.equal(reverts.length, 0);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag revert apply restores original and emits evidence", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-apply-"));
|
||||
try {
|
||||
const { original, replacement } = replacementFixture();
|
||||
await writeWorkspaceStore(root, [original, replacement]);
|
||||
const [replaceEvent] = await appendEvidenceEvents(root, [replacementEvidence(original, replacement)]);
|
||||
|
||||
const stdout = await runMemoryDiag(["revert", "--workspace", root, "--event", replaceEvent.eventId, "--apply"]);
|
||||
const stored = JSON.parse(await readFile(await workspaceMemoryPath(root), "utf8")) as WorkspaceMemoryStore;
|
||||
const restored = stored.entries.find(item => item.id === original.id);
|
||||
const supersededReplacement = stored.entries.find(item => item.id === replacement.id);
|
||||
const reverts = await queryEvidenceEvents(root, { types: ["memory_reverted_numbered_ref"], outcomes: ["recovered"] });
|
||||
const commandsStdout = await runMemoryDiag(["commands", "--workspace", root, "--verbose"]);
|
||||
|
||||
assert.match(stdout, /Memory revert applied/);
|
||||
assert.equal(restored?.status, "active");
|
||||
assert.equal(supersededReplacement?.status, "superseded");
|
||||
assert.ok(restored?.updatedAt && restored.updatedAt !== original.updatedAt);
|
||||
assert.ok(supersededReplacement?.updatedAt && supersededReplacement.updatedAt !== replacement.updatedAt);
|
||||
assert.equal(reverts.length, 1);
|
||||
assert.equal(reverts[0].type, "memory_reverted_numbered_ref");
|
||||
assert.equal(reverts[0].outcome, "recovered");
|
||||
assert.ok(reverts[0].reasonCodes.includes("manual_revert_numbered_ref"));
|
||||
assert.ok(reverts[0].relations?.some(relation => relation.role === "recovered" && relation.memory?.memoryId === original.id));
|
||||
assert.match(commandsStdout, /memory_reverted_numbered_ref/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag revert rejects unsafe requests", async () => {
|
||||
const missingOriginalRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-missing-original-"));
|
||||
const replacementSupersededRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-replacement-superseded-"));
|
||||
const laterSupersessionRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-later-supersession-"));
|
||||
const wrongEventRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-wrong-event-"));
|
||||
try {
|
||||
const { original, replacement } = replacementFixture();
|
||||
|
||||
await writeWorkspaceStore(missingOriginalRoot, [replacement]);
|
||||
await appendEvidenceEvents(missingOriginalRoot, [replacementEvidence(original, replacement)]);
|
||||
await assert.rejects(
|
||||
runMemoryDiagResult(["revert", "--workspace", missingOriginalRoot, "--memory", replacement.id]),
|
||||
(error: unknown) => {
|
||||
const err = error as { stderr?: string };
|
||||
assert.match(err.stderr ?? "", /revert rejected: original memory mem-original is missing/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
await writeWorkspaceStore(replacementSupersededRoot, [original, { ...replacement, status: "superseded" }]);
|
||||
await appendEvidenceEvents(replacementSupersededRoot, [replacementEvidence(original, replacement)]);
|
||||
await assert.rejects(
|
||||
runMemoryDiagResult(["revert", "--workspace", replacementSupersededRoot, "--memory", replacement.id]),
|
||||
(error: unknown) => {
|
||||
const err = error as { stderr?: string };
|
||||
assert.match(err.stderr ?? "", /revert rejected: replacement memory mem-replacement is not active/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const later = { ...entry("mem-later", "Later active supersession", "decision"), supersedes: [replacement.id] };
|
||||
await writeWorkspaceStore(laterSupersessionRoot, [original, replacement, later]);
|
||||
await appendEvidenceEvents(laterSupersessionRoot, [replacementEvidence(original, replacement)]);
|
||||
await assert.rejects(
|
||||
runMemoryDiagResult(["revert", "--workspace", laterSupersessionRoot, "--memory", replacement.id]),
|
||||
(error: unknown) => {
|
||||
const err = error as { stderr?: string };
|
||||
assert.match(err.stderr ?? "", /revert rejected: replacement memory mem-replacement is superseded by active memory mem-later/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const [wrongEvent] = await appendEvidenceEvents(wrongEventRoot, [evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted" })]);
|
||||
await assert.rejects(
|
||||
runMemoryDiagResult(["revert", "--workspace", wrongEventRoot, "--event", wrongEvent.eventId]),
|
||||
(error: unknown) => {
|
||||
const err = error as { stderr?: string };
|
||||
assert.match(err.stderr ?? "", /revert rejected: event .* is not a memory_replaced_numbered_ref event/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(missingOriginalRoot, { recursive: true, force: true });
|
||||
await rm(replacementSupersededRoot, { recursive: true, force: true });
|
||||
await rm(laterSupersessionRoot, { recursive: true, force: true });
|
||||
await rm(wrongEventRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory health reports dormancy and retention monitoring deprecations", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
|
||||
try {
|
||||
@@ -527,14 +242,15 @@ test("memory health reports dormancy and retention monitoring deprecations", asy
|
||||
}));
|
||||
await writeWorkspaceStore(root, entries, { lastActivityAt });
|
||||
|
||||
const stdout = await runMemoryDiagStatusVerbose(root);
|
||||
const stdout = await runMemoryDiagHealth(root);
|
||||
|
||||
assert.match(stdout, /Dormancy:/);
|
||||
assert.match(stdout, /wall days since activity: 19\.0/);
|
||||
assert.match(stdout, /dormant discount active: yes/);
|
||||
assert.match(stdout, /dormant days past grace: 5\.0/);
|
||||
assert.match(stdout, /Top rendered candidates:/);
|
||||
assert.match(stdout, /Weakest active memories:/);
|
||||
assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
|
||||
assert.match(stdout, /safety_critical_count: 6 .*deprecated.* WARNING/);
|
||||
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -551,9 +267,9 @@ test("memory health reports global cap overflow separately from type caps", asyn
|
||||
];
|
||||
await writeWorkspaceStore(root, entries);
|
||||
|
||||
const stdout = await runMemoryDiagStatusVerbose(root);
|
||||
const stdout = await runMemoryDiagHealth(root);
|
||||
|
||||
assert.match(stdout, /rendered: 28/);
|
||||
assert.match(stdout, /Rendered candidates: 28/);
|
||||
assert.match(stdout, /type-capped entries: 0/);
|
||||
assert.match(stdout, /global-cap overflow: 6/);
|
||||
} finally {
|
||||
@@ -566,13 +282,14 @@ test("memory health reports missing dormancy and non-alert monitoring defaults",
|
||||
try {
|
||||
await writeWorkspaceStore(root, [], { omitLastActivityAt: true });
|
||||
|
||||
const stdout = await runMemoryDiagStatusVerbose(root);
|
||||
const stdout = await runMemoryDiagHealth(root);
|
||||
|
||||
assert.match(stdout, /lastActivityAt: \(missing\)/);
|
||||
assert.match(stdout, /wall days since activity: unknown/);
|
||||
assert.match(stdout, /dormant discount active: no/);
|
||||
assert.match(stdout, /Top rendered candidates:\n\s+\(none\)/);
|
||||
assert.match(stdout, /Weakest active memories:\n\s+\(none\)/);
|
||||
assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
|
||||
assert.match(stdout, /safety_critical_count: 0 \(deprecated field\)\n/);
|
||||
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -594,8 +311,8 @@ test("memory health --json prints parseable privacy-safe diagnostics matching hu
|
||||
evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["within_caps", "within_char_budget"] }),
|
||||
]);
|
||||
|
||||
const human = await runMemoryDiagStatusVerbose(root);
|
||||
const jsonText = await runMemoryDiag(["status", "--workspace", root, "--json"]);
|
||||
const human = await runMemoryDiagHealth(root);
|
||||
const jsonText = await runMemoryDiag(["health", "--workspace", root, "--json"]);
|
||||
const parsed = JSON.parse(jsonText) as {
|
||||
version: 1;
|
||||
summary: { storedActive: number; rendered: number; pending: number; rejectedLast7Days: number; corruptStoresQuarantinedLast30Days: number };
|
||||
@@ -604,9 +321,9 @@ test("memory health --json prints parseable privacy-safe diagnostics matching hu
|
||||
};
|
||||
|
||||
assert.equal(parsed.version, 1);
|
||||
assert.equal(parsed.summary.storedActive, Number(human.match(/active: (\d+) \/ 28/)?.[1]));
|
||||
assert.equal(parsed.summary.rendered, Number(human.match(/rendered: (\d+)/)?.[1]));
|
||||
assert.equal(parsed.summary.pending, 1);
|
||||
assert.equal(parsed.summary.storedActive, Number(human.match(/Stored active memories: (\d+)/)?.[1]));
|
||||
assert.equal(parsed.summary.rendered, Number(human.match(/Rendered candidates: (\d+)/)?.[1]));
|
||||
assert.equal(parsed.summary.pending, Number(human.match(/Pending journal:\n\s+total: (\d+)/)?.[1]));
|
||||
assert.equal(parsed.summary.rejectedLast7Days, 1);
|
||||
assert.equal(parsed.summary.corruptStoresQuarantinedLast30Days, 1);
|
||||
assert.ok(parsed.recentEvents.some(event => event.eventId && event.type === "render_selected" && event.outcome === "rendered" && event.createdAt && event.reasonCodes.includes("within_caps")));
|
||||
@@ -666,7 +383,7 @@ test("memory-diag explain shows rendered, omitted, pending, and evidence reason
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag explain --memory prints lifecycle relations and redacts secrets", async () => {
|
||||
test("memory-diag trace prints lifecycle relations and redacts secrets", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-"));
|
||||
try {
|
||||
await writeWorkspaceStore(root, [
|
||||
@@ -682,7 +399,7 @@ test("memory-diag explain --memory prints lifecycle relations and redacts secret
|
||||
evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "omitted", memory: { memoryId: "mem-life" } }], reasonCodes: ["superseded"] }),
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["explain", "--workspace", root, "--memory", "mem-life"]);
|
||||
const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "mem-life"]);
|
||||
|
||||
assert.match(stdout, /Memory mem-life: omitted_superseded/);
|
||||
assert.match(stdout, /Lifecycle:/);
|
||||
@@ -722,10 +439,21 @@ test("memory-diag explain positional memory id prints lifecycle", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("memory-diag explain --memory reports unknown IDs", async () => {
|
||||
test("memory-diag trace requires --memory and reports unknown IDs", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-"));
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["explain", "--workspace", root, "--memory", "missing-memory"]);
|
||||
await assert.rejects(
|
||||
execFileAsync(process.execPath, ["--experimental-strip-types", "scripts/memory-diag.ts", "trace", "--workspace", root], { cwd: repoRoot }),
|
||||
(error: unknown) => {
|
||||
const err = error as { code?: number; stderr?: string };
|
||||
assert.notEqual(err.code, 0);
|
||||
assert.match(err.stderr ?? "", /--memory requires an id/);
|
||||
assert.match(err.stderr ?? "", /Usage:/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "missing-memory"]);
|
||||
assert.match(stdout, /Memory missing-memory: unknown/);
|
||||
assert.match(stdout, /Lifecycle:\n\(none\)/);
|
||||
} finally {
|
||||
@@ -753,15 +481,15 @@ async function setupQualityFixture(): Promise<string> {
|
||||
return root;
|
||||
}
|
||||
|
||||
test("status verbose output includes summary and aggregate inspection counts", async () => {
|
||||
test("quality human output includes summary and aggregate inspection counts", async () => {
|
||||
const root = await setupQualityFixture();
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
|
||||
const stdout = await runMemoryDiag(["quality", "--workspace", root]);
|
||||
|
||||
assert.match(stdout, /Memory status inspection/);
|
||||
assert.match(stdout, /Memory quality inspection/);
|
||||
assert.match(stdout, /Summary: Workspace memory quality is degraded:/);
|
||||
assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/);
|
||||
assert.match(stdout, /decision: 14 \/ 12 FULL/);
|
||||
assert.match(stdout, /decision: 14 \/ 10 FULL/);
|
||||
assert.match(stdout, /feedback: 14 \/ 10 FULL/);
|
||||
assert.match(stdout, /Retention clocks:\n\s+present: 27\n\s+missing: 0\n\s+invalid: 1/);
|
||||
assert.match(stdout, /Evidence:\n\s+current with evidence: 1\n\s+current without evidence: 27/);
|
||||
@@ -771,18 +499,26 @@ test("status verbose output includes summary and aggregate inspection counts", a
|
||||
}
|
||||
});
|
||||
|
||||
test("status --json includes summary status fields", async () => {
|
||||
test("quality --json includes summaryText, caps, retention, evidence, and rejections", async () => {
|
||||
const root = await setupQualityFixture();
|
||||
try {
|
||||
const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]);
|
||||
const stdout = await runMemoryDiag(["quality", "--workspace", root, "--json"]);
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
summary: { storedActive: number; status: string; evidenceCoveragePercent: number; needsAttention: string[] };
|
||||
summaryText: string;
|
||||
caps: { active: number; capsFull: boolean };
|
||||
retention: { invalid: number };
|
||||
evidence: { currentWithEvidence: number; unknownDisappearances: number };
|
||||
rejections: { workspaceScopedCount: number; legacyUnscopedCount: number };
|
||||
};
|
||||
|
||||
assert.equal(parsed.summary.storedActive, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(parsed.summary.status, "degraded");
|
||||
assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number");
|
||||
assert.ok(parsed.summary.needsAttention.length > 0);
|
||||
assert.match(parsed.summaryText, /Workspace memory quality is degraded/);
|
||||
assert.equal(parsed.caps.active, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(parsed.caps.capsFull, true);
|
||||
assert.equal(parsed.retention.invalid, 1);
|
||||
assert.equal(parsed.evidence.currentWithEvidence, 1);
|
||||
assert.equal(parsed.evidence.unknownDisappearances, 1);
|
||||
assert.equal(parsed.rejections.workspaceScopedCount, 1);
|
||||
assert.equal(parsed.rejections.legacyUnscopedCount, 1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -996,6 +732,25 @@ test("missing --json includes disappearances and additive summary", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy disappearances --explain emits deprecation notice and detailed output", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-legacy-"));
|
||||
try {
|
||||
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
||||
await appendEvidenceEvents(root, [
|
||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
||||
]);
|
||||
|
||||
const result = await runMemoryDiagResult(["disappearances", "--workspace", root, "--explain"]);
|
||||
|
||||
assert.match(result.stderr, /Note: 'disappearances' is now 'missing'\. This alias will be removed in v2\.0\./);
|
||||
assert.match(result.stdout, /^Memory disappearances/);
|
||||
assert.match(result.stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/);
|
||||
assert.match(result.stdout, /events:/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejected default output is concise with top reasons and samples", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-"));
|
||||
try {
|
||||
@@ -1067,3 +822,56 @@ test("rejected --json includes quality summary and false-positive risk", async (
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejections --quality --reason bad_decision --unique groups architecture-like samples heuristically", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-quality-"));
|
||||
try {
|
||||
const key = await workspaceKey(root);
|
||||
const now = new Date().toISOString();
|
||||
await writeRejectionRecords([
|
||||
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] },
|
||||
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] },
|
||||
{ timestamp: now, type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] },
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--reason", "bad_decision", "--unique"]);
|
||||
|
||||
assert.match(stdout, /Extraction rejection quality inspection/);
|
||||
assert.match(stdout, /Possible false-positive grouping is heuristic, not deterministic truth/);
|
||||
assert.match(stdout, /architecture_like_possible_false_positive: 1/);
|
||||
assert.match(stdout, /clearly_garbage: 1/);
|
||||
assert.match(stdout, /ambiguous: 1/);
|
||||
assert.doesNotMatch(stdout, /deterministic truth\s*:/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejections --quality --json includes scoping, unique reasons, and possible false-positive groups", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-json-"));
|
||||
try {
|
||||
const key = await workspaceKey(root);
|
||||
const now = new Date().toISOString();
|
||||
await writeRejectionRecords([
|
||||
{ timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
|
||||
{ timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] },
|
||||
]);
|
||||
|
||||
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--json"]);
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
workspaceScopedCount: number;
|
||||
legacyUnscopedCount: number;
|
||||
falsePositiveRisk: string;
|
||||
uniqueReasonDistribution: Record<string, number>;
|
||||
possibleFalsePositiveGroups: Record<string, { count: number }>;
|
||||
};
|
||||
|
||||
assert.equal(parsed.workspaceScopedCount, 1);
|
||||
assert.equal(parsed.legacyUnscopedCount, 1);
|
||||
assert.equal(parsed.falsePositiveRisk, "high");
|
||||
assert.equal(parsed.uniqueReasonDistribution.bad_decision, 1);
|
||||
assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,18 +35,6 @@ const acceptedCases = [
|
||||
expectedType: "reference",
|
||||
expectedText: /bracketless/,
|
||||
},
|
||||
{
|
||||
name: "stable URL query reference",
|
||||
line: "- [reference] Memory diagnostics dashboard URL is https://example.test/search?q=memory&view=summary",
|
||||
expectedType: "reference",
|
||||
expectedText: /search\?q=memory/,
|
||||
},
|
||||
{
|
||||
name: "bilingual stable rule",
|
||||
line: "- [decision] 使用 durable evidence records 保持 memory command auditability",
|
||||
expectedType: "decision",
|
||||
expectedText: /保持 memory command auditability/,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const rejectedCases = [
|
||||
@@ -90,34 +78,6 @@ const rejectedCases = [
|
||||
name: "session internal review note",
|
||||
line: "- [feedback] The assistant reviewed the code reviewer feedback and updated the plan",
|
||||
},
|
||||
{
|
||||
name: "unresolved question suffix",
|
||||
line: "- [project] Should we add semantic merge to workspace memory?",
|
||||
},
|
||||
{
|
||||
name: "unresolved question prefix",
|
||||
line: "- [reference] TODO: decide whether to keep this migration path",
|
||||
},
|
||||
{
|
||||
name: "unresolved Chinese question",
|
||||
line: "- [decision] 需要決定是否要增加新的記憶壓縮策略",
|
||||
},
|
||||
{
|
||||
name: "transient bug state",
|
||||
line: "- [project] Tests are failing and the next step is to fix the retry path",
|
||||
},
|
||||
{
|
||||
name: "Chinese transient bug state",
|
||||
line: "- [reference] 目前正在 debug storage lock failure,暫時 workaround 待修",
|
||||
},
|
||||
{
|
||||
name: "deployment snapshot",
|
||||
line: "- [reference] Latest deployed revision is rev-a8F3kL9pQ2xZ7bN4",
|
||||
},
|
||||
{
|
||||
name: "Chinese deployment snapshot",
|
||||
line: "- [project] 目前部署版本 build-9f8A7c6D5e4F3g2H 是 active release",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const item of acceptedCases) {
|
||||
@@ -165,57 +125,6 @@ test("progress snapshot rejection is type independent", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("new v1.6 hard quality reasons are emitted by concrete heuristics", () => {
|
||||
const cases = [
|
||||
{
|
||||
reason: "unresolved_question",
|
||||
entry: { type: "reference" as const, text: "Open question: whether to keep legacy prompt rendering", source: "compaction" as const },
|
||||
},
|
||||
{
|
||||
reason: "unresolved_question",
|
||||
entry: { type: "project" as const, text: "We need to decide storage migration order?", source: "compaction" as const },
|
||||
},
|
||||
{
|
||||
reason: "transient_bug_state",
|
||||
entry: { type: "project" as const, text: "Currently debugging memory replacement and tests are failing", source: "compaction" as const },
|
||||
},
|
||||
{
|
||||
reason: "deployment_snapshot",
|
||||
entry: { type: "reference" as const, text: "Current active release build is build-X9kLmN42pQ7rT6z", source: "compaction" as const },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { reason, entry } of cases) {
|
||||
const result = assessMemoryQuality(entry);
|
||||
assert.equal(result.accepted, false, `${entry.text} should reject`);
|
||||
assert.ok(result.reasons.includes(reason), `${entry.text} -> ${result.reasons.join(",")}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("unresolved question guardrails preserve stable URL queries and durable rules", () => {
|
||||
const urlResult = assessMemoryQuality({
|
||||
type: "reference",
|
||||
text: "Memory dashboard URL is https://example.test/search?q=memory&view=summary",
|
||||
source: "compaction",
|
||||
});
|
||||
assert.equal(urlResult.reasons.includes("unresolved_question"), false, urlResult.reasons.join(","));
|
||||
assert.equal(urlResult.accepted, true);
|
||||
|
||||
const durableRule = assessMemoryQuality({
|
||||
type: "decision",
|
||||
text: "Use verifier questions only when acceptance evidence is missing?",
|
||||
source: "compaction",
|
||||
});
|
||||
assert.equal(durableRule.reasons.includes("unresolved_question"), false, durableRule.reasons.join(","));
|
||||
});
|
||||
|
||||
test("terse_label is diagnostic only and does not block quality acceptance", () => {
|
||||
const result = assessMemoryQuality({ type: "reference", text: "Cache key", source: "compaction" });
|
||||
assert.equal(result.accepted, true);
|
||||
assert.deepEqual(result.reasons, []);
|
||||
assert.ok(result.diagnostics?.includes("terse_label"));
|
||||
});
|
||||
|
||||
test("feedback must be stable user preference or instruction", () => {
|
||||
assert.equal(assessMemoryQuality({ type: "feedback", text: "User prefers concise architecture reviews", source: "compaction" }).accepted, true);
|
||||
assert.equal(assessMemoryQuality({ type: "feedback", text: "Implemented owner-aware cleanup in plugin.ts", source: "compaction" }).accepted, false);
|
||||
@@ -287,11 +196,7 @@ test("hard quality reasons exclude soft whitelist failures", () => {
|
||||
assert.equal(isHardQualityReason("code_or_api_signature"), true);
|
||||
assert.equal(isHardQualityReason("path_heavy"), true);
|
||||
assert.equal(isHardQualityReason("empty"), true);
|
||||
assert.equal(isHardQualityReason("unresolved_question"), true);
|
||||
assert.equal(isHardQualityReason("transient_bug_state"), true);
|
||||
assert.equal(isHardQualityReason("deployment_snapshot"), true);
|
||||
|
||||
assert.equal(isHardQualityReason("bad_feedback"), false);
|
||||
assert.equal(isHardQualityReason("bad_decision"), false);
|
||||
assert.equal(isHardQualityReason("terse_label"), false);
|
||||
});
|
||||
|
||||
+16
-994
File diff suppressed because it is too large
Load Diff
@@ -1,363 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { MemoryV2Plugin } from "../src/plugin.ts";
|
||||
import * as sessionStateModule from "../src/session-state.ts";
|
||||
import type { HotSessionStateRenderAccounting } from "../src/session-state.ts";
|
||||
import { sessionStatePath, workspaceMemoryPath } from "../src/paths.ts";
|
||||
import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "../src/types.ts";
|
||||
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
|
||||
|
||||
type AccountHotSessionStateRender = (state: SessionState, workspaceRoot: string) => HotSessionStateRenderAccounting;
|
||||
|
||||
const accountHotSessionStateRender = (
|
||||
sessionStateModule as typeof sessionStateModule & { accountHotSessionStateRender: AccountHotSessionStateRender }
|
||||
).accountHotSessionStateRender;
|
||||
|
||||
const { createEmptySessionState, loadSessionState, renderHotSessionState, saveSessionState } = sessionStateModule;
|
||||
|
||||
const root = "/repo";
|
||||
|
||||
function state(overrides: Partial<SessionState> = {}): SessionState {
|
||||
return {
|
||||
version: 1,
|
||||
sessionID: "session-state-test",
|
||||
turn: 0,
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
activeFiles: [],
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [],
|
||||
compactionMemoryRefs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function compactionRef(index: number, overrides: Partial<CompactionMemoryRef> = {}): CompactionMemoryRef {
|
||||
return {
|
||||
ref: `M${index}`,
|
||||
memoryId: `memory-${index}`,
|
||||
type: "decision",
|
||||
source: "compaction",
|
||||
exactKey: `decision:durable fact ${index}`,
|
||||
identityKey: `decision:durable fact ${index}`,
|
||||
textPreview: `Durable fact ${index}`,
|
||||
capturedAt: 1_777_000_000_000 + index,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockRootClient(summary = "") {
|
||||
return {
|
||||
session: {
|
||||
get: async () => ({ data: { parentID: null } }),
|
||||
messages: async () => ({
|
||||
data: summary
|
||||
? [{ info: { role: "assistant", summary: true }, parts: [{ type: "text", text: summary }] }]
|
||||
: [],
|
||||
}),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function activeFile(path: string, action: ActiveFile["action"], count: number, lastSeen: number): ActiveFile {
|
||||
return { path, action, count, lastSeen };
|
||||
}
|
||||
|
||||
function openError(id: string, summary: string, lastSeen: number): OpenError {
|
||||
return {
|
||||
id,
|
||||
category: "test",
|
||||
summary,
|
||||
fingerprint: `fingerprint-${id}`,
|
||||
status: "open",
|
||||
firstSeen: lastSeen - 1,
|
||||
lastSeen,
|
||||
seenCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function decision(id: string, text: string, createdAt: number): SessionDecision {
|
||||
return { id, text, source: "assistant", createdAt };
|
||||
}
|
||||
|
||||
function memory(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
text,
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
test("accountHotSessionStateRender returns empty prompt and no omissions for empty state", () => {
|
||||
const accounting = accountHotSessionStateRender(createEmptySessionState("empty-session"), root);
|
||||
|
||||
assert.equal(accounting.prompt, "");
|
||||
assert.deepEqual(accounting.omitted, []);
|
||||
assert.equal(accounting.maxRenderedChars, HOT_STATE_LIMITS.maxRenderedChars);
|
||||
});
|
||||
|
||||
test("accountHotSessionStateRender renders hot-state sections in stable order", () => {
|
||||
const accounting = accountHotSessionStateRender(state({
|
||||
activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)],
|
||||
openErrors: [openError("err-1", "one failing test", 2)],
|
||||
recentDecisions: [decision("dec-1", "Keep renderer simple", 3)],
|
||||
pendingMemories: [memory("mem-1", "Promote useful fact")],
|
||||
}), root);
|
||||
|
||||
assert.ok(accounting.prompt.startsWith("Hot session state (current session):"));
|
||||
assert.ok(accounting.prompt.indexOf("active_files:") < accounting.prompt.indexOf("open_errors:"));
|
||||
assert.ok(accounting.prompt.indexOf("open_errors:") < accounting.prompt.indexOf("recent_decisions:"));
|
||||
assert.ok(accounting.prompt.indexOf("recent_decisions:") < accounting.prompt.indexOf("pending_memories:"));
|
||||
});
|
||||
|
||||
test("accountHotSessionStateRender ranks active files by score then lastSeen descending", () => {
|
||||
const accounting = accountHotSessionStateRender(state({
|
||||
activeFiles: [
|
||||
activeFile("/repo/src/edit.ts", "edit", 1, 400),
|
||||
activeFile("/repo/src/write.ts", "write", 5, 200),
|
||||
activeFile("/repo/src/grep.ts", "grep", 10, 300),
|
||||
activeFile("/repo/src/read.ts", "read", 12, 100),
|
||||
],
|
||||
}), root);
|
||||
|
||||
const lines = accounting.prompt.split("\n");
|
||||
const activeLines = lines.filter(line => line.startsWith("- src/"));
|
||||
assert.deepEqual(activeLines, [
|
||||
"- src/grep.ts (grep, 10x)",
|
||||
"- src/write.ts (write, 5x)",
|
||||
"- src/read.ts (read, 12x)",
|
||||
"- src/edit.ts (edit, 1x)",
|
||||
]);
|
||||
});
|
||||
|
||||
test("accountHotSessionStateRender reports section-cap omissions for every capped section", () => {
|
||||
const accounting = accountHotSessionStateRender(state({
|
||||
activeFiles: Array.from({ length: 9 }, (_, index) => activeFile(`/repo/a${index}.ts`, "read", 1, 100 - index)),
|
||||
openErrors: Array.from({ length: 4 }, (_, index) => openError(`err-${index}`, `e${index}`, 100 - index)),
|
||||
recentDecisions: Array.from({ length: 9 }, (_, index) => decision(`dec-${index}`, `d${index}`, index)),
|
||||
pendingMemories: Array.from({ length: 7 }, (_, index) => memory(`mem-${index}`, `m${index}`)),
|
||||
}), root);
|
||||
|
||||
const sectionCapOmissions = accounting.omitted.filter(item => item.reason === "section_cap");
|
||||
assert.deepEqual(
|
||||
sectionCapOmissions.map(item => item.section).sort(),
|
||||
["active_files", "open_errors", "pending_memories", "recent_decisions"].sort(),
|
||||
);
|
||||
assert.equal(sectionCapOmissions.find(item => item.section === "pending_memories")?.memoryId, "mem-0");
|
||||
});
|
||||
|
||||
test("accountHotSessionStateRender omits over-budget entries without cutting rendered lines", () => {
|
||||
const longPath = `/repo/${"x".repeat(650)}.ts`;
|
||||
const accounting = accountHotSessionStateRender(state({
|
||||
activeFiles: [
|
||||
activeFile("/repo/src/short.ts", "read", 1, 20),
|
||||
activeFile(longPath, "read", 1, 10),
|
||||
],
|
||||
}), root);
|
||||
|
||||
assert.equal(accounting.prompt, [
|
||||
"Hot session state (current session):",
|
||||
"active_files:",
|
||||
"- src/short.ts (read, 1x)",
|
||||
].join("\n"));
|
||||
assert.equal(accounting.omitted.length, 1);
|
||||
assert.equal(accounting.omitted[0]?.reason, "char_budget");
|
||||
assert.equal(accounting.omitted[0]?.section, "active_files");
|
||||
assert.ok(!accounting.prompt.includes("x".repeat(20)));
|
||||
});
|
||||
|
||||
test("accountHotSessionStateRender includes exact 700-char prompt but omits one additional character", () => {
|
||||
const fixedPrompt = [
|
||||
"Hot session state (current session):",
|
||||
"pending_memories:",
|
||||
"- [decision] ",
|
||||
].join("\n");
|
||||
const exactText = "x".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length);
|
||||
const exactAccounting = accountHotSessionStateRender(state({
|
||||
pendingMemories: [memory("mem-exact", exactText)],
|
||||
}), root);
|
||||
|
||||
assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars);
|
||||
assert.equal(exactAccounting.omitted.length, 0);
|
||||
|
||||
const overAccounting = accountHotSessionStateRender(state({
|
||||
pendingMemories: [memory("mem-over", `${exactText}y`)],
|
||||
}), root);
|
||||
|
||||
assert.equal(overAccounting.prompt, "");
|
||||
assert.equal(overAccounting.omitted.length, 1);
|
||||
assert.equal(overAccounting.omitted[0]?.reason, "char_budget");
|
||||
assert.equal(overAccounting.omitted[0]?.memoryId, "mem-over");
|
||||
});
|
||||
|
||||
test("accountHotSessionStateRender suppresses header-only sections when no entries fit", () => {
|
||||
const accounting = accountHotSessionStateRender(state({
|
||||
activeFiles: [activeFile(`/repo/${"z".repeat(720)}.ts`, "read", 1, 1)],
|
||||
}), root);
|
||||
|
||||
assert.equal(accounting.prompt, "");
|
||||
assert.equal(accounting.omitted.length, 1);
|
||||
assert.equal(accounting.omitted[0]?.reason, "char_budget");
|
||||
assert.ok(!accounting.prompt.includes("active_files:"));
|
||||
});
|
||||
|
||||
test("renderHotSessionState delegates to accounted renderer prompt for empty and seeded states", () => {
|
||||
const empty = createEmptySessionState("compat-empty");
|
||||
const seeded = state({
|
||||
activeFiles: [activeFile("/repo/src/a.ts", "edit", 2, 1)],
|
||||
pendingMemories: [memory("mem-compat", "Compatibility prompt")],
|
||||
});
|
||||
|
||||
assert.equal(renderHotSessionState(empty, root), accountHotSessionStateRender(empty, root).prompt);
|
||||
assert.equal(renderHotSessionState(seeded, root), accountHotSessionStateRender(seeded, root).prompt);
|
||||
});
|
||||
|
||||
test("accountHotSessionStateRender counts newline separators in the 700-char budget", () => {
|
||||
const fixedPrompt = [
|
||||
"Hot session state (current session):",
|
||||
"recent_decisions:",
|
||||
"- ",
|
||||
].join("\n");
|
||||
const exactText = "n".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length);
|
||||
|
||||
const exactAccounting = accountHotSessionStateRender(state({
|
||||
recentDecisions: [decision("dec-exact", exactText, 1)],
|
||||
}), root);
|
||||
assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars);
|
||||
assert.equal(exactAccounting.omitted.length, 0);
|
||||
|
||||
const overAccounting = accountHotSessionStateRender(state({
|
||||
recentDecisions: [decision("dec-over", `${exactText}!`, 1)],
|
||||
}), root);
|
||||
assert.equal(overAccounting.prompt, "");
|
||||
assert.equal(overAccounting.omitted.length, 1);
|
||||
assert.equal(overAccounting.omitted[0]?.reason, "char_budget");
|
||||
});
|
||||
|
||||
test("compaction memory refs round-trip through session state and are capped", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-"));
|
||||
|
||||
try {
|
||||
const refs = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, index) => compactionRef(
|
||||
index + 1,
|
||||
index === 0 ? { compactionId: "compaction-snapshot-1" } : {},
|
||||
));
|
||||
await saveSessionState(tmpDir, state({
|
||||
sessionID: "compaction-ref-roundtrip",
|
||||
compactionMemoryRefs: refs,
|
||||
}));
|
||||
|
||||
const loaded = await loadSessionState(tmpDir, "compaction-ref-roundtrip");
|
||||
assert.equal(loaded.compactionMemoryRefs.length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.deepEqual(loaded.compactionMemoryRefs, refs.slice(0, LONG_TERM_LIMITS.maxEntries));
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid stored compaction memory refs normalize to empty without crashing", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-"));
|
||||
|
||||
try {
|
||||
const path = await sessionStatePath(tmpDir, "invalid-compaction-ref-session");
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, JSON.stringify({
|
||||
version: 1,
|
||||
sessionID: "stale-id",
|
||||
turn: 0,
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
activeFiles: [],
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [],
|
||||
compactionMemoryRefs: [
|
||||
compactionRef(1),
|
||||
{ ...compactionRef(2), ref: "not-a-numbered-ref" },
|
||||
],
|
||||
}), "utf8");
|
||||
|
||||
const loaded = await loadSessionState(tmpDir, "invalid-compaction-ref-session");
|
||||
assert.equal(loaded.sessionID, "invalid-compaction-ref-session");
|
||||
assert.deepEqual(loaded.compactionMemoryRefs, []);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("compaction memory refs never render in hot session state", () => {
|
||||
const rendered = renderHotSessionState(state({
|
||||
activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)],
|
||||
compactionMemoryRefs: [compactionRef(1, {
|
||||
memoryId: "secret-memory-id",
|
||||
exactKey: "decision:secret exact key",
|
||||
identityKey: "decision:secret identity key",
|
||||
textPreview: "Secret compaction ref preview must not render",
|
||||
})],
|
||||
}), root);
|
||||
|
||||
assert.match(rendered, /active_files:/);
|
||||
assert.doesNotMatch(rendered, /Secret compaction ref preview/);
|
||||
assert.doesNotMatch(rendered, /secret-memory-id/);
|
||||
assert.doesNotMatch(rendered, /secret exact key/);
|
||||
assert.doesNotMatch(rendered, /secret identity key/);
|
||||
assert.doesNotMatch(rendered, /\bM1\b/);
|
||||
});
|
||||
|
||||
test("session.compacted clears compaction memory refs after processing", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-"));
|
||||
|
||||
try {
|
||||
await saveSessionState(tmpDir, state({
|
||||
sessionID: "clear-compaction-refs-session",
|
||||
compactionMemoryRefs: [compactionRef(1)],
|
||||
}));
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "clear-compaction-refs-session" } },
|
||||
});
|
||||
|
||||
const loaded = await loadSessionState(tmpDir, "clear-compaction-refs-session");
|
||||
assert.deepEqual(loaded.compactionMemoryRefs, []);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("session.compacted clears compaction memory refs even when promotion fails", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-"));
|
||||
|
||||
try {
|
||||
await saveSessionState(tmpDir, state({
|
||||
sessionID: "clear-compaction-refs-failure-session",
|
||||
pendingMemories: [memory("mem-pending-failure", "Keep pending memory when promotion fails")],
|
||||
compactionMemoryRefs: [compactionRef(1)],
|
||||
}));
|
||||
|
||||
const workspacePath = await workspaceMemoryPath(tmpDir);
|
||||
await rm(workspacePath, { force: true }).catch(() => undefined);
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
await (plugin as Record<string, Function>)["event"]({
|
||||
event: { type: "session.compacted", properties: { sessionID: "clear-compaction-refs-failure-session" } },
|
||||
});
|
||||
|
||||
const loaded = await loadSessionState(tmpDir, "clear-compaction-refs-failure-session");
|
||||
assert.deepEqual(loaded.compactionMemoryRefs, []);
|
||||
assert.equal(loaded.pendingMemories.length, 1,
|
||||
"unrelated retryable pending memory should remain on promotion failure");
|
||||
assert.equal(loaded.pendingMemories[0].id, "mem-pending-failure");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+11
-158
@@ -10,7 +10,6 @@ import { queryEvidenceEvents } from "../src/evidence-log.ts";
|
||||
import {
|
||||
renderWorkspaceMemory,
|
||||
accountWorkspaceMemoryRender,
|
||||
accountWorkspaceMemoryCompactionRefs,
|
||||
enforceLongTermLimits,
|
||||
dedupeLongTermEntriesWithAccounting,
|
||||
enforceLongTermLimitsWithAccounting,
|
||||
@@ -45,15 +44,6 @@ test("default prompt budgets use calibrated conservative character caps", () =>
|
||||
assert.equal(HOT_STATE_LIMITS.maxRenderedChars, 700);
|
||||
});
|
||||
|
||||
test("retention type caps use v1.6 decision headroom without changing other caps", () => {
|
||||
assert.equal(RETENTION_TYPE_MAX.feedback, 10);
|
||||
assert.equal(RETENTION_TYPE_MAX.decision, 12);
|
||||
assert.equal(RETENTION_TYPE_MAX.project, 8);
|
||||
assert.equal(RETENTION_TYPE_MAX.reference, 6);
|
||||
assert.equal(LONG_TERM_LIMITS.maxEntries, 28);
|
||||
assert.equal(LONG_TERM_LIMITS.maxRenderedChars, 3600);
|
||||
});
|
||||
|
||||
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
@@ -169,92 +159,6 @@ test("renderWorkspaceMemory returns empty for no entries", () => {
|
||||
assert.equal(rendered, "");
|
||||
});
|
||||
|
||||
test("accountWorkspaceMemoryCompactionRefs returns empty prompt and refs for no entries", () => {
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const accounting = accountWorkspaceMemoryCompactionRefs(store);
|
||||
|
||||
assert.equal(accounting.prompt, "");
|
||||
assert.deepEqual(accounting.refs, []);
|
||||
});
|
||||
|
||||
test("accountWorkspaceMemoryCompactionRefs renders numbered refs by type with snapshot keys", () => {
|
||||
const originalNow = Date.now;
|
||||
const capturedAt = Date.UTC(2026, 4, 7, 12, 0, 0);
|
||||
Date.now = () => capturedAt;
|
||||
|
||||
try {
|
||||
const createdAt = new Date(capturedAt).toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [
|
||||
{ ...entry("mem-feedback", "User requires verifier confirmation after each wave.", "feedback"), createdAt, updatedAt: createdAt, source: "explicit" },
|
||||
{ ...entry("mem-project", "This repository is an OpenCode plugin using local JSON stores.", "project"), createdAt, updatedAt: createdAt },
|
||||
{ ...entry("mem-decision", "Decision dedupe stays exact-only.", "decision"), createdAt, updatedAt: createdAt },
|
||||
{ ...entry("mem-reference", "Workspace memory is rendered as frozen system[1] during normal chat turns.", "reference"), createdAt, updatedAt: createdAt },
|
||||
{ ...entry("mem-superseded", "Superseded memory should not get a ref.", "decision"), status: "superseded" as const },
|
||||
];
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries,
|
||||
updatedAt: createdAt,
|
||||
lastActivityAt: createdAt,
|
||||
};
|
||||
|
||||
const accounting = accountWorkspaceMemoryCompactionRefs(store);
|
||||
|
||||
assert.equal(accounting.prompt, [
|
||||
"Existing workspace memories available for consolidation:",
|
||||
"feedback:",
|
||||
"[M1] User requires verifier confirmation after each wave.",
|
||||
"project:",
|
||||
"[M2] This repository is an OpenCode plugin using local JSON stores.",
|
||||
"decision:",
|
||||
"[M3] Decision dedupe stays exact-only.",
|
||||
"reference:",
|
||||
"[M4] Workspace memory is rendered as frozen system[1] during normal chat turns.",
|
||||
].join("\n"));
|
||||
assert.deepEqual(accounting.refs.map(ref => ref.ref), ["M1", "M2", "M3", "M4"]);
|
||||
assert.deepEqual(accounting.refs.map(ref => ref.memoryId), ["mem-feedback", "mem-project", "mem-decision", "mem-reference"]);
|
||||
assert.deepEqual(accounting.refs.map(ref => ref.exactKey), accounting.rendered.map(workspaceMemoryExactKey));
|
||||
assert.deepEqual(accounting.refs.map(ref => ref.identityKey), accounting.rendered.map(workspaceMemoryIdentityKey));
|
||||
assert.ok(accounting.refs.every(ref => ref.capturedAt === capturedAt));
|
||||
assert.equal(accounting.refs.some(ref => ref.memoryId === "mem-superseded"), false);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("accountWorkspaceMemoryCompactionRefs is bounded by long-term caps", () => {
|
||||
const now = new Date().toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [
|
||||
...Array.from({ length: 10 }, (_, i) => entry(`compaction-feedback-${i}`, `Compaction feedback ${i}`, "feedback")),
|
||||
...Array.from({ length: 10 }, (_, i) => entry(`compaction-decision-${i}`, `Compaction decision ${i}`, "decision")),
|
||||
...Array.from({ length: 8 }, (_, i) => entry(`compaction-project-${i}`, `Compaction project ${i}`, "project")),
|
||||
...Array.from({ length: 6 }, (_, i) => entry(`compaction-reference-${i}`, `Compaction reference ${i}`, "reference")),
|
||||
].map(memory => ({ ...memory, createdAt: now, updatedAt: now }));
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries,
|
||||
updatedAt: now,
|
||||
lastActivityAt: now,
|
||||
};
|
||||
|
||||
const accounting = accountWorkspaceMemoryCompactionRefs(store);
|
||||
|
||||
assert.equal(accounting.refs.length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(accounting.prompt.includes("[M28]"), true);
|
||||
assert.equal(accounting.prompt.includes("[M29]"), false);
|
||||
});
|
||||
|
||||
test("accountWorkspaceMemoryRender reports rendered and omitted reasons", () => {
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
@@ -949,15 +853,15 @@ test("mixed retention scenario applies caps and reinforcement ordering", () => {
|
||||
|
||||
test("type max sum above global cap still respects maxEntries", () => {
|
||||
const entries: LongTermMemoryEntry[] = [
|
||||
...Array.from({ length: RETENTION_TYPE_MAX.feedback }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference ${i}`, "feedback")),
|
||||
...Array.from({ length: RETENTION_TYPE_MAX.decision }, (_, i) => entry(`decision-${i}`, `Unique durable decision ${i}`, "decision")),
|
||||
...Array.from({ length: RETENTION_TYPE_MAX.project }, (_, i) => entry(`project-${i}`, `Unique project fact ${i}`, "project")),
|
||||
...Array.from({ length: RETENTION_TYPE_MAX.reference }, (_, i) => entry(`reference-${i}`, `Unique reference fact ${i}`, "reference")),
|
||||
...Array.from({ length: 10 }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference ${i}`, "feedback")),
|
||||
...Array.from({ length: 10 }, (_, i) => entry(`decision-${i}`, `Unique durable decision ${i}`, "decision")),
|
||||
...Array.from({ length: 8 }, (_, i) => entry(`project-${i}`, `Unique project fact ${i}`, "project")),
|
||||
...Array.from({ length: 6 }, (_, i) => entry(`reference-${i}`, `Unique reference fact ${i}`, "reference")),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
|
||||
assert.equal(entries.length, Object.values(RETENTION_TYPE_MAX).reduce((sum, count) => sum + count, 0));
|
||||
assert.equal(entries.length, 34);
|
||||
assert.equal(kept.length, LONG_TERM_LIMITS.maxEntries);
|
||||
});
|
||||
|
||||
@@ -2605,7 +2509,7 @@ function decisionEntry(id: string, text: string, timestampMs: number): LongTermM
|
||||
};
|
||||
}
|
||||
|
||||
test("enforceLongTermLimitsWithAccounting keeps 11th and 12th decisions and type-caps the 13th", () => {
|
||||
test("enforceLongTermLimitsWithAccounting capacity drops return 3 lower-ranked decisions in dropped", () => {
|
||||
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
|
||||
const thirtyDaysAgo = now - 30 * DAY_MS;
|
||||
const existingDecisions = Array.from({ length: 10 }, (_, i) =>
|
||||
@@ -2628,12 +2532,8 @@ test("enforceLongTermLimitsWithAccounting keeps 11th and 12th decisions and type
|
||||
const droppedIds = new Set(capacityDrops.map(event => event.memory.id));
|
||||
const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity");
|
||||
|
||||
assert.equal(result.kept.filter(memory => memory.type === "decision").length, RETENTION_TYPE_MAX.decision);
|
||||
assert.equal(result.kept.some(memory => memory.id === "new-decision-0"), true);
|
||||
assert.equal(result.kept.some(memory => memory.id === "new-decision-1"), true);
|
||||
assert.equal(result.kept.some(memory => memory.id === "new-decision-2"), true);
|
||||
assert.equal(capacityDrops.length, 1);
|
||||
assert.equal(capacityEvidence.length, 1);
|
||||
assert.equal(capacityDrops.length, 3);
|
||||
assert.equal(capacityEvidence.length, 3);
|
||||
for (const event of capacityEvidence) {
|
||||
assert.equal(event.phase, "storage");
|
||||
assert.equal(event.outcome, "removed");
|
||||
@@ -2668,20 +2568,20 @@ test("enforceLongTermLimitsWithAccounting emits global_cap evidence for global c
|
||||
const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity");
|
||||
const globalCapEvidence = capacityEvidence.filter(event => event.reasonCodes.includes("global_cap"));
|
||||
|
||||
assert.equal(entries.length, Object.values(RETENTION_TYPE_MAX).reduce((sum, count) => sum + count, 0));
|
||||
assert.equal(entries.length, 34);
|
||||
assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(globalCapEvidence.length, entries.length - LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(capacityEvidence.some(event => event.reasonCodes.includes("type_cap")), false);
|
||||
assert.ok(globalCapEvidence.every(event => event.phase === "storage" && event.outcome === "removed"));
|
||||
});
|
||||
|
||||
test("accountWorkspaceMemoryRender emits render_omitted for type_cap with 13 decisions", () => {
|
||||
test("accountWorkspaceMemoryRender emits render_omitted for type_cap with 11 decisions", () => {
|
||||
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries: Array.from({ length: 13 }, (_, i) =>
|
||||
entries: Array.from({ length: 11 }, (_, i) =>
|
||||
decisionEntry(`render-decision-${i}`, `Render durable decision ${i}`, now)
|
||||
),
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
@@ -2698,50 +2598,3 @@ test("accountWorkspaceMemoryRender emits render_omitted for type_cap with 13 dec
|
||||
assert.equal(typeCapOmissions.length, 1);
|
||||
assert.equal(typeCapEvidence.length, 1);
|
||||
});
|
||||
|
||||
test("accountWorkspaceMemoryRender records char-budget omissions when 12 decisions crowd other types", () => {
|
||||
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
|
||||
const longText = (prefix: string, i: number) => `${prefix} ${i}: ${"durable architecture context ".repeat(8)}`;
|
||||
const entries: LongTermMemoryEntry[] = [
|
||||
...Array.from({ length: 2 }, (_, i) => ({
|
||||
...decisionEntry(`crowd-feedback-${i}`, longText("User feedback preference", i), now),
|
||||
type: "feedback" as const,
|
||||
source: "explicit" as const,
|
||||
})),
|
||||
...Array.from({ length: 2 }, (_, i) => ({
|
||||
...decisionEntry(`crowd-project-${i}`, longText("Project fact", i), now),
|
||||
type: "project" as const,
|
||||
})),
|
||||
...Array.from({ length: RETENTION_TYPE_MAX.decision }, (_, i) =>
|
||||
decisionEntry(`crowd-decision-${i}`, longText("Decision rule", i), now)
|
||||
),
|
||||
...Array.from({ length: 2 }, (_, i) => ({
|
||||
...decisionEntry(`crowd-reference-${i}`, longText("Reference fact", i), now),
|
||||
type: "reference" as const,
|
||||
})),
|
||||
];
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries,
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
lastActivityAt: new Date(now).toISOString(),
|
||||
};
|
||||
|
||||
const accounting = accountWorkspaceMemoryRender(store);
|
||||
const charBudgetOmissions = accounting.omitted.filter(item => item.reason === "char_budget");
|
||||
const charBudgetEvidence = accounting.evidence.filter(event =>
|
||||
event.type === "render_omitted" && event.reasonCodes.includes("char_budget")
|
||||
);
|
||||
const omittedIds = new Set(charBudgetOmissions.map(item => item.memory.id));
|
||||
const evidenceIds = new Set(charBudgetEvidence.map(event => event.memory?.memoryId).filter(Boolean));
|
||||
|
||||
assert.ok(accounting.prompt.length <= LONG_TERM_LIMITS.maxRenderedChars);
|
||||
assert.equal(accounting.omitted.some(item => item.reason === "type_cap"), false);
|
||||
assert.equal(accounting.omitted.some(item => item.reason === "global_cap"), false);
|
||||
assert.ok(charBudgetOmissions.length > 0, "crowded render should omit by char budget");
|
||||
assert.ok(charBudgetOmissions.some(item => item.memory.type === "reference"), "later non-decision types should be accounted if crowded out");
|
||||
assert.equal(charBudgetEvidence.length, charBudgetOmissions.length);
|
||||
assert.deepEqual(evidenceIds, omittedIds);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user