Compare commits

...

13 Commits

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

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

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

Closes docs/plans/2026-05-11-memory-diag-quality-review-board.md
2026-05-12 14:13:03 +08:00
Ralph Chang e4dfe81d89 fix: package memory-diag compiled runtime 2026-05-11 15:43:56 +08:00
Ralph Chang 9b6955f490 docs(readme): remove roadmap section 2026-05-08 22:13:40 +08:00
Ralph Chang e708e77e61 docs(release): prepare v1.6.1 2026-05-08 22:09:08 +08:00
Ralph Chang 9114b57dc1 feat(tui): consolidate memory dialog menu 2026-05-08 21:41:57 +08:00
Ralph Chang 2ff17ea1b3 fix(tui): keep memory commands out of suggestions 2026-05-08 20:55:03 +08:00
Ralph Chang 65b3b2f2c3 fix(tui): clarify memory command surface 2026-05-08 19:49:56 +08:00
Ralph Chang 49bf866de2 feat(tui): add native memory visibility commands 2026-05-08 19:26:17 +08:00
35 changed files with 7153 additions and 209 deletions
+65
View File
@@ -5,6 +5,71 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6.4] - 2026-05-15
### Changed
- Replaced same-session reinforcement blocking with a rolling 7-day elapsed reinforcement window so long-lived OpenCode sessions can reinforce durable memories after meaningful weekly recurrence.
- Kept the 45-day base half-life while changing the max reinforcement count to a growth saturation point: memories at count 6 can refresh retention timestamps weekly without increasing count or effective half-life.
- Bumped memory evidence instrumentation to version 3 for the new elapsed-window and refresh-only reinforcement semantics.
- Updated `memory-diag commands --memory` to show elapsed-window details, `sameSession` evidence, `reinforcementMode`, and legacy missing timestamp markers without exposing raw session IDs.
- Updated `memory-diag quality` to keep historical `same_session` block accounting while preventing new `sameSession` evidence from triggering old same-session diagnostic questions.
### Fixed
- Prevented long-lived sessions from indefinitely blocking reinforcement solely because the session ID stayed the same across days.
- Prevented saturated memories from growing stronger beyond the max reinforcement count while still allowing continued weekly use to keep them fresh.
- Preserved historical block reason compatibility for `same_session`, `same_utc_day`, `min_interval`, and `max_count` without producing those reasons from the new policy path.
## [1.6.3] - 2026-05-14
### Added
- Added `memory-diag quality`, a read-only review board for memory-system mechanism evidence, answerability levels, provenance classification, active-memory review surfaces, and JSON review output.
- Added producer/version-aware diagnostic facts so current instrumentation can be separated from historical or unversioned evidence when reviewing reinforcement, rejection, and eviction patterns.
- Added `memory-diag commands --memory <memory-id>` for focused reinforcement command detail, including current memory status, recorded block reasons, missing block detail counts, UTC-day evidence, and privacy-safe JSON.
### Changed
- Made `activeMemoryDisplay` the canonical active-memory review surface in `memory-diag quality` JSON and removed duplicate active-memory `reviewCandidates` entries.
- Clarified diagnostic provenance and answerability wording so `memory-diag quality` separates facts, heuristic flags, review questions, and human judgment requirements.
### Fixed
- Removed duplicate active-memory candidate construction from `memory-diag quality` to prevent drift between human output and JSON surfaces.
- Kept reinforcement detail diagnostics evidence-only so blocked reinforcement attempts are shown as recorded evidence without claiming policy failure or memory loss.
## [1.6.2] - 2026-05-11
### Fixed
- Fixed the published `memory-diag` npm bin by compiling the diagnostics CLI before packing and launching the compiled JavaScript runtime instead of type-stripping TypeScript under `node_modules`.
### Added
- Added a pack/npx smoke test that runs `memory-diag --help` from a packed tarball outside the repository.
## [1.6.1] - 2026-05-08
### Added
- Native OpenCode TUI `/memory` submenu for local memory statistics, searchable current workspace memory refs, and help.
- Package `./tui` export for OpenCode TUI plugin loading.
### Changed
- README documents separate server and TUI plugin configuration.
- Recent activity/last TUI commands were removed before release because duplicate-looking slash menu entries were not useful.
- Pre-release hyphenated TUI commands were consolidated into `/memory` because native submenu/list dialogs provide better bounded navigation with less slash-menu clutter.
### Fixed
- Replaced a literal NUL byte in `workspace-memory.ts` regex source with a `\0` escape so source search tools treat the file as text.
### Notes / Known UX
- TUI memory command output opens in transcript-free native TUI dialogs and does not call the LLM.
## [1.6.0] - 2026-05-08
### Added
+81 -100
View File
@@ -23,28 +23,73 @@ Use it when you want your agent to remember things like:
- Important file paths or references
- Current active files and unresolved errors
## Features
## What You Get
- **Workspace memory** — durable project facts, preferences, decisions, and references across sessions.
- **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 OpenCodes 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.
| Need | Feature |
|---|---|
| Remember durable context | Workspace memory keeps project facts, preferences, decisions, and references across sessions. |
| Capture what matters | Say `remember this` or `記住` to explicitly save important rules and preferences. |
| Inspect memory locally | Use `/memory` in the OpenCode TUI to browse status, help, and searchable current `[M#]` memories. |
| Stay out of the way | Memory is injected automatically and piggybacks on OpenCode compaction — no manual tools, no extra LLM/API calls. |
| Keep memory clean | Quality guards filter noise, redact credentials, dedupe repeats, and let weak memories fade. |
```text
remember this ──► workspace memory ──► /memory
▲ │ searchable [M#] refs
│ ▼
compaction ─────► reinforce / replace ──► selective prompt context
```
## Installation
Add OpenCode Working Memory to your OpenCode config:
New users: add OpenCode Working Memory to both OpenCode plugin configs.
`.opencode/opencode.json`:
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-working-memory"]
}
```
Then restart OpenCode. It activates automatically.
`.opencode/tui.json`:
```json
{
"$schema": "https://opencode.ai/tui.json",
"plugin": ["opencode-working-memory"]
}
```
Existing users: keep your current `.opencode/opencode.json` config and add only the `.opencode/tui.json` block above to enable the native `/memory` TUI menu.
Then restart OpenCode. Memory activates automatically, and `/memory` appears in the TUI slash command menu.
## Native TUI Memory Menu
The TUI plugin adds one display-only local memory command:
- `/memory` — open a native memory submenu.
Submenu entries:
- Status — show status counts for workspace memory, rendered memories, pending memory, open errors, and recent decisions.
- Current memories — browse a searchable grouped list of current active workspace memories with display-local `[M1]` refs.
- Help — show command help.
This menu is read-only and local-only. It reads local memory files and opens native TUI dialogs, so it does not create conversation history entries and does not make an LLM/API call.
```text
/memory
├─ Status
├─ Current memories ← searchable, grouped [M#] refs
└─ Help
```
Use `/memory` when you want to inspect what the agent currently remembers without asking the model or polluting the transcript.
Compaction output already appears through OpenCode's built-in conversation flow. This plugin does not add duplicate compaction notices.
## How It Works
@@ -148,112 +193,65 @@ Memories decay over time. The strongest stay visible in the prompt; weaker ones
## Explicit Memory Triggers
You can explicitly ask the agent to remember durable facts.
Examples:
Most memory is extracted automatically during compaction. When something is especially important, tell the agent directly:
```md
Remember this: we prefer Vitest for new frontend tests.
記住:這個 repo 發 release 前要先跑 npm test。
覚えておいて: API clients should use the shared retry helper.
기억해줘: this project uses pnpm, not npm.
```
Supported trigger languages include:
Use explicit triggers for stable preferences, project rules, architecture decisions, or important references. Then inspect active workspace memory with:
| Language | Examples |
|---|---|
| English | `remember this`, `save to memory`, `from now on`, `my preference` |
| Chinese | `記住`, `记住`, `記得`, `请帮我记住` |
| Japanese | `覚えて`, `覚えておいて`, `メモして` |
| Korean | `기억해`, `기억해줘`, `메모해줘` |
Negative requests are respected too:
```md
Don't remember this.
不要記住這個。
覚えないで。
기억하지 마.
```text
/memory → Current memories
```
Avoid saving:
Trigger phrases include `remember this`, `save to memory`, `from now on`, `my preference`, `記住`, `記得`, `覚えて`, and `기억해`.
- Secrets, passwords, tokens, or credentials
- Temporary progress updates
- Raw command output
- Short-lived session details
Negative requests are respected too: `Don't remember this`, `不要記住這個`, `覚えないで`, `기억하지 마`.
Avoid asking memory to save secrets, temporary progress, raw command output, or short-lived session details.
## Quality Guards
OpenCode Working Memory tries to keep memory useful and low-noise.
**Good memory is selective memory.**
It includes guards for:
OpenCode Working Memory is designed to be selective. Its strength is not storing more; it is keeping the prompt focused on durable facts that still help.
- Credential redaction
- Duplicate memory cleanup
- Accounting for promoted, absorbed, superseded, and rejected memories
- Strength-based retention so useful memories stay visible without hard age pruning
- Filtering stack traces, git hashes, raw errors, and noisy path-heavy facts
- Rejecting temporary project progress snapshots
It protects memory quality in three ways:
- **Selective** — filters temporary progress, raw errors, stack traces, git hashes, noisy debug fragments, and duplicate restatements.
- **Safe** — redacts credentials and protects manual or explicit memories from unsafe automatic replacement.
- **Diagnosable** — tracks promoted, absorbed, superseded, rejected, reinforced, and replaced memory outcomes.
The goal is to remember durable facts, not every detail.
**Good memory is selective memory.**
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:
During compaction, existing workspace memories may be shown as numbered refs such as `[M1]` or `[M2]`. The model can reinforce a still-useful memory or propose a protected replacement instead of copying the same fact again.
```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.
Protected memories and stale refs are rejected rather than mutated. Use `memory-diag commands` for detailed command outcomes and recovery guidance.
### Memory Diagnostics CLI
Use the read-only diagnostics CLI when you want to understand what memory is doing for the current workspace.
| Question | Command |
|---|---|
| Is memory healthy? | `npx --package opencode-working-memory memory-diag` or `npx --package opencode-working-memory memory-diag status` |
| Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` |
| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` |
| Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain <memory-id>` |
| 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:
- `--workspace <path>` — inspect another workspace; defaults to the current directory.
- `--verbose` — show detailed diagnostics.
- `--json` — print machine-readable output where supported.
Examples:
For deeper troubleshooting, use the read-only `memory-diag` CLI:
```bash
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>
npx --package opencode-working-memory memory-diag rejected
npx --package opencode-working-memory memory-diag missing
npx --package opencode-working-memory memory-diag explain <memory-id>
npx --package opencode-working-memory memory-diag quality
```
`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`.
See [Diagnostics](docs/diagnostics.md) for the full command reference, numbered memory command reports, and dry-run recovery workflow.
## Configuration
@@ -269,34 +267,17 @@ Default behavior:
See [Configuration](docs/configuration.md) for customization options.
## Roadmap
Current focus:
- Add explicit delete tombstones so removed memories do not get re-extracted.
- Monitor numbered refs and protected replacements with `memory-diag commands` before tightening automatic replacement policy further.
- Explore tiered hot/warm/cold storage after the retention model has more real-world data.
## Documentation
- [Architecture Overview](docs/architecture.md)
- [Configuration](docs/configuration.md)
- [Diagnostics](docs/diagnostics.md)
- [Installation Guide](docs/installation.md)
## Development
```bash
git clone https://github.com/sdwolf4103/opencode-working-memory.git
cd opencode-working-memory
npm install
npm test
npm run typecheck
```
## Requirements
- OpenCode plugin API `>=1.2.0 <2.0.0`
- Node.js >= 22.6.0 (for `memory-diag` CLI, which runs TypeScript with `--experimental-strip-types`)
- Node.js >= 22.6.0 (the published `memory-diag` CLI runs compiled JavaScript)
## Limitations
+157
View File
@@ -1,5 +1,162 @@
# Release Notes
## 1.6.4 (2026-05-15)
### Rolling Weekly Reinforcement
This patch release fixes the reinforcement policy for users who work in long-lived OpenCode sessions. Reinforcement no longer treats `same_session` as a hard block. Instead, each memory uses a rolling 7-day elapsed window, so recurring preferences can be reinforced after meaningful weekly use even when the session stays open.
The base retention half-life remains 45 days. The max reinforcement count remains 6, but it now acts as a growth saturation point rather than a lifetime hard stop.
### What Changed
- **7-day rolling window**: repeated reinforcement is allowed once 7 rolling days have elapsed since the memory's last reinforcement; 7 days minus 1ms still blocks.
- **Same-session as evidence**: `sameSession` is recorded for diagnostics but no longer blocks reinforcement by itself.
- **Refresh-only saturation**: memories at reinforcement count 6 can refresh `retentionClock`, `lastReinforcedAt`, and session evidence after the weekly window without increasing count or effective half-life.
- **Instrumentation v3**: new reinforcement evidence records elapsed-window fields, `sameSession`, `reinforcementMode`, and legacy missing timestamp markers.
- **Diagnostics updated**: `memory-diag commands --memory` exposes the new fields, while `memory-diag quality` keeps historical `same_session` block analysis separate from new same-session evidence.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files and evidence logs remain compatible.
- Historical diagnostics may still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, or `max_count`; new instrumentation-version-3 events use the rolling elapsed-window semantics.
- Consumers of `memory-diag commands --memory --json` should use `reinforcementMode` to distinguish count-increment reinforcement from refresh-only saturation.
### Validation
- `node --test --experimental-strip-types tests/retention.test.ts` — 10 tests passing
- `node --test --experimental-strip-types tests/workspace-memory.test.ts tests/plugin.test.ts` — 181 tests passing
- `node --test --experimental-strip-types tests/memory-diag.test.ts tests/memory-diag-quality.test.ts` — 93 tests passing
- `npm run typecheck``TYPECHECK_PASS`
- `npm test` — 498 tests passing, `TEST_PASS`
- `npm run build``BUILD_PASS`
---
## 1.6.3 (2026-05-14)
### Diagnostic Quality Review Board
This patch release focuses on safer memory diagnostics. It adds a read-only quality review board and finer reinforcement drill-downs so reviewers can inspect memory-system evidence without treating historical artifacts as current failures or turning heuristic flags into automatic cleanup decisions.
The goal is better observability before policy changes: diagnostics show facts, provenance, answerability, and review questions, while leaving judgment to the operator.
### What Changed
- **Quality review board**: `memory-diag quality` now reports system-mechanism facts for rejection filters, reinforcement rules, eviction/caps, identity/dedup, and active memory content review.
- **Version/provenance context**: diagnostics distinguish current producer-instrumented events from historical or unversioned evidence where possible, and label ambiguous evidence conservatively.
- **Focused reinforcement drill-down**: `memory-diag commands --memory <memory-id>` shows one memory's reinforcement command evidence, current status, recorded block reasons, missing details, and UTC-day evidence.
- **Canonical active-memory JSON surface**: `memory-diag quality --json` now uses `activeMemoryDisplay` as the single active-memory review surface instead of duplicating active memories under `reviewCandidates`.
- **Attribution-safe wording**: quality and reinforcement diagnostics avoid claiming that a block is a bug, policy failure, or cause of memory loss; they present recorded evidence and review prompts instead.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files and evidence logs remain compatible.
- If you consume `memory-diag quality --json` from unreleased builds after v1.6.2, read active-memory review data from `activeMemoryDisplay`; `reviewCandidates` is now reserved for system-mechanism candidates.
### Useful Commands
```bash
npx --package opencode-working-memory@1.6.3 memory-diag quality
npx --package opencode-working-memory@1.6.3 memory-diag quality --json
npx --package opencode-working-memory@1.6.3 memory-diag commands --memory <memory-id>
```
### Validation
- `node --test --experimental-strip-types tests/memory-diag-quality.test.ts` — 53 tests passing
- `node --test --experimental-strip-types tests/memory-diag.test.ts` — 39 tests passing
- `npm run typecheck``TYPECHECK_PASS`
- `npm test` — 486 tests passing, `TEST_PASS`
- `npm run build``BUILD_PASS`
- `npm run test:pack:memory-diag` — packed tarball smoke test passed
---
## 1.6.2 (2026-05-11)
### Published `memory-diag` Bin Fix
This patch release fixes the published npm package path for `memory-diag`. In v1.6.1 the source-tree CLI tests passed, but the installed package could fail under `npx --package opencode-working-memory memory-diag` because Node refuses TypeScript type stripping for files inside `node_modules`.
v1.6.2 compiles the diagnostics CLI during packing and makes the npm bin launch the compiled JavaScript runtime.
### What Changed
- **Compiled diagnostics runtime**: `prepack` now builds `dist/scripts/memory-diag.js` before the package is packed or published.
- **Safer npm bin wrapper**: `memory-diag` no longer runs published `.ts` files through `--experimental-strip-types`; it launches the compiled JS artifact and reports a clear reinstall/build message if the artifact is missing.
- **Packaged-bin smoke test**: release verification now includes a pack/npx smoke test from a temp consumer project outside the repository.
### Upgrade Notes
- No config changes are required.
- Existing OpenCode server and TUI plugin entry points are unchanged.
- If you hit the v1.6.1 bin failure, upgrade and rerun:
```bash
npx --package opencode-working-memory@1.6.2 memory-diag --help
```
### Validation
- `npm run build``BUILD_PASS`
- `node ./scripts/memory-diag-bin.cjs --help`
- `npm run test:pack:memory-diag` — packed tarball smoke test passed
- `npm run typecheck``TYPECHECK_PASS`
- `npm test` — 421 tests passing, `TEST_PASS`
- `npm pack --dry-run` — includes compiled `dist/` diagnostics artifacts
---
## 1.6.1 (2026-05-08)
### Native TUI Memory Menu
This release adds a native OpenCode TUI memory menu so users can inspect local working memory without asking the model and without adding command output to the conversation transcript.
Open `/memory` in the TUI to browse memory status, current workspace memories, and help from native dialogs.
> Memory should stay visible when you need it — and stay out of the transcript when you are only inspecting it.
```text
/memory
├─ Status
│ local counts and memory health
├─ Current memories
│ searchable grouped [M#] refs
└─ Help
local usage notes
```
### What Changed
- **Single TUI entry point**: `/memory` opens a native submenu instead of exposing multiple memory slash commands.
- **Searchable current memory list**: `Current memories` uses OpenCode's native select dialog for bounded scrolling, filtering, and grouping.
- **Transcript-free inspection**: memory status, list, help, empty states, and errors render in native dialogs instead of user-style session messages.
- **Server and TUI plugin exports**: the package exposes `./server` and `./tui` entry points for OpenCode plugin loading.
- **User docs refreshed**: README highlights the `/memory` workflow and moves the full diagnostics CLI reference to `docs/diagnostics.md`.
### Upgrade Notes
- Add `.opencode/tui.json` if you want the native `/memory` TUI menu. Existing server-only configuration continues to work.
- Restart OpenCode after adding the TUI plugin config.
- The TUI menu is read-only and local-only. It does not call the LLM.
- Individual memory row selection is intentionally a no-op in this release; use the list for inspection and search.
### Validation
- `npm run typecheck``TYPECHECK_PASS`
- `npm test` — 421 tests passing, `TEST_PASS`
- `npm pack --dry-run`
- Real OpenCode TUI smoke test for `/memory` menu, searchable current memories, and transcript-free output.
---
## 1.6.0 (2026-05-08)
### Numbered Memory Refs
+25 -1
View File
@@ -55,6 +55,30 @@ Long-term memory that persists across sessions within the same workspace. Perfec
}
```
### Evidence Log Schema
Workspace diagnostics also read the append-only evidence log for the current workspace. New evidence records are additive and keep historical records valid:
```typescript
{
version: 1,
eventId: string,
createdAt: string,
workspaceKey: string,
workspaceRootHash: string,
producerName?: string,
producerVersion?: string,
instrumentationVersion?: number,
type: EvidenceEventType,
phase: EvidencePhase,
outcome: EvidenceOutcome,
reasonCodes: string[],
details?: Record<string, string | number | boolean | null | string[] | number[]>
}
```
Instrumentation version 2 added optional causal block details for diagnostics without backfilling old JSONL records. Instrumentation version 3 adds elapsed-window reinforcement details such as `details.elapsedMs`, `details.requiredElapsedMs`, `details.sameSession`, `details.reinforcementMode`, and `details.legacyMissingTimestamp`. Historical reinforcement-block events may still include older `details.blockReason` values such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or may have missing block details. Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior.
### Entry Types
| Type | Purpose | Example |
@@ -130,7 +154,7 @@ Default type caps:
The type-cap total is 34, intentionally above the global 28-entry cap. These are maximums, not quotas.
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement.
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry only after a rolling 7-day elapsed window. Below reinforcement count 6, an allowed recurrence increments the reinforcement count and refreshes retention timestamps; at count 6 or higher, an allowed recurrence refreshes retention timestamps without increasing the count. Same-session status is recorded as diagnostic evidence, not as a new-policy block reason.
### Safety-Critical Deprecation
+100
View File
@@ -0,0 +1,100 @@
# Memory Diagnostics
Use the read-only diagnostics CLI when you want to understand what OpenCode Working Memory is doing for the current workspace.
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`.
## Commands
| Question | Command |
|---|---|
| Is memory healthy? | `npx --package opencode-working-memory memory-diag` or `npx --package opencode-working-memory memory-diag status` |
| Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` |
| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` |
| Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain <memory-id>` |
| How are numbered memory commands behaving? | `npx --package opencode-working-memory memory-diag commands` |
| What reinforcement evidence exists for one memory? | `npx --package opencode-working-memory memory-diag commands --memory <memory-id>` |
| How do I review memory quality without automatic cleanup? | `npx --package opencode-working-memory memory-diag quality` |
| Revert a numbered replacement? | `npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>` |
## Global Options
- `--workspace <path>` — inspect another workspace; defaults to the current directory.
- `--verbose` — show detailed diagnostics.
- `--json` — print machine-readable output where supported.
## Diagnostic Answerability Contract
Every diagnostic section must document:
1. **Question:** What does the reviewer want to know?
2. **Decision:** What action could the answer inform?
3. **Competing explanations:** At least two interpretations of the same metric.
4. **Required signals:** What fields/events distinguish those explanations?
5. **Current signals:** What currently exists?
6. **Answerability level:** `supported` | `partial` | `inventory_only` | `not_instrumented`
7. **Output permission:** What the tool may say without overclaiming.
For `memory-diag quality`:
- `reinforcementRules`: `inventory_only` (cannot distinguish spam from legitimate blocks)
- `evictionAndCaps`: `inventory_only` (cannot distinguish healthy turnover from premature eviction)
- Old evidence remains ambiguous. Answerability improves for producer-instrumented events, including instrumentation version 2 block details and instrumentation version 3 elapsed-window details. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections.
- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and, when available, rolling elapsed-window fields; they still require human content judgment.
- Producer-instrumented capacity removals with rank/strength snapshots can upgrade `evictionAndCaps` to `partial`; fullness alone remains occupancy inventory, not proof of a capacity problem.
## Examples
```bash
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 commands --memory <memory-id>
npx --package opencode-working-memory memory-diag quality
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>
```
## Quality Review Board
Use `memory-diag quality` for a read-only, answerability-scoped evidence inventory without automatic cleanup.
- Primarily provides memory-system mechanism observations for human/agent interpretation.
- Secondarily helps review active memory content quality.
- Prints answerability labels and output permissions so inventory facts are not presented as conclusions.
- Separates system-mechanism facts, memory-content facts, heuristic flags, and review questions.
- Includes inferred evidence provenance because historical records do not record producer package version.
- Labels uncertain provenance as `unversioned_ambiguous` so old artifacts are not treated as current mechanism failures.
- Does not decide what to delete or mutate.
- Use `--json` for agent/objective review.
## Numbered Memory Command Reports
Use `memory-diag commands` to inspect `REINFORCE [M#]` and `REPLACE [M#]` outcomes from compaction.
```bash
npx --package opencode-working-memory memory-diag commands
npx --package opencode-working-memory memory-diag commands --verbose
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
```
The report includes successful reinforcements, refresh-only reinforcements, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode.
Use `commands --memory <memory-id>` when you need a focused, evidence-only reinforcement view for one memory. It reports current memory status separately from recorded reinforcement attempts, block reasons, missing block details, elapsed-window fields (`elapsedMs`, `requiredElapsedMs`), `sameSession` evidence, `reinforcementMode` (`increment` or `refresh_only`), `legacyMissingTimestamp` when true, and historical UTC-day evidence without judging whether the policy is correct.
Current reinforcement policy uses a rolling 7-day elapsed window. Below reinforcement count 6, allowed attempts increment the count and refresh retention timestamps; at count 6 or higher, allowed attempts refresh retention timestamps without increasing the count. Historical evidence can still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or missing block details because evidence logs are append-only and are not backfilled.
## Dry-run Recovery
`memory-diag revert` is dry-run by default. Add `--apply` only after reviewing the planned original/replacement status changes.
```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>
```
@@ -0,0 +1,491 @@
# Native TUI Memory Command UX Implementation Plan
> **For agentic workers:** Use `agenthub-writing-plans-skill` to create this plan and `agenthub-executing-plans-skill` to execute it task-by-task. Steps use checkbox (`- [ ]`) syntax. Wave checkpoints are gates.
**Goal:** Replace the current ambiguous native OpenCode TUI memory slash-command surface with three visibly distinct hyphenated commands before commit/push.
**User outcome:** OpenCode users see only `/memory-status`, `/memory-list`, and `/memory-help` in slash autocomplete; status shows memory statistics, list shows current active workspace memories as display-local `[M1]` refs, and duplicate recent-activity commands are no longer user-facing.
**Architecture:** Keep the existing TUI plugin and no-reply session-message injection path. Change only the command registration/routing layer (`src/tui-plugin.ts`) and the local read/format core (`src/memory-visibility.ts`), then update focused tests and docs. Do not add storage, background jobs, LLM calls, command mutation, or a parallel UI surface.
**Tech stack:** TypeScript ESM on Node >=22.6, OpenCode TUI plugin API, local JSON stores, Node built-in test runner with `--experimental-strip-types`.
**Scope mode:** COMPLETE for the approved UX correction; no implementation code is changed by this plan.
---
## Scope Challenge
- Existing leverage: Reuse `src/tui-plugin.ts` command registration and `api.client.session.prompt({ noReply: true })`; reuse `src/memory-visibility.ts` local read snapshots, redaction helper, and `accountWorkspaceMemoryRender()`/`accountWorkspaceMemoryCompactionRefs()` accounting instead of creating a separate diagnostic subsystem.
- Minimum complete change: Register three unique top-level slash names, add/route a list formatter, remove visible activity/last commands, make status stats-only, and update README/CHANGELOG/tests to match the new public surface.
- Scope smell check: Expected code/docs/test touch set is 6 files: `src/tui-plugin.ts`, `src/memory-visibility.ts`, `tests/tui-plugin.test.ts`, `tests/memory-visibility.test.ts`, `README.md`, and `CHANGELOG.md`. `RELEASE_NOTES.md`, `docs/installation.md`, and `docs/configuration.md` do not currently mention the TUI memory commands and should stay unchanged unless implementation reveals new command mentions.
- Lake vs ocean: The lake is display-only status/list/help for existing local data. Ocean-sized extras remain out of scope: `/memory delete`, `/memory edit`, stable memory IDs in the TUI, interactive list selection, evidence activity dashboards, assistant-style output APIs, and upstream OpenCode TUI changes.
- Out of scope: No activity/last user-facing commands, no `/memory` space-subcommand autocomplete entries, no new aliases unless OpenCode proves they do not create duplicate menu rows, no persistence schema changes, no LLM/API calls, and no server plugin behavior changes.
## Search and Prior Art
- Layer 1 choices:
- `src/tui-plugin.ts:113-148` currently registers four commands with the same `slash.name: "memory"`, causing OpenCode to show multiple identical `/memory` rows.
- `src/tui-plugin.ts:58-63` maps internal values `memory.activity` and `memory.last` to the same `"activity"` command, confirming duplication.
- `src/memory-visibility.ts:12` currently exposes `MemoryVisibilityCommand = "status" | "activity" | "help"`; `formatMemoryHelp()` at lines 273-288 documents `/memory activity` and `/memory last`.
- `src/memory-visibility.ts:213-238` already formats status counts but also includes preview lines; the approved UX wants status focused on statistics and delegates memory content to `/memory-list`.
- `src/workspace-memory.ts:937-997` already has numbered ref accounting (`accountWorkspaceMemoryCompactionRefs`) that returns rendered entries, omitted entries, and `refs` with `M1`, `M2`, ... display labels. This is the best existing source for list refs because it respects the same selection/cap logic as compaction refs.
- `tests/tui-plugin.test.ts:98-153` covers TUI registration, no-reply injection, routing, no-session warning, dialog clearing, and injection failure.
- `tests/memory-visibility.test.ts:53-195` covers status/activity/help formatting and read-only redaction behavior.
- `README.md:64-76` and `CHANGELOG.md:8-25` document the current `/memory` status/activity/help UX and must be updated before commit/push.
- Layer 2 choices: None required. Do not add dependencies.
- Layer 3 choices: A small `MemoryListModel`/`formatMemoryList()` in `src/memory-visibility.ts` is justified because the TUI needs a user-facing grouped list shape that differs from compaction prompt text.
- Eureka findings: OpenCode's current slash menu does not visibly distinguish trailing subcommand text, so the technically elegant `/memory status` model is worse UX than three hyphenated top-level commands for this release.
## Architecture
### Components and responsibilities
- `src/tui-plugin.ts`: Owns visible TUI command names and active-session/no-reply injection. It should register exactly three commands with `slash.name` values `memory-status`, `memory-list`, and `memory-help`; internal `value` strings may remain dot-form (`memory.status`, `memory.list`, `memory.help`) because they are not displayed to users.
- `src/memory-visibility.ts`: Owns read-only local models and markdown/plain-text formatting. It should expose command variants `status`, `list`, and `help`; status should report stats only; list should show active rendered workspace memories with display-local `[M#]` refs; help should list only the three public commands. `MemoryVisibilityCommand` is currently consumed by `src/tui-plugin.ts`; verify no other consumers exist before changing/removing exported command variants.
- `src/workspace-memory.ts`: No planned edit. Reuse `accountWorkspaceMemoryRender()` for stats and `accountWorkspaceMemoryCompactionRefs()` for capped/ref-capable list selection. If imports need updating, import only existing exported functions.
- `tests/tui-plugin.test.ts`: Assert unique slash names and removal of user-facing activity/last registrations.
- `tests/memory-visibility.test.ts`: Assert status/list/help output contracts, redaction/truncation, caps, and fallback routing.
- `README.md` and `CHANGELOG.md`: Align public docs with the new three-command UX. `RELEASE_NOTES.md` has no 1.6.1 TUI command section today; leave it unchanged unless a later release-notes pass adds one.
### Data flow
```text
User selects /memory-status, /memory-list, or /memory-help in OpenCode TUI
-> src/tui-plugin.ts registered command onSelect
-> determine active sessionID from api.route/current session route
-> commandFromValue(value) returns "status" | "list" | "help"
-> src/memory-visibility.ts renderMemoryCommand(root, sessionID, command)
status: read workspace/session/pending snapshots + render accounting counts
list: read workspace snapshot + accountWorkspaceMemoryCompactionRefs() + safePreview()
help: static command help
-> api.client.session.prompt({ sessionID, noReply: true, parts: [{ type: "text", text }] })
-> OpenCode renders the report as local no-reply session text; no LLM call is made
```
### Output contracts
#### `/memory-status`
Required shape:
```md
## Memory status
Workspace:
- Active memories: <n>
- Rendered in prompt: <n>
- Omitted active memories: <n>
- Superseded memories: <n>
Pending:
- Pending in this session: <n>
- Pending journal memories: <n>
Session:
- Open errors: <n>
- Recent decisions: <n>
Use /memory-list to view current [M1]-[M28] memory refs.
Local only: no LLM request was made.
```
Notes:
- Remove preview lines from status.
- Keep zero/empty counts visible.
- Keep the local-only footer.
#### `/memory-list`
Required shape:
```md
## Current workspace memories
Display refs are local to this output and may change after memory updates.
feedback:
- [M1] <redacted/truncated text>
project:
- [M2] <redacted/truncated text>
decision:
- [M3] <redacted/truncated text>
reference:
- [M4] <redacted/truncated text>
Shown: <rendered> of <active> active memories.
Omitted active memories: <omitted-active>.
Local only: no LLM request was made.
```
Notes:
- Use refs that are explicitly display-local, not stable IDs.
- Group by memory type/kind in the existing order: `feedback`, `project`, `decision`, `reference`.
- Show only active, non-superseded memories selected by the same caps/budget used for rendered memory refs. The default global cap is 28 (`src/types.ts` via `LONG_TERM_LIMITS.maxEntries`).
- Apply `safePreview()` or equivalent credential redaction/truncation to every displayed memory text. Do not dump raw JSON or full unbounded memory text.
- Empty state: `No active workspace memories are stored yet.` plus the local-only footer.
#### `/memory-help`
Required shape:
```md
## Memory help
Commands:
- /memory-status — show local memory statistics.
- /memory-list — show current workspace memories as display-local [M1]-[M28] refs.
- /memory-help — show this help.
These commands are read-only, local-only, and do not call the LLM.
```
Notes:
- Do not mention `/memory`, `/memory status`, `/memory activity`, or `/memory last` as available commands.
- It is acceptable to keep a short note that mutation commands such as delete/edit are not available, but do not expand scope.
### Error flow
- No active session route: preserve existing warning toast behavior and do not write a message.
- Local read/format error with a session: preserve existing `## Memory error` stream-visible report.
- `api.client.session.prompt()` failure: preserve existing error toast and no retry.
- Unknown internal command value: route to help. Do not register unknown/legacy values in the visible command list.
### Security and permissions
- Commands are read-only over local memory/session/pending files and write only the user-invoked no-reply session output.
- Display memory text only after redaction and truncation.
- Do not introduce shell execution, network calls, LLM calls, or file writes to memory stores.
- Do not treat display-local `[M#]` refs as authorization or stable identity; they are only labels in the printed list.
### Performance
- Status remains O(number of workspace/session/pending entries), using existing bounded stores.
- List should format at most the rendered/ref-selected memories and must respect existing caps/budgets; avoid full evidence lifecycle joins.
- Removing activity from the visible UX avoids querying/formatting evidence logs during normal command use.
### Production failure scenarios
- OpenCode still displays aliases or duplicate slash names unexpectedly: keep only three primary `slash.name` values and avoid aliases until verified.
- User expects `/memory` from an unreleased local build: because this is before commit/push, prefer clean UX over compatibility debt; docs should clearly advertise the three hyphenated commands.
- Very long memories or credentials appear in stored data: list formatter must redacted/truncate via `safePreview()` and tests should assert credential-like fixture text is absent.
- More than 28 active memories exist: list reports shown vs active and omitted count; it must not imply refs cover hidden memories.
## Backwards Compatibility Stance
- Treat the current `/memory` space-subcommand surface as pre-public/unshipped for this commit because it produces duplicate-looking menu entries in OpenCode.
- Remove visible registrations for `/memory`, `/memory status`, `/memory activity`, `/memory last`, and `/memory help`.
- Do not document old spellings.
- Internal fallback may continue routing unknown values to help, but do not preserve hidden legacy command entries if OpenCode would show them in autocomplete.
- If a reviewer requests aliases, add only after confirming aliases do not create extra duplicate menu rows; otherwise defer aliases to a later OpenCode API capability discussion.
## File Plan
- Modify: `src/tui-plugin.ts:58-63` — route `memory.status`, `memory.list`, and `memory.help`; remove activity/last mapping from the public path.
- Modify: `src/tui-plugin.ts:113-148` — register exactly three commands with `slash.name` values `memory-status`, `memory-list`, `memory-help`; remove `Memory activity` and `Memory last` command objects.
- Modify: `src/memory-visibility.ts:12-40` — change command/model types from status/activity/help to status/list/help; add `MemoryListModel`; remove or unexport activity-only types/functions if no longer used.
- Modify: `src/memory-visibility.ts:190-238` — keep stats model but format status as grouped statistics with no previews.
- Modify: `src/memory-visibility.ts:240-271` — replace activity reader/formatter with list reader/formatter or remove activity code and add list code nearby.
- Modify: `src/memory-visibility.ts:273-288` — update help to list only `/memory-status`, `/memory-list`, `/memory-help`.
- Modify: `src/memory-visibility.ts:291-302` — route `"list"` to the new list formatter and remove `"activity"` routing.
- Modify: `tests/tui-plugin.test.ts` — assert three unique visible commands and route status/list/help.
- Modify: `tests/memory-visibility.test.ts` — replace activity tests with list tests; update status/help assertions.
- Modify: `README.md:33,51-76` — update feature copy and Native TUI command docs.
- Modify: `CHANGELOG.md:8-25` — amend unreleased/current 1.6.1 entry from status/activity/help to status/list/help and note hyphenated names.
- No planned change: `RELEASE_NOTES.md` — no 1.6.1 TUI command mention exists in current evidence.
- No planned change: `docs/installation.md`, `docs/configuration.md` — current grep found no TUI command mentions.
## Test Strategy
- Framework: Node built-in test runner via `npm test`; TypeScript via `npm run typecheck`.
- Unit coverage:
- `memory-visibility.ts` status counts with active/superseded/rendered/omitted entries, pending memories, pending journal entries, open errors, and recent decisions; assert no preview section remains.
- `memory-visibility.ts` list output with active memories grouped by type, display-local `[M#]` labels, shown/active/omitted summary, redacted credential-like text, empty state, and local-only footer.
- `memory-visibility.ts` help text lists only three hyphenated commands and omits `/memory activity` and `/memory last`.
- `renderMemoryCommand()` routes `status`, `list`, and `help`; unknown values fall back to help.
- TUI integration-style unit coverage:
- Registers exactly three command values.
- Slash names are exactly `memory-status`, `memory-list`, `memory-help` and unique.
- No registered command value is `memory.activity` or `memory.last`.
- Selecting `memory.status`, `memory.list`, and `memory.help` injects no-reply text with the expected heading.
- Existing no-session warning, dialog clearing, and prompt-injection failure behavior still passes.
- Docs verification:
- Grep for old public spellings in markdown and source tests after implementation; old spellings should remain only in this plan or intentionally in negative assertions.
## Implementation Tasks
## Wave 1: Failing Tests for the New Public Contract
### Task 1.1: Update TUI command registration/routing tests first
**Purpose:** Prove the slash command menu no longer contains duplicate-looking `/memory` entries.
**Files:**
- Modify: `tests/tui-plugin.test.ts`
**Behavior:**
- Given the TUI plugin registers commands, there are exactly three memory commands.
- Given autocomplete displays slash names, the names are unique hyphenated top-level commands.
- Given a command is selected, status/list/help route to distinct headings.
- [ ] **Step 1: Write failing tests**
Add or update assertions equivalent to:
```ts
test("registers three unique hyphenated memory slash commands", async () => {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } });
await MemoryTuiPlugin(api as any, undefined, mockMeta);
const slashNames = api.commands.map(command => command.slash?.name).filter(Boolean);
assert.deepEqual(slashNames, ["memory-status", "memory-list", "memory-help"]);
assert.equal(new Set(slashNames).size, slashNames.length);
assert.deepEqual(api.commands.map(command => command.value), ["memory.status", "memory.list", "memory.help"]);
assert.equal(api.commands.some(command => command.value === "memory.activity"), false);
assert.equal(api.commands.some(command => command.value === "memory.last"), false);
});
```
Update the routing test to select `memory.list` and expect `## Current workspace memories`.
- [ ] **Step 2: Run expected failure**
Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/tui-plugin.test.ts`
Expected: FAIL because current `src/tui-plugin.ts` registers repeated `slash.name: "memory"` and does not register `memory.list`.
### Task 1.2: Update memory visibility formatter tests first
**Purpose:** Lock the approved status/list/help output shape before implementation.
**Files:**
- Modify: `tests/memory-visibility.test.ts`
**Behavior:**
- Status is stats-only and points to `/memory-list`.
- List prints current active memories grouped by type with display-local refs and redaction.
- Help lists only three hyphenated commands.
- [ ] **Step 1: Write failing tests**
Required assertions:
```ts
assert.match(output, /^## Memory status/);
assert.match(output, /Workspace:/);
assert.match(output, /Pending:/);
assert.match(output, /Session:/);
assert.match(output, /Use \/memory-list to view current \[M1\]-\[M28\] memory refs\./);
assert.equal(output.includes("Recent active memory previews"), false);
```
Replace activity tests with list tests that create at least one memory for each type and one superseded memory. The redaction fixture must include at least one short credential-like active memory that is guaranteed to render, such as `Remember password: sushi for the fake test.`, so `output.includes("sushi") === false` proves redaction rather than omission by caps/budget. Assert:
```ts
assert.match(output, /^## Current workspace memories/);
assert.match(output, /Display refs are local to this output/);
assert.match(output, /feedback:\n- \[M\d+\]/);
assert.match(output, /project:\n- \[M\d+\]/);
assert.match(output, /decision:\n- \[M\d+\]/);
assert.match(output, /reference:\n- \[M\d+\]/);
assert.match(output, /Shown: \d+ of \d+ active memories\./);
assert.equal(output.includes("sushi"), false);
assert.equal(output.includes("Superseded memory should not be active"), false);
```
Update help assertions:
```ts
assert.match(output, /\/memory-status/);
assert.match(output, /\/memory-list/);
assert.match(output, /\/memory-help/);
assert.equal(output.includes("/memory activity"), false);
assert.equal(output.includes("/memory last"), false);
```
- [ ] **Step 2: Run expected failure**
Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/memory-visibility.test.ts`
Expected: FAIL because current implementation still exposes activity/last and lacks list output.
### Wave 1 Checkpoint
- [ ] Confirm both focused test files fail for the expected missing behavior, not unrelated setup errors.
- [ ] Do not proceed if failures indicate fixture/storage regressions unrelated to command UX.
## Wave 2: Implement the Visibility Core
### Task 2.1: Add list model/formatter and simplify status/help
**Purpose:** Make the local rendering core match the approved command set independently of TUI registration.
**Files:**
- Modify: `src/memory-visibility.ts`
**Implementation instructions:**
- Change `MemoryVisibilityCommand` to `"status" | "list" | "help"`.
- Add a `MemoryListModel` that contains:
- `activeMemories: number`
- `renderedMemories: number`
- `omittedActiveMemories: number`
- `groups: Record<LongTermMemoryEntry["type"], Array<{ ref: string; text: string }>>` or equivalent typed structure preserving `feedback`, `project`, `decision`, `reference` order.
- Implement `getMemoryList(root: string)` using `readWorkspaceMemorySnapshot(root)` and `accountWorkspaceMemoryCompactionRefs(store)`.
- Count active memories from the raw/snapshot store by `status !== "superseded"`.
- Use accounting `refs` plus `rendered` entries to build display-local refs.
- Only use the `refs`, `rendered`, and `omitted` fields from `accountWorkspaceMemoryCompactionRefs()` for the list formatter; discard its `evidence` and `prompt` fields and do not call `appendEvidenceEvents()` from `/memory-list`.
- Display text must pass through `safePreview(ref.textPreview)`.
- `omittedActiveMemories` should count only `accounting.omitted` entries whose memory is not superseded; `accounting.omitted` can include superseded entries from selection accounting and those must not inflate active omissions.
- Implement `formatMemoryList(model)` with the required output contract.
- Update `formatMemoryStatus()` to remove preview output and use grouped stat sections.
- Update `formatMemoryHelp()` to list only `/memory-status`, `/memory-list`, `/memory-help`.
- Update `renderMemoryCommand()` switch to route `"list"`.
- Remove `MemoryActivityModel`, `DEFAULT_ACTIVITY_LIMIT`, `MAX_ACTIVITY_LIMIT`, `clampLimit`, `getMemoryActivity()`, `formatMemoryActivity()`, `formatActivityEvent()`, and `summarizeReasons()` if they become unused. Also remove unused `EvidenceEventV1`/`queryEvidenceEvents` imports.
- Before deleting activity-only exports/helpers, grep `src/` for `MemoryActivityModel`, `getMemoryActivity`, `formatMemoryActivity`, `formatActivityEvent`, and `summarizeReasons()`; remove them only after confirming there are no cross-module consumers outside `memory-visibility.ts`.
- [ ] **Step 1: Implement minimal code**
Do not modify `src/workspace-memory.ts` unless TypeScript proves an export is missing. Current evidence shows `accountWorkspaceMemoryCompactionRefs` is exported.
- [ ] **Step 2: Run focused verification**
Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/memory-visibility.test.ts`
Expected: PASS.
- [ ] **Step 3: Run typecheck for dead imports/types**
Run: `npm run typecheck`
Expected: PASS and output includes `TYPECHECK_PASS`.
### Wave 2 Checkpoint
- [ ] Status output contains no memory preview content.
- [ ] List output includes display-local `[M#]` refs, grouped by type, with redacted/truncated text.
- [ ] Activity/last formatter exports are either removed or no longer referenced by user-facing code.
## Wave 3: Implement the TUI Command Surface
### Task 3.1: Register only three hyphenated slash commands
**Purpose:** Fix OpenCode autocomplete by ensuring visible slash names are unique top-level commands.
**Files:**
- Modify: `src/tui-plugin.ts`
**Implementation instructions:**
- Update `commandFromValue(value)`:
- `memory.status` -> `"status"`
- `memory.list` -> `"list"`
- `memory.help` -> `"help"`
- default -> `"help"`
- Update `memoryCommands(api)` to return exactly three objects:
- title `Memory status`, value `memory.status`, description `Show working memory statistics in the current session.`, category `Memory`, suggested `true`, `slash: { name: "memory-status" }`
- title `Memory list`, value `memory.list`, description `Show current workspace memories with display-local refs.`, category `Memory`, `slash: { name: "memory-list" }`
- title `Memory help`, value `memory.help`, description `Show working memory help.`, category `Memory`, `slash: { name: "memory-help" }`
- Remove `Memory activity` and `Memory last` command objects.
- Do not include `aliases: ["mem"]` in this wave; aliases can be reconsidered only after verifying they do not create duplicate menu entries.
- [ ] **Step 1: Implement minimal code**
Keep existing active-session guard, no-reply injection, dialog clearing, and prompt failure toast logic unchanged.
- [ ] **Step 2: Run focused verification**
Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/tui-plugin.test.ts`
Expected: PASS.
- [ ] **Step 3: Run adjacent focused verification**
Run: `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/tui-plugin.test.ts tests/memory-visibility.test.ts`
Expected: PASS.
### Wave 3 Checkpoint
- [ ] TUI registration tests prove slash names are unique.
- [ ] No test expects or selects `memory.activity` or `memory.last`.
- [ ] No implementation path requires OpenCode to render trailing subcommand text.
## Wave 4: Documentation and Release Metadata Alignment
### Task 4.1: Update user-facing docs
**Purpose:** Ensure install/usage docs no longer advertise broken or removed command spellings.
**Files:**
- Modify: `README.md`
- Modify: `CHANGELOG.md`
- Verify/no change unless needed: `RELEASE_NOTES.md`, `docs/installation.md`, `docs/configuration.md`
**Implementation instructions:**
- In `README.md` feature bullets, replace “status, recent activity, and help” with “status, current memory list, and help”.
- In the Native TUI Memory Command section, document:
- `/memory-status` — status counts/statistics
- `/memory-list` — current active workspace memories with display-local `[M1]` refs
- `/memory-help` — help
- Keep the existing local-only/no LLM/no-reply transcript caveat.
- Remove docs for `/memory`, `/memory status`, `/memory activity`, `/memory last`, and `/memory help` as available user commands.
- In `CHANGELOG.md` 1.6.1 entry, amend the current TUI command bullet to say hyphenated `/memory-status`, `/memory-list`, `/memory-help`, and note recent activity/last were removed before release because duplicate entries were not useful.
- `RELEASE_NOTES.md` currently has no 1.6.1 TUI command mention in the evidence read; do not add a release note unless the release process requires a 1.6.1 section.
- [ ] **Step 1: Update markdown docs**
Use exact command names consistently.
- [ ] **Step 2: Run docs/source grep**
Run equivalent local search:
```bash
rg "/memory activity|/memory last|/memory status|/memory help|slash: \{ name: \"memory\"|memory\.activity|memory\.last" README.md CHANGELOG.md src tests
```
Expected: no matches except this plan file if searching the whole repo, or negative test assertions that intentionally verify old commands are absent. The space-separated forms in this grep are obsolete spellings; the correct hyphenated commands `/memory-status`, `/memory-list`, and `/memory-help` should remain present.
### Wave 4 Checkpoint
- [ ] Docs advertise only `/memory-status`, `/memory-list`, `/memory-help`.
- [ ] Changelog matches the pre-release UX correction.
- [ ] No release docs mention stale activity/last commands.
## Final Verification
- [ ] Run: `npm run typecheck`
Expected: PASS and output includes `TYPECHECK_PASS`.
- [ ] Run: `npm test`
Expected: PASS and output includes `TEST_PASS`.
- [ ] Run: `npm pack --dry-run`
Expected: package contains `index.ts`, `src/tui-plugin.ts`, `src/memory-visibility.ts`, README, LICENSE, and no unexpected generated artifacts.
- [ ] Manual OpenCode TUI smoke before commit/push:
- Configure `.opencode/tui.json` to load the local plugin target.
- Open slash command menu and confirm exactly three visible memory commands: `/memory-status`, `/memory-list`, `/memory-help`.
- Select `/memory-status`; expected no-reply session text headed `## Memory status`, no assistant response, no LLM/provider activity.
- Select `/memory-list`; expected no-reply session text headed `## Current workspace memories`, display-local `[M#]` refs, grouped memory types, redacted/truncated text.
- Select `/memory-help`; expected help lists only the three hyphenated commands.
- [ ] Review changed files for placeholders, dead code, unused activity imports, debug logging, stale docs, raw secret output, and accidental storage writes.
## Review Readiness
- [ ] Scope challenge resolved: this is a focused UX correction, not a memory subsystem rewrite.
- [ ] Architecture and data flow are explicit.
- [ ] Every changed behavior has a focused test or manual TUI smoke check.
- [ ] Failure paths and user-visible states are covered.
- [ ] Commands are exact and runnable.
- [ ] Backwards compatibility stance is explicit and pre-release-safe.
- [ ] Plan has no placeholders.
## Risks and Mitigations
- Risk: Hyphenated names are less elegant than `/memory status` subcommands. Mitigation: current OpenCode menu behavior makes hyphenated top-level names the only visible unambiguous option.
- Risk: Users may interpret `[M#]` as stable memory IDs. Mitigation: list output must explicitly say refs are display-local and may change after memory updates.
- Risk: Activity formatter code may be left as unused dead code. Mitigation: typecheck plus source grep should catch unused imports/references; remove activity-only exports unless a maintainer-only consumer is introduced later.
- Risk: List output may leak long or sensitive memory text. Mitigation: use redaction/truncation for each line and add regression assertions that credential-like fixture text is absent.
- Risk: Docs drift with the just-added 1.6.1 changelog. Mitigation: amend the same 1.6.1 entry before commit/push rather than adding contradictory release notes.
+11 -4
View File
@@ -1,11 +1,13 @@
{
"name": "opencode-working-memory",
"version": "1.6.0",
"version": "1.6.4",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
"exports": {
".": "./index.ts"
".": "./index.ts",
"./server": "./index.ts",
"./tui": "./src/tui-plugin.ts"
},
"bin": {
"memory-diag": "./scripts/memory-diag-bin.cjs"
@@ -16,12 +18,17 @@
"scripts/memory-diag.ts",
"scripts/memory-diag/",
"scripts/memory-diag-bin.cjs",
"dist/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
"diag": "node --experimental-strip-types scripts/memory-diag.ts",
"clean:dist": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"build:memory-diag": "npm run clean:dist && tsc -p tsconfig.memory-diag.json",
"build": "npm run build:memory-diag && node -e \"console.log('BUILD_PASS')\"",
"prepack": "npm run build",
"diag": "npm run --silent build:memory-diag && node ./scripts/memory-diag-bin.cjs",
"test:pack:memory-diag": "node --test --experimental-strip-types tests/smoke/memory-diag-packaging.test.ts",
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
+10 -3
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
const { execFileSync } = require("child_process");
const { existsSync } = require("fs");
const path = require("path");
function isSupportedNodeVersion(version) {
@@ -9,13 +10,19 @@ function isSupportedNodeVersion(version) {
}
if (!isSupportedNodeVersion(process.versions.node)) {
process.stderr.write(`memory-diag requires Node >=22.6.0 because it runs TypeScript with --experimental-strip-types. Current Node: v${process.versions.node}.\n`);
process.stderr.write(`memory-diag requires Node >=22.6.0 per opencode-working-memory package engines. Current Node: v${process.versions.node}.\n`);
process.exit(1);
}
const binDir = __dirname;
const tsScript = path.join(binDir, "memory-diag.ts");
const args = ["--experimental-strip-types", tsScript, ...process.argv.slice(2)];
const compiledScript = path.join(binDir, "..", "dist", "scripts", "memory-diag.js");
if (!existsSync(compiledScript)) {
process.stderr.write("memory-diag package is missing dist/scripts/memory-diag.js. Reinstall opencode-working-memory or run npm run build before using the local package.\n");
process.exit(1);
}
const args = [compiledScript, ...process.argv.slice(2)];
try {
execFileSync(process.execPath, args, { stdio: "inherit" });
process.exit(0);
+5 -4
View File
@@ -9,7 +9,8 @@ export function usage(): string {
memory-diag rejected [--workspace <path>] [--verbose] [--json]
memory-diag missing [--workspace <path>] [--verbose] [--json]
memory-diag explain [memory-id] [--workspace <path>] [--raw]
memory-diag commands [--workspace <path>] [--verbose] [--json]
memory-diag commands [--workspace <path>] [--verbose] [--json] [--memory <id>]
memory-diag quality [--workspace <path>] [--verbose] [--json] [--raw] [--no-emoji]
memory-diag revert (--memory <replacement-id> | --event <event-id>) [--workspace <path>] [--apply]
Global options:
@@ -103,12 +104,12 @@ export function parseArgs(argv: string[]): ParsedArgs {
if (command === "status") {
if (options.all) return error(`${command} does not accept --all`);
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "revert") {
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "quality" || command === "revert") {
if (options.all) return error(`${command} does not accept --all`);
} else {
if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`);
}
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands") {
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands" && command !== "quality") {
return error(`${command} does not accept --json`);
}
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
@@ -120,7 +121,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
if (command !== "audit" && options.migration) {
return error(`${command} does not accept --migration`);
}
if (command !== "explain" && command !== "revert" && options.memory) {
if (command !== "explain" && command !== "revert" && command !== "commands" && options.memory) {
return error(`${command} does not accept --memory`);
}
if (command !== "revert" && options.event) return error(`${command} does not accept --event`);
+1 -1
View File
@@ -1,4 +1,4 @@
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "revert"] as const;
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "quality", "revert"] as const;
export const HIDDEN_COMMANDS = ["coverage", "audit"] as const;
export type VisibleCommand = typeof VISIBLE_COMMANDS[number];
+2
View File
@@ -3,6 +3,7 @@ import { runCommands } from "./commands/commands.ts";
import { runCoverage } from "./commands/coverage.ts";
import { runExplain } from "./commands/explain.ts";
import { runMissing } from "./commands/missing.ts";
import { runQuality } from "./commands/quality.ts";
import { runRejected } from "./commands/rejected.ts";
import { runRevert } from "./commands/revert.ts";
import { runStatus } from "./commands/status.ts";
@@ -17,6 +18,7 @@ export async function dispatch(command: Command, options: CliOptions): Promise<C
case "audit": return runAudit(options);
case "explain": return runExplain(options);
case "commands": return runCommands(options);
case "quality": return runQuality(options);
case "revert": return runRevert(options);
}
}
+266
View File
@@ -1,6 +1,11 @@
import { queryEvidenceEvents, type EvidenceEventV1, type EvidenceOutcome } from "../../../src/evidence-log.ts";
import { workspaceKey, workspaceMemoryPath } from "../../../src/paths.ts";
import type { WorkspaceMemoryStore } from "../../../src/types.ts";
import { accountWorkspaceMemoryRender } from "../../../src/workspace-memory.ts";
import { readJSONFile } from "../io.ts";
import { objectFromCounts, sortedCounts } from "../text.ts";
import type { CliOptions, CommandResult } from "../types.ts";
import { normalizedStore } from "../workspace-snapshot.ts";
type CommandKind = "reinforce" | "replace";
@@ -33,6 +38,47 @@ type MemoryCommandSummary = {
}>;
};
type MemoryCommandDetail = {
version: 1;
generatedAt: string;
memoryId: string;
current: {
present: boolean;
status?: string;
renderStatus?: "rendered" | "not_rendered" | "unknown";
type?: string;
source?: string;
};
summary: {
attempts: number;
reinforced: number;
rejectedOrBlocked: number;
windowBlocked: number;
blocksByReason: Record<string, number>;
blockDetailsMissing: number;
refs: string[];
sameSessionCrossUtcDayBlocks: number;
};
events: Array<{
eventId: string;
createdAt: string;
outcome: EvidenceOutcome;
ref?: string;
blockReason?: string;
reasonCodes: string[];
attemptedAtIso?: string;
lastReinforcedAtIso?: string;
elapsedMs?: number;
requiredElapsedMs?: number;
sameSession?: boolean;
legacyMissingTimestamp?: boolean;
reinforcementMode?: string;
crossUtcDay?: boolean | "unknown";
producerVersion?: string;
instrumentationVersion?: number;
}>;
};
const INVALID_COMMAND_REASONS = new Set([
"invalid_memory_command",
"invalid_memory_ref",
@@ -66,6 +112,139 @@ function refFromEvent(event: EvidenceEventV1): string | undefined {
return typeof ref === "string" ? ref : undefined;
}
function isReinforcementEvent(event: EvidenceEventV1): boolean {
return event.type === "memory_reinforced";
}
function stringDetail(event: EvidenceEventV1, key: string): string | undefined {
const value = event.details?.[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function numberDetail(event: EvidenceEventV1, key: string): number | undefined {
const value = event.details?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function booleanDetail(event: EvidenceEventV1, key: string): boolean | undefined {
const value = event.details?.[key];
return typeof value === "boolean" ? value : undefined;
}
function isRejectedOrBlocked(event: EvidenceEventV1): boolean {
return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked");
}
function blockReasonFor(event: EvidenceEventV1): string | undefined {
if (!isRejectedOrBlocked(event)) return undefined;
return stringDetail(event, "blockReason") ?? "unknown";
}
function isCrossUtcDay(attemptedAtIso: string | undefined, lastReinforcedAtIso: string | undefined): boolean | "unknown" {
if (!attemptedAtIso || !lastReinforcedAtIso) return "unknown";
const attempted = new Date(attemptedAtIso);
const lastReinforced = new Date(lastReinforcedAtIso);
if (Number.isNaN(attempted.getTime()) || Number.isNaN(lastReinforced.getTime())) return "unknown";
return attempted.toISOString().slice(0, 10) !== lastReinforced.toISOString().slice(0, 10);
}
async function currentMemoryStatus(root: string, memoryId: string): Promise<MemoryCommandDetail["current"]> {
const rawStore = await readJSONFile<WorkspaceMemoryStore>(await workspaceMemoryPath(root));
const storeRoot = rawStore?.workspace?.root ?? root;
const storeKey = rawStore?.workspace?.key ?? await workspaceKey(root);
const store = normalizedStore(rawStore, storeRoot, storeKey);
const activeEntry = store.entries.find(entry => entry.id === memoryId && entry.status !== "superseded");
const renderAccounting = accountWorkspaceMemoryRender(store);
const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id));
const omittedIds = new Set(renderAccounting.omitted.map(item => item.memory.id));
const renderStatus = renderedIds.has(memoryId)
? "rendered"
: omittedIds.has(memoryId)
? "not_rendered"
: "unknown";
if (!activeEntry) {
return { present: false, renderStatus };
}
return {
present: true,
status: activeEntry.status,
renderStatus,
type: activeEntry.type,
source: activeEntry.source,
};
}
function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][number] {
const attemptedAtIso = stringDetail(event, "attemptedAtIso");
const lastReinforcedAtIso = stringDetail(event, "lastReinforcedAtIso");
const blocked = isRejectedOrBlocked(event);
const blockReason = blockReasonFor(event);
return {
eventId: event.eventId,
createdAt: event.createdAt,
outcome: event.outcome,
ref: refFromEvent(event),
blockReason,
reasonCodes: event.reasonCodes,
attemptedAtIso,
lastReinforcedAtIso,
elapsedMs: numberDetail(event, "elapsedMs"),
requiredElapsedMs: numberDetail(event, "requiredElapsedMs"),
sameSession: booleanDetail(event, "sameSession"),
legacyMissingTimestamp: booleanDetail(event, "legacyMissingTimestamp") === true ? true : undefined,
reinforcementMode: stringDetail(event, "reinforcementMode"),
crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined,
producerVersion: event.producerVersion,
instrumentationVersion: event.instrumentationVersion,
};
}
export async function buildMemoryCommandDetail(
root: string,
memoryId: string,
events: EvidenceEventV1[],
generatedAt = new Date().toISOString(),
): Promise<MemoryCommandDetail> {
const reinforcementEvents = events.filter(isReinforcementEvent);
const blockReasonCounts = new Map<string, number>();
const refs = new Set<string>();
let blockDetailsMissing = 0;
let sameSessionCrossUtcDayBlocks = 0;
for (const event of reinforcementEvents) {
const ref = refFromEvent(event);
if (ref) refs.add(ref);
if (!isRejectedOrBlocked(event)) continue;
const blockReason = blockReasonFor(event) ?? "unknown";
blockReasonCounts.set(blockReason, (blockReasonCounts.get(blockReason) ?? 0) + 1);
if (!stringDetail(event, "blockReason")) blockDetailsMissing += 1;
if (blockReason === "same_session" && isCrossUtcDay(stringDetail(event, "attemptedAtIso"), stringDetail(event, "lastReinforcedAtIso")) === true) {
sameSessionCrossUtcDayBlocks += 1;
}
}
return {
version: 1,
generatedAt,
memoryId,
current: await currentMemoryStatus(root, memoryId),
summary: {
attempts: reinforcementEvents.length,
reinforced: reinforcementEvents.filter(event => event.outcome === "reinforced").length,
rejectedOrBlocked: reinforcementEvents.filter(isRejectedOrBlocked).length,
windowBlocked: reinforcementEvents.filter(event => hasReason(event, "reinforcement_window_blocked")).length,
blocksByReason: objectFromCounts(blockReasonCounts),
blockDetailsMissing,
refs: [...refs].sort(),
sameSessionCrossUtcDayBlocks,
},
events: reinforcementEvents.map(detailEventJSON),
};
}
function latestEventJSON(event: EvidenceEventV1): MemoryCommandSummary["latestEvents"][number] {
return {
eventId: event.eventId,
@@ -145,6 +324,82 @@ function formatLatestEvents(events: MemoryCommandSummary["latestEvents"]): strin
});
}
function formatInlineCounts(counts: Record<string, number>): string {
const rows = sortedCounts(new Map(Object.entries(counts)));
return rows.length > 0 ? rows.map(([reason, count]) => `${reason}=${count}`).join(", ") : "(none)";
}
function formatCrossUtcDay(value: boolean | "unknown" | undefined): string {
if (value === true) return "yes";
if (value === false) return "no";
return "unknown";
}
function formatBoolean(value: boolean): string {
return value ? "yes" : "no";
}
function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] {
if (events.length === 0) return [" (none)"];
return events.map(event => {
const ref = event.ref ? ` ref=${event.ref}` : "";
const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : "";
const reinforcementMode = event.reinforcementMode ? ` reinforcementMode=${event.reinforcementMode}` : "";
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : "";
const elapsedMs = event.elapsedMs !== undefined ? ` elapsedMs=${event.elapsedMs}` : "";
const requiredElapsedMs = event.requiredElapsedMs !== undefined ? ` requiredElapsedMs=${event.requiredElapsedMs}` : "";
const sameSession = event.sameSession !== undefined ? ` sameSession=${formatBoolean(event.sameSession)}` : "";
const legacyMissingTimestamp = event.legacyMissingTimestamp === true ? " legacyMissingTimestamp=yes" : "";
const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : "";
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${reinforcementMode}${attemptedAt}${lastReinforcedAt}${elapsedMs}${requiredElapsedMs}${sameSession}${legacyMissingTimestamp}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`;
});
}
export function formatMemoryCommandDetail(detail: MemoryCommandDetail, options: Pick<CliOptions, "verbose"> = {}): string {
const current = detail.current;
const lines = [
`Memory command diagnostics for ${detail.memoryId}`,
"",
"Current memory:",
` - present: ${current.present ? "yes" : "no"}`,
` - status: ${current.status ?? "unknown"}`,
` - render: ${current.renderStatus ?? "unknown"}`,
];
if (current.type) lines.push(` - type: ${current.type}`);
if (current.source) lines.push(` - source: ${current.source}`);
lines.push("");
if (detail.summary.attempts === 0) {
lines.push(`No reinforcement command evidence found for ${detail.memoryId}.`);
return lines.join("\n");
}
lines.push(
"Reinforcement summary:",
` - attempts: ${detail.summary.attempts}`,
` - reinforced: ${detail.summary.reinforced}`,
` - rejected/blocked: ${detail.summary.rejectedOrBlocked}`,
` - window blocked: ${detail.summary.windowBlocked}`,
` - block reasons: ${formatInlineCounts(detail.summary.blocksByReason)}`,
` - block details missing: ${detail.summary.blockDetailsMissing}`,
` - same-session cross UTC day blocks: ${detail.summary.sameSessionCrossUtcDayBlocks}`,
` - refs: ${detail.summary.refs.length > 0 ? detail.summary.refs.join(", ") : "(none)"}`,
"",
);
const eventRows = options.verbose ? detail.events : detail.events.slice(-10).reverse();
if (!options.verbose && detail.events.length > eventRows.length) {
lines.push(`Latest reinforcement events (showing ${eventRows.length} of ${detail.events.length}):`);
} else {
lines.push("Latest reinforcement events:");
}
lines.push(...formatMemoryCommandDetailEvents(eventRows));
return lines.join("\n");
}
export function formatMemoryCommandSummary(summary: MemoryCommandSummary, options: Pick<CliOptions, "verbose" | "noEmoji"> = {}): string {
const warning = options.noEmoji ? "!" : "⚠";
const lines = [
@@ -176,6 +431,17 @@ export function formatMemoryCommandSummary(summary: MemoryCommandSummary, option
export async function runCommands(options: CliOptions): Promise<CommandResult> {
const root = options.workspace ?? process.cwd();
if (options.memory) {
const events = await queryEvidenceEvents(root, { memoryId: options.memory });
const detail = await buildMemoryCommandDetail(root, options.memory, events);
if (options.json) {
return { stdout: JSON.stringify(detail, null, 2) };
}
return { stdout: formatMemoryCommandDetail(detail, options) };
}
const events = await queryEvidenceEvents(root);
const summary = buildMemoryCommandSummary(events);
+19
View File
@@ -0,0 +1,19 @@
import { buildQualityJSON, formatQualityReviewBoard } from "../formatters/quality.ts";
import { buildInspectionReadModel } from "../inspection-model.ts";
import { buildQualityReviewBoard } from "../quality-review-model.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runQuality(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const report = buildQualityReviewBoard(model, {
verbose: options.verbose,
raw: options.raw,
json: options.json,
});
if (options.json) {
return { stdout: JSON.stringify(buildQualityJSON(report, options.raw), null, 2) };
}
return { stdout: formatQualityReviewBoard(report, { verbose: options.verbose }) };
}
+467
View File
@@ -0,0 +1,467 @@
import { cleanText, formatDetails } from "../text.ts";
import type {
CandidateProvenance,
HeuristicFlag,
ProvenanceClassification,
RejectionVersionFacts,
ReinforcementVersionFacts,
ReviewBoardActiveMemory,
ReviewBoardCandidate,
ReviewBoardReport,
EvictionVersionFacts,
VersionAvailability,
VersionCoverage,
VersionedMechanismFacts,
} from "../quality-review-model.ts";
const PROVENANCE_ORDER: ProvenanceClassification[] = [
"explicit_migration_evidence",
"legacy_unversioned_format",
"reabsorbed_post_rejection",
"suspected_pre_migration_legacy",
"likely_current_behavior",
"unversioned_ambiguous",
];
const REVIEW_FLAG_CAVEAT = "This flag is a prompt for review, not a conclusion.";
export function buildQualityJSON(report: ReviewBoardReport, raw = false): unknown {
if (raw) return report;
return redactUnknown(report);
}
export function formatQualityReviewBoard(
report: ReviewBoardReport,
options: { verbose?: boolean },
): string {
const bullet = "-";
const lines: string[] = [];
lines.push("Memory quality review board");
lines.push("Purpose: evidence for human/agent review only; no automatic judgment or cleanup.");
lines.push("Producer version note: historical records do not include package/plugin version; provenance below is inferred.");
lines.push("Primary review purpose: SYSTEM MECHANISM observations (filters, reinforcement, eviction/caps, identity/dedup).");
lines.push("Secondary review purpose: MEMORY CONTENT quality (staleness, durability, redundancy, specificity).");
lines.push("");
pushEvidenceProvenance(lines, report, bullet);
lines.push("");
pushSystemMechanismFacts(lines, report, bullet);
lines.push("");
pushMemoryContentFacts(lines, report, bullet);
lines.push("");
pushSystemMechanismCandidates(lines, report, bullet);
lines.push("");
pushMemoryContentCandidates(lines, report, bullet, options);
lines.push("");
pushReviewQuestions(lines, report, bullet);
lines.push("");
pushNextCommands(lines, report, bullet);
return lines.join("\n");
}
function pushEvidenceProvenance(lines: string[], report: ReviewBoardReport, bullet: string): void {
const context = report.provenanceContext;
const instrumentation = report.facts.systemMechanisms.instrumentation;
lines.push("Evidence provenance");
lines.push(` ${bullet} method: migration/timestamp/format inference`);
lines.push(` ${bullet} confidence: ${context.confidenceDisclaimer}`);
lines.push(` ${bullet} Producer coverage: ${instrumentation.evidenceEventsWithProducer} of ${instrumentation.evidenceEventsTotal} evidence events instrumented`);
lines.push(` ${bullet} Rejection producer coverage: ${instrumentation.rejectionRecordsWithProducer} of ${instrumentation.rejectionRecordsTotal} rejection records instrumented`);
lines.push(` ${bullet} instrumentation versions: ${formatCounts(instrumentation.instrumentationVersions)}`);
lines.push(` ${bullet} migration timeline: ${formatMigrationTimeline(context.migrationTimeline)}`);
if (context.lastActivityAt) lines.push(` ${bullet} last activity: ${context.lastActivityAt}`);
}
function pushSystemMechanismFacts(lines: string[], report: ReviewBoardReport, bullet: string): void {
const facts = report.facts.systemMechanisms;
lines.push("Facts - system mechanisms");
lines.push(" Provenance counts for mechanism evidence");
lines.push(` ${bullet} ${formatProvenanceCounts(report.provenanceContext.countsByClassification)}`);
lines.push(" Rejection filters");
pushAnswerability(lines, report.answerability?.rejectionFilters, " ");
lines.push(` ${bullet} rejected records: ${facts.rejectionFilters.totalRecords} (unique: ${facts.rejectionFilters.uniqueTexts})`);
lines.push(` ${bullet} raw reason-code distribution: ${formatCounts(facts.rejectionFilters.byRawReasonCode)}`);
lines.push(` ${bullet} type distribution: ${formatCounts(facts.rejectionFilters.byType)}`);
lines.push(` ${bullet} ambiguous/architecture-like rejected candidates: ${facts.rejectionFilters.ambiguousOrArchitectureLike}`);
lines.push(` ${bullet} status-or-hard-reason evidence: ${facts.rejectionFilters.hardReasonOrNoiseHeuristic}`);
lines.push(` ${bullet} re-absorbed rejected texts: ${facts.rejectionFilters.reabsorbedRejectedTexts}`);
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.rejectionFilters, facts.versionedFacts.versionCoverage, formatRejectionVersionFacts, bullet);
lines.push(" Reinforcement rules");
pushAnswerability(lines, report.answerability?.reinforcementRules, " ");
lines.push(` ${bullet} reinforce attempts: ${facts.reinforcementRules.reinforceEvents}, reinforced: ${facts.reinforcementRules.reinforcedEvents}, rejected/blocked: ${facts.reinforcementRules.rejectedOrBlockedEvents}`);
lines.push(` ${bullet} reinforcement-window blocked: ${facts.reinforcementRules.windowBlockedEvents} (rate: ${formatPercent(facts.reinforcementRules.windowBlockRate)})`);
lines.push(` ${bullet} Exact block reasons: ${formatCounts(facts.reinforcementRules.blocksByExactReason)}`);
lines.push(` ${bullet} window blocks by UTC day: ${formatCounts(facts.reinforcementRules.windowBlocksByUtcDay)}`);
lines.push(` ${bullet} block details missing: ${facts.reinforcementRules.blockDetailsMissing}`);
lines.push(` ${bullet} repeated blocks by memory: ${formatRepeatedBlocks(facts.reinforcementRules.repeatedBlocksByMemory)}`);
lines.push(` ${bullet} malformed command events: ${facts.reinforcementRules.malformedCommandEvents}`);
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.reinforcementRules, facts.versionedFacts.versionCoverage, formatReinforcementVersionFacts, bullet);
lines.push(" Eviction and caps");
pushAnswerability(lines, report.answerability?.evictionAndCaps, " ");
lines.push(` ${bullet} active memories: ${facts.evictionAndCaps.activeMemories} / ${facts.evictionAndCaps.maxEntries}`);
lines.push(` ${bullet} rendered memories: ${facts.evictionAndCaps.renderedMemories}`);
lines.push(` ${bullet} cap occupancy: ${formatFullCaps(facts.evictionAndCaps.fullCaps, facts.evictionAndCaps.typeCounts, facts.evictionAndCaps.typeCaps, facts.evictionAndCaps.activeMemories, facts.evictionAndCaps.maxEntries)}`);
lines.push(` ${bullet} capacity removals: total=${facts.evictionAndCaps.removedByCapacity}, global=${facts.evictionAndCaps.removedByGlobalCap}, type=${facts.evictionAndCaps.removedByTypeCap}`);
lines.push(` ${bullet} Removals with snapshot: ${facts.evictionAndCaps.recentCapacityRemovalsWithSnapshot}`);
lines.push(` ${bullet} Removals without snapshot: ${facts.evictionAndCaps.capacitySnapshotsMissing} (historical)`);
if (facts.evictionAndCaps.highestRankRemoved) lines.push(` ${bullet} highest-rank removed snapshot: ${formatHighestRankRemoved(facts.evictionAndCaps.highestRankRemoved)}`);
lines.push(` ${bullet} recent evictions by type: ${formatCounts(facts.evictionAndCaps.recentEvictionsByType)}`);
lines.push(` ${bullet} recent evicted content shown: ${facts.evictionAndCaps.recentEvictedContentShown}`);
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.evictionAndCaps, facts.versionedFacts.versionCoverage, formatEvictionVersionFacts, bullet);
lines.push(" Unknown disappearances");
pushAnswerability(lines, report.answerability?.unknownDisappearances, " ");
lines.push(` ${bullet} unversioned disappearance inventory: evidence-only=${facts.evictionAndCaps.missingEvidenceOnly}, unknown=${facts.evictionAndCaps.unknownDisappearances}`);
lines.push(" Identity and dedup");
pushAnswerability(lines, report.answerability?.identityAndDedup, " ");
lines.push(` ${bullet} replacements: total=${facts.identityAndDedup.replacementEvents}, same-type=${facts.identityAndDedup.sameTypeReplacementEvents}, cross-type=${facts.identityAndDedup.crossTypeReplacementEvents}`);
lines.push(` ${bullet} superseded entries: ${facts.identityAndDedup.supersededEntries}`);
lines.push(` ${bullet} exact duplicate/identity groups identified: ${facts.identityAndDedup.duplicateTextOrIdentityGroups}`);
}
function pushMemoryContentFacts(lines: string[], report: ReviewBoardReport, bullet: string): void {
const facts = report.facts.memoryContent;
lines.push("Facts - memory content");
pushAnswerability(lines, report.answerability?.memoryContent, " ");
lines.push(` ${bullet} rendered memories: ${facts.renderedMemories}`);
lines.push(` ${bullet} evidence coverage: ${facts.evidenceCoverage.covered} / ${facts.evidenceCoverage.total}`);
lines.push(` ${bullet} type counts: ${formatTypeCountsWithCaps(facts.typeCounts, facts.typeCaps)}`);
lines.push(` ${bullet} weakest/strongest active memory previews: weakest=${formatMemoryPreviews(facts.weakestActiveMemories)}; strongest=${formatMemoryPreviews(facts.strongestActiveMemories)}`);
}
function pushAnswerability(
lines: string[],
assessment: NonNullable<ReviewBoardReport["answerability"]>[keyof NonNullable<ReviewBoardReport["answerability"]>] | undefined,
indent: string,
): void {
if (!assessment) return;
const suffix = assessment.level === "partial" ? " — causal fields exist, but human content judgment is still required" : "";
lines.push(`${indent}(Answerability: ${assessment.level}${suffix})`);
lines.push(`${indent}Output permission: ${assessment.outputPermission}`);
}
function pushVersionAnalysis<TFacts>(
lines: string[],
mechanism: VersionedMechanismFacts<TFacts>,
coverage: VersionCoverage,
formatFacts: (facts: TFacts) => string,
bullet: string,
): void {
lines.push(" Version analysis by producer version");
lines.push(` Version-stamp coverage (all evidence/rejection records, not mechanism problem counts): Coverage: ${formatCoveragePercent(coverage.coveragePercent)} of ${formatInteger(coverage.totalEvents)} records carry a version stamp (${formatInteger(coverage.currentVersionEvents)} current, ${formatInteger(coverage.previousVersionEvents)} previous, ${formatInteger(coverage.unknownVersionEvents)} unknown/unversioned). Comparison will become meaningful as new events accumulate.`);
if (coverage.isTransitional) {
lines.push(" NOTE: Version coverage is below 50%. Current-version comparisons may not be representative.");
}
lines.push(` ${mechanismOpportunityDescription(mechanism)}`);
for (const group of ["current", "previous", "unknown_unversioned"] as const) {
const bucket = mechanism.buckets[group];
lines.push(` ${bullet} ${bucket.label}: opportunities=${bucket.opportunityCount}, observed=${bucket.observedPatternCount}, sample=${bucket.sampleAssessment}, answerability=${bucket.answerabilityLevel}`);
if (Object.keys(bucket.producerVersions).length > 0) lines.push(` producer versions: ${formatCounts(bucket.producerVersions)}`);
lines.push(` composition: ${formatVersionAvailability(bucket.versionAvailability)}`);
lines.push(` facts: ${formatFacts(bucket.facts)}`);
}
lines.push(` ${bullet} inference: ${mechanism.inference.message}`);
lines.push(` diagnostic strength: ${diagnosticStrengthLabel(mechanism)}`);
const diagnosticLine = currentMechanismDiagnosticLine(mechanism);
if (diagnosticLine) lines.push(` ${diagnosticLine}`);
if (mechanism.diagnosticQuestions) {
for (const question of mechanism.diagnosticQuestions) {
lines.push(` diagnostic question: ${question.question} Evidence: ${question.evidence.join(", ")}`);
}
}
lines.push(` caveat: ${mechanism.inference.caveat}`);
}
function diagnosticStrengthLabel<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string {
const current = mechanism.buckets.current;
const strength = mechanism.inference.status === "no_current_version_opportunities" || current.opportunityCount === 0
? "unavailable"
: current.opportunityCount < mechanism.sampleThreshold
? "weak"
: "moderate";
return hasCurrentCausalDetail(mechanism) ? `${strength}; causal detail available` : strength;
}
function hasCurrentCausalDetail<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): boolean {
const currentFacts = mechanism.buckets.current.facts;
return isReinforcementVersionFacts(currentFacts) && Object.keys(currentFacts.blocksByExactReason).length > 0;
}
function currentMechanismDiagnosticLine<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string | undefined {
const current = mechanism.buckets.current;
if (!isReinforcementVersionFacts(current.facts)) return undefined;
const facts = current.facts;
const parts: string[] = [];
if (Object.keys(facts.blocksByExactReason).length > 0) parts.push(`current block reasons=${formatCounts(facts.blocksByExactReason)}`);
if (facts.blockDetailsMissing > 0) parts.push(`current block details missing=${facts.blockDetailsMissing}`);
if (parts.length === 0) return undefined;
parts.push(`sample=${current.opportunityCount} attempts`);
return `diagnostic: ${parts.join("; ")}`;
}
function isReinforcementVersionFacts(facts: unknown): facts is ReinforcementVersionFacts {
return typeof facts === "object"
&& facts !== null
&& "blocksByExactReason" in facts
&& "blockDetailsMissing" in facts
&& "windowBlockedEvents" in facts;
}
function mechanismOpportunityDescription<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string {
if (mechanism.opportunityName === "rejection candidates") return "Mechanism opportunities below are reviewable rejection candidates only; render/accounting events are excluded.";
if (mechanism.opportunityName === "attempts") return "Mechanism opportunities below are reinforcement attempts only; render/accounting events are excluded.";
if (mechanism.opportunityName === "capacity removals") return "Mechanism opportunities below are capacity removals only; render/accounting events are excluded.";
return `Mechanism opportunities below are ${mechanism.opportunityName} only; render/accounting events are excluded.`;
}
function formatVersionAvailability(availability: VersionAvailability): string {
const parts = [
availability.noProducerFields > 0 ? `no producer fields=${availability.noProducerFields}` : undefined,
availability.unknownProducerVersion > 0 ? `unknown version=${availability.unknownProducerVersion}` : undefined,
availability.emptyProducerVersion > 0 ? `empty version=${availability.emptyProducerVersion}` : undefined,
availability.knownProducerVersion > 0 ? `known version=${availability.knownProducerVersion}` : undefined,
].filter((part): part is string => Boolean(part));
return parts.length === 0 ? "(empty bucket)" : parts.join(", ");
}
function formatRejectionVersionFacts(facts: RejectionVersionFacts): string {
return `records=${facts.totalRecords}, candidates=${facts.candidateRecords}, raw reason codes=${formatCounts(facts.byRawReasonCode)}, types=${formatCounts(facts.byType)}`;
}
function formatReinforcementVersionFacts(facts: ReinforcementVersionFacts): string {
return `reinforce attempts=${facts.reinforceEvents}, window blocked=${facts.windowBlockedEvents}, exact reasons=${formatCounts(facts.blocksByExactReason)}, block details missing=${facts.blockDetailsMissing}`;
}
function formatEvictionVersionFacts(facts: EvictionVersionFacts): string {
return `capacity removals=${facts.removedByCapacity}, with snapshot=${facts.recentCapacityRemovalsWithSnapshot}, missing snapshot=${facts.capacitySnapshotsMissing}`;
}
function pushSystemMechanismCandidates(lines: string[], report: ReviewBoardReport, bullet: string): void {
const display = report.provenanceContext.candidateDisplay;
if (report.provenanceContext.candidateLimit && display && display.shown < display.total) {
lines.push(`System mechanism review candidates (representative; ${display.shown} shown of ${display.total} total; limit ${report.provenanceContext.candidateLimit} per mechanism category)`);
} else {
lines.push("System mechanism review candidates");
}
pushCandidateGroup(lines, "Rejection filter evidence", candidatesFor(report, ["rejection_rule_evidence"]), bullet);
pushCandidateGroup(lines, "Re-absorption evidence", candidatesFor(report, ["reabsorption_evidence"]), bullet);
pushCandidateGroup(lines, "Reinforcement rule evidence", candidatesFor(report, ["numbered_command_evidence"]), bullet);
pushCandidateGroup(lines, "Eviction/cap evidence", candidatesFor(report, ["eviction_cap_evidence", "missing_evidence"]), bullet);
pushCandidateGroup(lines, "Identity/dedup evidence", candidatesFor(report, ["identity_dedup_evidence"]), bullet);
}
function pushCandidateGroup(lines: string[], title: string, candidates: ReviewBoardCandidate[], bullet: string): void {
lines.push(` ${title}`);
if (candidates.length === 0) {
lines.push(" (none)");
return;
}
const shared = sharedProvenance(candidates);
if (shared) lines.push(` shared provenance for displayed candidates in this group: ${formatProvenance(shared)}`);
for (const candidate of candidates) pushCandidate(lines, candidate, bullet, shared);
}
function pushCandidate(lines: string[], candidate: ReviewBoardCandidate, bullet: string, groupProvenance?: CandidateProvenance): void {
const rawReasonCodes = candidate.evidence.rawReasonCodes && candidate.evidence.rawReasonCodes.length > 0
? candidate.evidence.rawReasonCodes.join(", ")
: "none";
const question = candidate.reviewQuestions[0] ?? "What should a reviewer infer from this evidence?";
lines.push(` ${bullet} concern=${formatConcern(candidate.concernKind)} id=${candidate.id} source=${candidate.source} mechanism=${candidate.mechanism ?? "unspecified"} raw reason codes=${rawReasonCodes} question=${question}`);
if (candidate.provenance && (!groupProvenance || formatProvenance(candidate.provenance) !== formatProvenance(groupProvenance))) {
lines.push(` provenance: ${formatProvenance(candidate.provenance)}`);
}
if (candidate.evidence.eventIds && candidate.evidence.eventIds.length > 0) lines.push(` event ids: ${candidate.evidence.eventIds.join(", ")}`);
if (candidate.evidence.textAvailable) {
lines.push(` text preview: ${candidate.evidence.textPreview ?? "available but empty after redaction"}`);
} else {
lines.push(" text preview: unavailable in historical evidence");
}
lines.push(` facts: ${formatCandidateFacts(candidate.facts)}`);
pushHeuristicFlags(lines, candidate.heuristicFlags, " ", bullet);
}
function pushMemoryContentCandidates(lines: string[], report: ReviewBoardReport, bullet: string, options: { verbose?: boolean }): void {
const display = report.activeMemoryDisplay;
lines.push("Memory content review candidates");
if (report.reviewQuestions.memoryContent.length > 0) {
lines.push(" Standard review questions (applicable to all active memories below):");
for (const question of report.reviewQuestions.memoryContent) lines.push(` ${bullet} ${question}`);
}
if (display.total === 0) {
lines.push(" Active memories (none)");
return;
}
if (display.total <= display.threshold) {
lines.push(` Active memories (showing all ${display.total} because <= ${display.threshold})`);
} else if (display.mode === "all" || options.verbose) {
lines.push(` Active memories (showing all ${display.total} because --verbose)`);
} else {
lines.push(` Active memories (showing ${display.shown} of ${display.total})`);
lines.push(` Showing ${display.shown} of ${display.total} active memories. Use --verbose or --json for all active memory text.`);
}
display.items.forEach((item, index) => pushActiveMemory(lines, item, index + 1, bullet, report.reviewQuestions.memoryContent));
}
function pushActiveMemory(lines: string[], item: ReviewBoardActiveMemory, index: number, bullet: string, standardQuestions: string[]): void {
const strength = typeof item.strength === "number" ? item.strength.toFixed(3) : "unknown";
lines.push(` [${index}] id=${item.id} type=${item.type} source=${item.source} status=${item.status} strength=${strength}`);
lines.push(" text: " + indentContinuation(item.text, " "));
const rawReasonCodes = item.evidence.rawReasonCodes.length > 0 ? item.evidence.rawReasonCodes.join(", ") : "none";
lines.push(` evidence: events=${item.evidence.eventCount} raw reason codes=${rawReasonCodes}`);
if (item.provenance) lines.push(` provenance: ${formatProvenance(item.provenance)}`);
pushHeuristicFlags(lines, item.heuristicFlags, " ", bullet);
if (questionsEqual(item.reviewQuestions, standardQuestions)) return;
const additionalQuestions = item.reviewQuestions.filter(question => !standardQuestions.includes(question));
if (additionalQuestions.length > 0 && additionalQuestions.length < item.reviewQuestions.length) {
lines.push(" additional review questions:");
for (const question of additionalQuestions) lines.push(` ${bullet} ${question}`);
return;
}
lines.push(" review questions:");
for (const question of item.reviewQuestions) lines.push(` ${bullet} ${question}`);
}
function pushHeuristicFlags(lines: string[], flags: HeuristicFlag[], indent: string, bullet: string): void {
if (flags.length === 0) return;
lines.push(`${indent}heuristic flags:`);
for (const flag of flags) {
const caveat = flag.caveat || REVIEW_FLAG_CAVEAT;
lines.push(`${indent} ${bullet} ${flag.label}: ${flag.evidence}. ${caveat}`);
}
}
function pushReviewQuestions(lines: string[], report: ReviewBoardReport, bullet: string): void {
lines.push("Review questions");
lines.push(" SYSTEM MECHANISM");
for (const question of report.reviewQuestions.systemMechanism) lines.push(` ${bullet} ${question}`);
lines.push(" MEMORY CONTENT");
for (const question of report.reviewQuestions.memoryContent) lines.push(` ${bullet} ${question}`);
}
function pushNextCommands(lines: string[], report: ReviewBoardReport, bullet: string): void {
lines.push("Next commands");
for (const command of report.nextCommands) lines.push(` ${bullet} ${command}`);
}
function candidatesFor(report: ReviewBoardReport, sources: ReviewBoardCandidate["source"][]): ReviewBoardCandidate[] {
const sourceSet = new Set(sources);
return report.reviewCandidates.filter(candidate => candidate.concernKind === "system_mechanism" && sourceSet.has(candidate.source));
}
function sharedProvenance(candidates: ReviewBoardCandidate[]): CandidateProvenance | undefined {
if (candidates.length <= 1) return undefined;
const first = candidates[0]?.provenance;
if (!first) return undefined;
const key = formatProvenance(first);
return candidates.every(candidate => candidate.provenance && formatProvenance(candidate.provenance) === key) ? first : undefined;
}
function formatConcern(concern: ReviewBoardCandidate["concernKind"]): string {
return concern === "system_mechanism" ? "SYSTEM MECHANISM" : "MEMORY CONTENT";
}
function formatMigrationTimeline(timeline: ReviewBoardReport["provenanceContext"]["migrationTimeline"]): string {
if (timeline.length === 0) return "(none)";
return timeline.map(row => `${row.migrationId}=${row.presentInStore ? "present" : "absent"}${row.firstEvidenceAt ? ` firstEvidenceAt=${row.firstEvidenceAt}` : ""}`).join(", ");
}
function formatProvenanceCounts(counts: Record<ProvenanceClassification, number>): string {
return PROVENANCE_ORDER.map(classification => `${classification}=${counts[classification] ?? 0}`).join(", ");
}
function formatCounts(counts: Record<string, number>): string {
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
return entries.length === 0 ? "(none)" : entries.map(([key, count]) => `${key}=${count}`).join(", ");
}
function formatTypeCountsWithCaps(counts: Record<string, number>, caps: Record<string, number>): string {
const keys = uniqueSorted([...Object.keys(caps), ...Object.keys(counts)]);
return keys.length === 0 ? "(none)" : keys.map(key => `${key} ${counts[key] ?? 0}/${caps[key] ?? "?"}`).join(", ");
}
function formatFullCaps(fullCaps: string[], typeCounts: Record<string, number>, typeCaps: Record<string, number>, active: number, maxEntries: number): string {
if (fullCaps.length === 0) return "(none)";
return fullCaps.map(cap => cap === "global" ? `global ${active}/${maxEntries}` : `${cap} ${typeCounts[cap] ?? 0}/${typeCaps[cap] ?? "?"}`).join(", ");
}
function formatRepeatedBlocks(blocks: ReviewBoardReport["facts"]["systemMechanisms"]["reinforcementRules"]["repeatedBlocksByMemory"]): string {
if (blocks.length === 0) return "(none)";
return blocks.map(block => `${block.memoryId} count=${block.count} refs=${block.refs.join("|") || "none"} raw reason codes=${block.rawReasonCodes.join("|") || "none"}`).join(", ");
}
function formatHighestRankRemoved(snapshot: NonNullable<ReviewBoardReport["facts"]["systemMechanisms"]["evictionAndCaps"]["highestRankRemoved"]>): string {
const parts = [
`eventId=${snapshot.eventId}`,
snapshot.memoryId ? `memoryId=${snapshot.memoryId}` : undefined,
snapshot.type ? `type=${snapshot.type}` : undefined,
`rankAtRemoval=${snapshot.rankAtRemoval}`,
typeof snapshot.strengthAtRemoval === "number" ? `strengthAtRemoval=${snapshot.strengthAtRemoval}` : undefined,
].filter((part): part is string => Boolean(part));
return parts.join(" ");
}
function formatMemoryPreviews(items: ReviewBoardReport["facts"]["memoryContent"]["weakestActiveMemories"]): string {
if (items.length === 0) return "(none)";
return items.map(item => `${item.id} type=${item.type} strength=${typeof item.strength === "number" ? item.strength.toFixed(3) : "unknown"} text=${JSON.stringify(item.textPreview)}`).join(" | ");
}
function formatPercent(value: number): string {
return `${(Number.isFinite(value) ? value * 100 : 0).toFixed(1)}%`;
}
function formatCoveragePercent(value: number): string {
if (!Number.isFinite(value)) return "0%";
return Number.isInteger(value) ? `${value}%` : `${value.toFixed(1)}%`;
}
function formatInteger(value: number): string {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
}
function formatProvenance(provenance: CandidateProvenance): string {
return `${provenance.classification} confidence=${provenance.confidence}; basis=${provenance.basis.join("; ") || "unavailable"}; caveat=${provenance.interpretationCaveat}`;
}
function formatCandidateFacts(facts: Record<string, unknown>): string {
if (Object.keys(facts).length === 0) return "(none)";
return formatDetails(Object.fromEntries(
Object.entries(facts).map(([key, value]) => [key, formatFactValue(value)]),
));
}
function formatFactValue(value: unknown): string | number | boolean | string[] | undefined {
if (value === undefined) return undefined;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (value === null) return "null";
if (Array.isArray(value)) return value.map(item => typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null ? String(item) : stringifyUnknown(item));
return stringifyUnknown(value);
}
function stringifyUnknown(value: unknown): string {
try {
const serialized = JSON.stringify(value);
return serialized === undefined ? String(value) : serialized;
} catch {
return String(value);
}
}
function questionsEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function indentContinuation(text: string, indent: string): string {
return text.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values)].sort();
}
function redactUnknown(value: unknown): unknown {
if (typeof value === "string") return cleanText(value, false);
if (Array.isArray(value)) return value.map(item => redactUnknown(item));
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactUnknown(item)]));
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -27,6 +27,11 @@ export function normalizeRejection(record: RejectionLogRecord): NormalizedReject
source: record.source,
origin,
fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger",
producerName: typeof record.producerName === "string" ? record.producerName : undefined,
producerVersion: typeof record.producerVersion === "string" ? record.producerVersion : undefined,
instrumentationVersion: typeof record.instrumentationVersion === "number" ? record.instrumentationVersion : undefined,
decisionLogicName: typeof record.decisionLogicName === "string" ? record.decisionLogicName : undefined,
decisionLogicVersion: typeof record.decisionLogicVersion === "number" ? record.decisionLogicVersion : undefined,
text: record.text,
reasons: record.reasons,
};
+10
View File
@@ -93,6 +93,11 @@ export type RejectionLogRecord = {
fromTrigger?: boolean;
text?: string;
reasons?: string[];
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
decisionLogicName?: string;
decisionLogicVersion?: number;
};
export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
@@ -102,6 +107,11 @@ export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp"
source?: string;
origin: Origin;
fromTrigger: boolean;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
decisionLogicName?: string;
decisionLogicVersion?: number;
};
export type MigrationLogRecord = {
+8 -1
View File
@@ -4,6 +4,7 @@ import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } fr
import { dirname, join } from "node:path";
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
import { redactCredentials } from "./redaction.ts";
import { producerFields } from "./instrumentation.ts";
export type EvidenceEventType =
| "extraction_candidate_accepted"
@@ -95,6 +96,9 @@ export type EvidenceEventV1 = {
workspaceRootHash: string;
sessionHash?: string;
messageHash?: string;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
type: EvidenceEventType;
phase: EvidencePhase;
outcome: EvidenceOutcome;
@@ -273,7 +277,10 @@ function buildEvidenceEvent(
if (details) event.details = details;
if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax);
return event;
return {
...event,
...producerFields(),
};
}
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
+9
View File
@@ -7,6 +7,7 @@ import { assessMemoryQuality } from "./memory-quality.ts";
import { extractionRejectionLogPath } from "./paths.ts";
import { redactCredentials } from "./redaction.ts";
import type { EvidenceEventInput } from "./evidence-log.ts";
import { producerFields } from "./instrumentation.ts";
function id(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -329,6 +330,11 @@ type ExtractionRejectionLogEntry = {
source: "compaction";
workspaceKey?: string;
workspaceRootHash?: string;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
decisionLogicName?: string;
decisionLogicVersion?: number;
};
type WorkspaceMemoryCandidateParseOptions = {
@@ -381,6 +387,9 @@ function evaluateWorkspaceMemoryCandidate(
source: "compaction",
workspaceKey: options.workspaceKey,
workspaceRootHash: options.workspaceRootHash,
...producerFields(),
decisionLogicName: "assessMemoryQuality",
decisionLogicVersion: 1,
});
return { accepted: false, reasons: quality.reasons };
}
+41
View File
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
let cachedVersion: string | undefined;
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
const MEMORY_INSTRUMENTATION_VERSION = 3;
function producerVersion(): string {
if (cachedVersion) return cachedVersion;
try {
const candidates = [
join(__dirname, "..", "package.json"),
join(__dirname, "..", "..", "package.json"),
// resolve from compiled dist/src/ -> repo root
];
for (const path of candidates) {
try {
const pkg = JSON.parse(readFileSync(path, "utf8"));
cachedVersion = pkg.version as string;
break;
} catch {
// try next
}
}
if (!cachedVersion) cachedVersion = "unknown";
} catch {
cachedVersion = "unknown";
}
return cachedVersion;
}
export function producerFields(): { producerName: string; producerVersion: string; instrumentationVersion: number } {
return {
producerName: MEMORY_PRODUCER_NAME,
producerVersion: producerVersion(),
instrumentationVersion: MEMORY_INSTRUMENTATION_VERSION,
};
}
+301
View File
@@ -0,0 +1,301 @@
// No OpenCode SDK or TUI imports. Uses only local file-system reads from workspace memory, session state, and pending journal.
import { readFile } from "node:fs/promises";
import { sessionStatePath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "./paths.ts";
import { redactCredentials } from "./redaction.ts";
import type { LongTermMemoryEntry, PendingMemoryJournalStore, SessionState, WorkspaceMemoryStore } from "./types.ts";
import { LONG_TERM_LIMITS } from "./types.ts";
import { accountWorkspaceMemoryCompactionRefs, accountWorkspaceMemoryRender } from "./workspace-memory.ts";
export type MemoryVisibilityCommand = "status" | "list" | "help";
type MemoryListItem = {
ref: string;
text: string;
};
export type MemoryStatusModel = {
activeMemories: number;
supersededMemories: number;
renderedInPrompt: number;
omittedActiveMemories: number;
pendingInSession: number;
pendingJournalMemories: number;
openErrors: number;
recentDecisions: number;
};
export type MemoryListModel = {
activeMemories: number;
renderedMemories: number;
omittedActiveMemories: number;
groups: Record<LongTermMemoryEntry["type"], MemoryListItem[]>;
};
const MAX_PREVIEW_CHARS = 120;
const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermMemoryEntry["type"][];
function safePreview(text: string | undefined, maxChars = MAX_PREVIEW_CHARS): string {
const clean = redactCredentials(text ?? "").replace(/\s+/g, " ").trim();
if (clean.length <= maxChars) return clean;
return `${clean.slice(0, Math.max(0, maxChars - 1)).trimEnd()}`;
}
async function readJSONSnapshot(path: string): Promise<unknown | undefined> {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch {
return undefined;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isLongTermType(value: unknown): value is LongTermMemoryEntry["type"] {
return value === "feedback" || value === "project" || value === "decision" || value === "reference";
}
function isLongTermSource(value: unknown): value is LongTermMemoryEntry["source"] {
return value === "explicit" || value === "compaction" || value === "manual";
}
function isLongTermMemoryEntry(value: unknown): value is LongTermMemoryEntry {
if (!isRecord(value)) return false;
if (typeof value.id !== "string") return false;
if (!isLongTermType(value.type)) return false;
if (typeof value.text !== "string") return false;
if (!isLongTermSource(value.source)) return false;
if (typeof value.confidence !== "number") return false;
if (value.status !== "active" && value.status !== "superseded") return false;
if (typeof value.createdAt !== "string") return false;
return typeof value.updatedAt === "string";
}
function memoryEntries(value: unknown): LongTermMemoryEntry[] {
return Array.isArray(value) ? value.filter(isLongTermMemoryEntry) : [];
}
async function emptyWorkspaceMemorySnapshot(root: string): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: {
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: LONG_TERM_LIMITS.maxEntries,
},
entries: [],
migrations: [],
updatedAt: nowIso,
lastActivityAt: nowIso,
};
}
async function readWorkspaceMemorySnapshot(root: string): Promise<WorkspaceMemoryStore> {
const fallback = await emptyWorkspaceMemorySnapshot(root);
const loaded = await readJSONSnapshot(await workspaceMemoryPath(root));
if (!isRecord(loaded)) return fallback;
const limits = isRecord(loaded.limits) ? loaded.limits : {};
return {
version: 1,
workspace: fallback.workspace,
limits: {
maxRenderedChars: typeof limits.maxRenderedChars === "number" ? limits.maxRenderedChars : LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: typeof limits.maxEntries === "number" ? limits.maxEntries : LONG_TERM_LIMITS.maxEntries,
},
entries: memoryEntries(loaded.entries),
migrations: Array.isArray(loaded.migrations) ? loaded.migrations.filter(item => typeof item === "string") : [],
updatedAt: typeof loaded.updatedAt === "string" ? loaded.updatedAt : fallback.updatedAt,
lastActivityAt: typeof loaded.lastActivityAt === "string" ? loaded.lastActivityAt : undefined,
};
}
async function emptyPendingJournalSnapshot(root: string): Promise<PendingMemoryJournalStore> {
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
entries: [],
updatedAt: new Date().toISOString(),
};
}
async function readPendingJournalSnapshot(root: string): Promise<PendingMemoryJournalStore> {
const fallback = await emptyPendingJournalSnapshot(root);
const loaded = await readJSONSnapshot(await workspacePendingJournalPath(root));
if (!isRecord(loaded)) return fallback;
return {
version: 1,
workspace: fallback.workspace,
entries: memoryEntries(loaded.entries),
updatedAt: typeof loaded.updatedAt === "string" ? loaded.updatedAt : fallback.updatedAt,
};
}
function emptySessionStateSnapshot(sessionID: string): SessionState {
return {
version: 1,
sessionID,
turn: 0,
updatedAt: new Date().toISOString(),
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [],
compactionMemoryRefs: [],
};
}
async function readSessionStateSnapshot(root: string, sessionID: string): Promise<SessionState> {
const fallback = emptySessionStateSnapshot(sessionID);
const loaded = await readJSONSnapshot(await sessionStatePath(root, sessionID));
if (!isRecord(loaded)) return fallback;
return {
...fallback,
turn: typeof loaded.turn === "number" ? loaded.turn : fallback.turn,
updatedAt: typeof loaded.updatedAt === "string" ? loaded.updatedAt : fallback.updatedAt,
activeFiles: Array.isArray(loaded.activeFiles) ? loaded.activeFiles as SessionState["activeFiles"] : [],
openErrors: Array.isArray(loaded.openErrors) ? loaded.openErrors as SessionState["openErrors"] : [],
recentDecisions: Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions as SessionState["recentDecisions"] : [],
pendingMemories: memoryEntries(loaded.pendingMemories),
compactionMemoryRefs: Array.isArray(loaded.compactionMemoryRefs) ? loaded.compactionMemoryRefs as SessionState["compactionMemoryRefs"] : [],
};
}
export async function getMemoryStatus(root: string, sessionID: string): Promise<MemoryStatusModel> {
const [store, sessionState, pendingJournal] = await Promise.all([
readWorkspaceMemorySnapshot(root),
readSessionStateSnapshot(root, sessionID),
readPendingJournalSnapshot(root),
]);
const renderAccounting = accountWorkspaceMemoryRender(store);
const activeEntries = store.entries.filter(entry => entry.status !== "superseded");
const supersededEntries = store.entries.filter(entry => entry.status === "superseded");
return {
activeMemories: activeEntries.length,
supersededMemories: supersededEntries.length,
renderedInPrompt: renderAccounting.rendered.length,
omittedActiveMemories: renderAccounting.omitted.filter(item => item.memory.status !== "superseded").length,
pendingInSession: sessionState.pendingMemories.length,
pendingJournalMemories: pendingJournal.entries.length,
openErrors: sessionState.openErrors.filter(error => error.status === "open").length,
recentDecisions: sessionState.recentDecisions.length,
};
}
export function formatMemoryStatus(model: MemoryStatusModel): string {
return [
"## Memory status",
"",
"Workspace:",
`- Active memories: ${model.activeMemories}`,
`- Rendered in prompt: ${model.renderedInPrompt}`,
`- Omitted active memories: ${model.omittedActiveMemories}`,
`- Superseded memories: ${model.supersededMemories}`,
"",
"Pending:",
`- Pending in this session: ${model.pendingInSession}`,
`- Pending journal memories: ${model.pendingJournalMemories}`,
"",
"Session:",
`- Open errors: ${model.openErrors}`,
`- Recent decisions: ${model.recentDecisions}`,
"",
`Use /memory → Current memories to browse current [M1]-[M${LONG_TERM_LIMITS.maxEntries}] memory refs.`,
"",
"Local only: no LLM request was made.",
].join("\n");
}
function emptyMemoryListGroups(): MemoryListModel["groups"] {
return { feedback: [], project: [], decision: [], reference: [] };
}
export async function getMemoryList(root: string): Promise<MemoryListModel> {
const store = await readWorkspaceMemorySnapshot(root);
const accounting = accountWorkspaceMemoryCompactionRefs(store);
const groups = emptyMemoryListGroups();
const renderedMemoryIds = new Set(accounting.rendered.map(memory => memory.id));
for (const ref of accounting.refs) {
if (!renderedMemoryIds.has(ref.memoryId)) continue;
groups[ref.type].push({
ref: ref.ref,
text: safePreview(ref.textPreview),
});
}
const renderedMemories = MEMORY_TYPE_ORDER.reduce((total, type) => total + groups[type].length, 0);
return {
activeMemories: store.entries.filter(entry => entry.status !== "superseded").length,
renderedMemories,
omittedActiveMemories: accounting.omitted.filter(item => item.memory.status !== "superseded").length,
groups,
};
}
export function formatMemoryList(model: MemoryListModel): string {
const lines = [
"## Current workspace memories",
"",
];
if (model.renderedMemories === 0) {
lines.push("No active workspace memories are stored yet.", "", "Local only: no LLM request was made.");
return lines.join("\n");
}
lines.push("Display refs are local to this output and may change after memory updates.", "");
for (const type of MEMORY_TYPE_ORDER) {
const group = model.groups[type];
if (group.length === 0) continue;
lines.push(`${type}:`);
for (const item of group) {
lines.push(`- [${item.ref}] ${item.text}`);
}
lines.push("");
}
lines.push(
`Shown: ${model.renderedMemories} of ${model.activeMemories} active memories.`,
`Omitted active memories: ${model.omittedActiveMemories}.`,
"",
"Local only: no LLM request was made.",
);
return lines.join("\n");
}
export function formatMemoryHelp(): string {
return [
"## Memory help",
"",
"Command:",
"- /memory — open the local memory menu.",
"",
"Menu entries:",
"- Status — show local memory statistics.",
`- Current memories — browse active workspace memories as display-local [M1]-[M${LONG_TERM_LIMITS.maxEntries}] refs.`,
"- Help — show this help.",
"",
"These commands are read-only, local-only, and do not call the LLM.",
].join("\n");
}
export async function renderMemoryCommand(root: string, sessionID: string, command: MemoryVisibilityCommand): Promise<string> {
switch (command) {
case "status":
return formatMemoryStatus(await getMemoryStatus(root, sessionID));
case "list":
return formatMemoryList(await getMemoryList(root));
case "help":
return formatMemoryHelp();
default:
return formatMemoryHelp();
}
}
+38 -6
View File
@@ -44,7 +44,7 @@ import {
workspaceMemoryExactKey,
workspaceMemoryIdentityKey,
} from "./workspace-memory.ts";
import { reinforceMemory } from "./retention.ts";
import { REINFORCEMENT_MAX_COUNT, tryReinforceMemory, type ReinforcementDecision } from "./retention.ts";
import {
appendPendingMemories,
clearPendingMemories,
@@ -311,6 +311,21 @@ export const MemoryV2Plugin: Plugin = async (input) => {
};
}
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
return {
attemptedAtMs: decision.attemptedAt,
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
...(decision.lastReinforcedAt !== undefined ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
requiredElapsedMs: decision.requiredElapsedMs,
sameSession: decision.sameSession,
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
};
}
function replacementMemoryId(): string {
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
@@ -429,17 +444,33 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const { refSnapshot, target, targetIndex } = resolution;
if (command.kind === "REINFORCE") {
const reinforced = reinforceMemory(target, sessionID, now);
if (reinforced === target) {
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked"], {
const decision = tryReinforceMemory(target, sessionID, now);
if (decision.outcome === "blocked") {
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], {
memoryId: refSnapshot.memoryId,
blockReason: decision.blockReason,
...reinforcementDecisionTimingDetails(decision),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
}));
continue;
}
const reinforced = decision.memory;
workspaceMemory.entries[targetIndex] = reinforced;
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
const reasonCodes = ["numbered_ref_reinforce", "reinforcement_window_allowed"];
if (decision.reinforcementMode === "refresh_only") {
reasonCodes.push("reinforcement_saturation_refresh");
}
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", reasonCodes, {
memoryId: refSnapshot.memoryId,
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
reinforcementMode: decision.reinforcementMode,
...reinforcementDecisionTimingDetails(decision),
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
reinforcementCount: decision.newReinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
}));
continue;
}
@@ -662,11 +693,12 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const key = memoryKey(memory);
const existing = existingByKey.get(key);
if (existing) {
const reinforced = reinforceMemory(
const decision = tryReinforceMemory(
existing.memory,
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
promotedAt,
);
const reinforced = decision.memory;
if (reinforced !== existing.memory) {
workspaceMemory.entries[existing.index] = reinforced;
existingByKey.set(key, { memory: reinforced, index: existing.index });
+96 -29
View File
@@ -1,13 +1,52 @@
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
export type ReinforcementBlockReason =
| "min_elapsed_window"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "same_session"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "same_utc_day"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "min_interval"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "max_count";
export type ReinforcementMode = "increment" | "refresh_only";
type ReinforcementDecisionMetadata = {
attemptedAt: number;
lastReinforcedAt?: number;
elapsedMs?: number;
requiredElapsedMs: number;
sameSession: boolean;
legacyMissingTimestamp?: boolean;
};
export type ReinforcementDecision =
| ({
outcome: "reinforced";
memory: LongTermMemoryEntry;
previousReinforcementCount: number;
newReinforcementCount: number;
reinforcementMode: ReinforcementMode;
} & ReinforcementDecisionMetadata)
| ({
outcome: "blocked";
memory: LongTermMemoryEntry;
blockReason: ReinforcementBlockReason;
reinforcementCount: number;
maxReinforcementCount: number;
} & ReinforcementDecisionMetadata);
// Retention decay model constants (v1.5)
export const BASE_HALF_LIFE_DAYS = 45;
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
export const REINFORCEMENT_MAX_COUNT = 6;
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const DAY_MS = 24 * 60 * 60 * 1000;
export const REINFORCEMENT_MIN_ELAPSED_MS = 7 * DAY_MS;
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // Deprecated compatibility constant; new policy uses REINFORCEMENT_MIN_ELAPSED_MS.
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
export const DORMANT_DECAY_MULTIPLIER = 0.25;
export const DAY_MS = 24 * 60 * 60 * 1000;
export const TYPE_FACTOR = {
reference: 1.0,
@@ -108,41 +147,69 @@ export function calculateEffectiveAgeDays(
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
}
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
const d1 = new Date(ts1);
const d2 = new Date(ts2);
return d1.getUTCFullYear() === d2.getUTCFullYear()
&& d1.getUTCMonth() === d2.getUTCMonth()
&& d1.getUTCDate() === d2.getUTCDate();
}
export function reinforceMemory(
export function tryReinforceMemory(
memory: LongTermMemoryEntry,
sessionId: string,
now: number,
): LongTermMemoryEntry {
if (memory.lastReinforcedSessionID === sessionId) {
return memory;
): ReinforcementDecision {
const count = memory.reinforcementCount ?? 0;
const lastAt = validLastReinforcedAt(memory.lastReinforcedAt);
const lastSession = memory.lastReinforcedSessionID;
const sameSession = lastSession === sessionId;
const legacyMissingTimestamp = count > 0 && lastAt === undefined;
const metadata: ReinforcementDecisionMetadata = {
attemptedAt: now,
...(lastAt !== undefined ? {
lastReinforcedAt: lastAt,
elapsedMs: now - lastAt,
} : {}),
requiredElapsedMs: REINFORCEMENT_MIN_ELAPSED_MS,
sameSession,
...(legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
};
if (lastAt !== undefined && now - lastAt < REINFORCEMENT_MIN_ELAPSED_MS) {
return blockedDecision(memory, "min_elapsed_window", count, metadata);
}
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
return memory;
}
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
return memory;
}
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
return memory;
}
return {
const reinforcementMode: ReinforcementMode = count >= REINFORCEMENT_MAX_COUNT
? "refresh_only"
: "increment";
const newReinforcementCount = reinforcementMode === "refresh_only" ? count : count + 1;
const reinforced: LongTermMemoryEntry = {
...memory,
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
reinforcementCount: newReinforcementCount,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
retentionClock: now,
};
return {
outcome: "reinforced",
memory: reinforced,
previousReinforcementCount: count,
newReinforcementCount,
reinforcementMode,
...metadata,
};
}
function validLastReinforcedAt(value: unknown): number | undefined {
if (typeof value !== "number") return undefined;
return Number.isFinite(value) && value > 0 ? value : undefined;
}
function blockedDecision(
memory: LongTermMemoryEntry,
blockReason: ReinforcementBlockReason,
reinforcementCount: number,
metadata: ReinforcementDecisionMetadata,
): ReinforcementDecision {
return {
outcome: "blocked",
memory,
blockReason,
reinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
...metadata,
};
}
+304
View File
@@ -0,0 +1,304 @@
import {
formatMemoryHelp,
formatMemoryList,
getMemoryList,
renderMemoryCommand,
type MemoryVisibilityCommand,
} from "./memory-visibility.ts";
type DialogContext = {
clear?: () => void;
};
type DialogSize = "medium" | "large" | "xlarge";
type DialogElement = unknown;
type DialogStackContext = {
clear?: () => void;
replace?: (render: () => DialogElement, onClose?: () => void) => void;
setSize?: (size: DialogSize) => void;
};
type DialogAlertComponent = (props: { title: string; message: string; onConfirm?: () => void }) => DialogElement;
type DialogSelectOption<Value = string> = {
title: string;
value: Value;
description?: string;
footer?: string;
category?: string;
disabled?: boolean;
onSelect?: () => void | Promise<void>;
};
type DialogSelectProps<Value = string> = {
title: string;
placeholder?: string;
options: DialogSelectOption<Value>[];
onSelect?: (option: DialogSelectOption<Value>) => void | Promise<void>;
skipFilter?: boolean;
};
type DialogSelectComponent = <Value = string>(props: DialogSelectProps<Value>) => DialogElement;
type TuiCommand = {
title: string;
value: string;
description?: string;
category?: string;
suggested?: boolean;
slash?: {
name: string;
aliases?: string[];
};
onSelect?: (dialog?: DialogContext) => void | Promise<void>;
};
type TuiRouteCurrent =
| { name: "home" }
| { name: "session"; params: { sessionID: string; prompt?: unknown } }
| { name: string; params?: Record<string, unknown> };
type TuiPluginApi = {
command: {
register: (cb: () => TuiCommand[]) => () => void;
};
route: ({ readonly current: TuiRouteCurrent } | TuiRouteCurrent);
ui: {
DialogAlert?: DialogAlertComponent;
DialogSelect?: DialogSelectComponent;
toast: (input: { variant?: "info" | "success" | "warning" | "error"; message: string }) => void;
dialog?: DialogStackContext;
};
state: {
path: {
directory: string;
};
};
client?: unknown;
};
type TuiPlugin = (api: TuiPluginApi, options: unknown, meta: unknown) => Promise<void>;
function currentRoute(api: TuiPluginApi): TuiRouteCurrent {
const route = api.route as ({ readonly current?: TuiRouteCurrent } & Partial<TuiRouteCurrent>);
return route.current ?? (route as TuiRouteCurrent);
}
function renderErrorReport(error: unknown): string {
const detail = error instanceof Error ? error.message : String(error);
return [
"## Memory error",
"",
"Unable to render local memory visibility output.",
`Error: ${detail}`,
"",
"Local only: no LLM request was made.",
].join("\n");
}
function dialogSizeForCommand(command: MemoryVisibilityCommand): DialogSize {
if (command === "list") return "xlarge";
if (command === "status") return "large";
return "medium";
}
function fallbackTitleForCommand(command: MemoryVisibilityCommand): string {
if (command === "list") return "Current workspace memories";
if (command === "status") return "Memory status";
return "Memory help";
}
function dialogCopyFromMarkdown(text: string, fallbackTitle: string): { title: string; message: string } {
const match = /^##\s+(.+)$/m.exec(text);
if (!match) return { title: fallbackTitle, message: text };
const headingStart = match.index;
const headingEnd = text.indexOf("\n", headingStart);
const before = text.slice(0, headingStart);
const after = headingEnd === -1 ? "" : text.slice(headingEnd + 1);
return {
title: match[1].trim(),
message: `${before}${after}`.replace(/^\s+/, ""),
};
}
function getDialogApi(api: TuiPluginApi): {
DialogAlert: DialogAlertComponent;
DialogSelect: DialogSelectComponent;
dialog: Required<Pick<DialogStackContext, "replace" | "setSize">>;
} | undefined {
if (
typeof api.ui.DialogAlert !== "function" ||
typeof api.ui.DialogSelect !== "function" ||
typeof api.ui.dialog?.replace !== "function" ||
typeof api.ui.dialog?.setSize !== "function"
) {
api.ui.toast({
variant: "error",
message: "Memory dialog UI is unavailable in this OpenCode runtime.",
});
return undefined;
}
return {
DialogAlert: api.ui.DialogAlert,
DialogSelect: api.ui.DialogSelect,
dialog: {
replace: api.ui.dialog.replace,
setSize: api.ui.dialog.setSize,
},
};
}
function showDialogError(api: TuiPluginApi, error: unknown): void {
const detail = error instanceof Error ? error.message : String(error);
api.ui.toast({
variant: "error",
message: `Unable to show memory dialog: ${detail}`,
});
}
function showAlertFromMarkdown(api: TuiPluginApi, text: string, fallbackTitle: string, size: DialogSize): void {
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
const { title, message } = dialogCopyFromMarkdown(text, fallbackTitle);
try {
dialogApi.dialog.replace(() => dialogApi.DialogAlert({ title, message }));
dialogApi.dialog.setSize(size);
} catch (error) {
showDialogError(api, error);
}
}
function showMemoryMenu(api: TuiPluginApi, dialog?: DialogContext): void {
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
const options: DialogSelectOption[] = [
{
title: "Status",
value: "memory.status",
description: "Show local memory statistics",
onSelect: () => showMemoryStatus(api),
},
{
title: "Current memories",
value: "memory.list",
description: "Browse active workspace memories with display-local refs",
onSelect: () => showMemoryList(api),
},
{
title: "Help",
value: "memory.help",
description: "Show memory command help",
onSelect: () => showMemoryHelp(api),
},
];
try {
dialog?.clear?.();
dialogApi.dialog.replace(() => dialogApi.DialogSelect({
title: "Memory",
placeholder: "Search memory actions",
options,
}));
dialogApi.dialog.setSize("large");
} catch (error) {
showDialogError(api, error);
}
}
async function showMemoryStatus(api: TuiPluginApi): Promise<void> {
const route = currentRoute(api);
if (route.name !== "session" || typeof route.params?.sessionID !== "string") {
api.ui.toast({
variant: "warning",
message: "Open a session to use memory commands.",
});
return;
}
const sessionID = route.params.sessionID;
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
let text: string;
let fallbackTitle = fallbackTitleForCommand("status");
try {
text = await renderMemoryCommand(api.state.path.directory, sessionID, "status");
} catch (error) {
text = renderErrorReport(error);
fallbackTitle = "Memory error";
}
const { title, message } = dialogCopyFromMarkdown(text, fallbackTitle);
try {
dialogApi.dialog.replace(() => dialogApi.DialogAlert({ title, message }));
dialogApi.dialog.setSize(dialogSizeForCommand("status"));
} catch (error) {
showDialogError(api, error);
}
}
function showMemoryHelp(api: TuiPluginApi): void {
showAlertFromMarkdown(api, formatMemoryHelp(), "Memory help", "medium");
}
const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const;
async function showMemoryList(api: TuiPluginApi): Promise<void> {
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
try {
const model = await getMemoryList(api.state.path.directory);
if (model.renderedMemories === 0) {
showAlertFromMarkdown(api, formatMemoryList(model), "Current workspace memories", "medium");
return;
}
const options: DialogSelectOption[] = [];
for (const type of MEMORY_TYPE_ORDER) {
for (const item of model.groups[type]) {
options.push({
title: `[${item.ref}] ${item.text}`,
value: item.ref,
category: type,
footer: "display-local",
});
}
}
dialogApi.dialog.replace(() => dialogApi.DialogSelect({
title: "Current workspace memories",
placeholder: "Search memory refs",
options,
}));
dialogApi.dialog.setSize("xlarge");
} catch (error) {
showAlertFromMarkdown(api, renderErrorReport(error), "Memory error", "medium");
}
}
function memoryCommands(api: TuiPluginApi): TuiCommand[] {
return [
{
title: "Memory",
value: "memory.menu",
description: "Browse local working memory.",
category: "Memory",
slash: { name: "memory" },
onSelect: (dialog?: DialogContext) => showMemoryMenu(api, dialog),
},
];
}
export const MemoryTuiPlugin: TuiPlugin = async (api) => {
api.command.register(() => memoryCommands(api));
};
export default {
id: "working-memory-tui",
tui: MemoryTuiPlugin,
};
+98 -12
View File
@@ -7,9 +7,11 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
import { redactCredentials } from "./redaction.ts";
import {
REINFORCEMENT_MAX_COUNT,
RETENTION_TYPE_MAX,
calculateRetentionStrength,
reinforceMemory,
tryReinforceMemory,
type ReinforcementDecision,
} from "./retention.ts";
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
import { appendEvidenceEvents } from "./evidence-log.ts";
@@ -528,7 +530,7 @@ function extractConcreteIdentityKey(text: string): string | null {
if (pathIdentity) return pathIdentity;
}
const pathMatch = text.match(/(?:\/[^\s`"'<>]+|(?:\.{1,2}[\\/]|[A-Za-z0-9_.-]+[\\/])[^\s`"'<>]+|[A-Za-z0-9_.-]+\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config))(?:\b|$)/);
const pathMatch = text.match(/(?:\/[^\0\s`"'<>]+|(?:\.{1,2}[\\/]|[A-Za-z0-9_.-]+[\\/])[^\s`"'<>]+|[A-Za-z0-9_.-]+\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config))(?:\b|$)/);
if (!pathMatch) return null;
return normalizeConcretePathIdentity(pathMatch[0]);
@@ -559,7 +561,13 @@ function consolidationEvent(
function capacityRemovalEvidence(
memory: LongTermMemoryEntry,
reason: "type_cap" | "global_cap" | "capacity",
reason: "type_cap" | "global_cap",
details: {
strengthAtRemoval: number;
rankAtRemoval: number;
typeRankAtRemoval: number;
ageDaysAtRemoval: number;
},
): EvidenceEventInput {
return {
type: "memory_removed_capacity",
@@ -578,6 +586,7 @@ function capacityRemovalEvidence(
...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}),
...(memory.createdAt ? { createdAt: memory.createdAt } : {}),
...(memory.source ? { source: memory.source } : {}),
...details,
},
};
}
@@ -664,8 +673,8 @@ export function enforceLongTermLimitsWithAccounting(
const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id));
const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id));
const capacityEvidence: EvidenceEventInput[] = [
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap")),
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap")),
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
];
const capacityDropped = sorted
.filter(entry => !keptIds.has(entry.id))
@@ -680,6 +689,28 @@ export function enforceLongTermLimitsWithAccounting(
};
}
function capacityRemovalSnapshot(
memory: LongTermMemoryEntry,
sorted: LongTermMemoryEntry[],
now: number,
lastActivityAt?: string,
): {
strengthAtRemoval: number;
rankAtRemoval: number;
typeRankAtRemoval: number;
ageDaysAtRemoval: number;
} {
const createdAtMs = new Date(memory.createdAt).getTime();
const rank = sorted.findIndex(entry => entry.id === memory.id);
const typeRank = sorted.filter(entry => entry.type === memory.type).findIndex(entry => entry.id === memory.id);
return {
strengthAtRemoval: calculateRetentionStrength(memory, now, lastActivityAt),
rankAtRemoval: rank >= 0 ? rank + 1 : -1,
typeRankAtRemoval: typeRank >= 0 ? typeRank + 1 : -1,
ageDaysAtRemoval: Number.isFinite(createdAtMs) ? Math.floor(Math.max(0, now - createdAtMs) / 86_400_000) : 0,
};
}
function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
return applyTypeMaxCapsWithOmissions(entries).kept;
}
@@ -732,12 +763,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "absorbed_identity" as const;
const reinforced = reinforceMemory(
const decision = tryReinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
absorbed.push(consolidationEvent(dropped, reason, reinforced));
@@ -760,12 +792,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs
const reinforced = reinforceMemory(
const decision = tryReinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
if (reason === "superseded_existing") {
@@ -807,11 +840,38 @@ function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
function reinforcementEvidence(
retained: LongTermMemoryEntry,
dropped: LongTermMemoryEntry,
reinforced: LongTermMemoryEntry,
decision: ReinforcementDecision,
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
): EvidenceEventInput | undefined {
if ((reinforced.reinforcementCount ?? 0) <= (retained.reinforcementCount ?? 0)) return undefined;
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
if (decision.outcome === "blocked") {
return {
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: memoryEvidenceRef(retained),
relations: [
{ role: "target", memory: memoryEvidenceRef(retained) },
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`],
details: {
memoryId: retained.id,
droppedMemoryId: dropped.id,
blockReason: decision.blockReason,
...reinforcementDecisionTimingDetails(decision),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
},
textPreview: retained.text,
};
}
const reinforced = decision.memory;
const reasonCodes = [duplicateReason, "reinforcement_window_allowed"];
if (decision.reinforcementMode === "refresh_only") {
reasonCodes.push("reinforcement_saturation_refresh");
}
return {
type: "memory_reinforced",
phase: "reinforcement",
@@ -821,11 +881,37 @@ function reinforcementEvidence(
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
reasonCodes,
details: {
memoryId: reinforced.id,
droppedMemoryId: dropped.id,
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
reinforcementMode: decision.reinforcementMode,
...reinforcementDecisionTimingDetails(decision),
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
reinforcementCount: decision.newReinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
},
textPreview: reinforced.text,
};
}
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
return {
attemptedAtMs: decision.attemptedAt,
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
...(decision.lastReinforcedAt !== undefined ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
requiredElapsedMs: decision.requiredElapsedMs,
sameSession: decision.sameSession,
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
};
}
function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string {
return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe";
}
+48 -2
View File
@@ -9,9 +9,10 @@ test("help returns usage without exposing hidden or removed commands", () => {
assert.equal("help" in parsed && parsed.help, true);
assert.match(parsed.usage, /Usage:/);
assert.match(parsed.usage, /memory-diag \[status\]/);
assert.match(parsed.usage, /memory-diag quality/);
assert.match(parsed.usage, /memory-diag commands/);
assert.match(parsed.usage, /memory-diag revert/);
for (const command of ["health", "quality", "rejections", "disappearances", "trace", "coverage", "audit"]) {
for (const command of ["health", "rejections", "disappearances", "trace", "coverage", "audit"]) {
assert.doesNotMatch(parsed.usage, new RegExp(command));
}
});
@@ -35,7 +36,7 @@ test("unknown command returns usage error", () => {
});
test("removed legacy aliases are ordinary unknown subcommands", () => {
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
for (const command of ["health", "rejections", "disappearances", "trace"]) {
const parsed = parseArgs([command]);
assert.equal(parsed.ok, false, command);
@@ -45,6 +46,41 @@ test("removed legacy aliases are ordinary unknown subcommands", () => {
}
});
test("quality accepts read-only workspace json and display flags", () => {
const parsed = parseArgs(["quality", "--workspace", "/tmp/workspace", "--json", "--verbose", "--raw", "--no-emoji"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "quality");
assert.equal("options" in parsed && parsed.options.workspace, "/tmp/workspace");
assert.equal("options" in parsed && parsed.options.json, true);
assert.equal("options" in parsed && parsed.options.verbose, true);
assert.equal("options" in parsed && parsed.options.raw, true);
assert.equal("options" in parsed && parsed.options.noEmoji, true);
});
test("quality rejects mutation filter and drill-down flags", () => {
const cases: Array<{ args: string[]; message: string }> = [
{ args: ["quality", "--all"], message: "quality does not accept --all" },
{ args: ["quality", "--apply"], message: "quality does not accept --apply" },
{ args: ["quality", "--memory", "mem-1"], message: "quality does not accept --memory" },
{ args: ["quality", "--event", "evt-1"], message: "quality does not accept --event" },
{ args: ["quality", "--reason", "bad_decision"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--since", "7d"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--soft-only"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--trigger-only"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--include-historical"], message: "quality does not accept --include-historical" },
{ args: ["quality", "--explain"], message: "quality does not accept --explain" },
];
for (const item of cases) {
const parsed = parseArgs(item.args);
assert.equal(parsed.ok, false, item.args.join(" "));
if (parsed.ok) continue;
assert.equal(parsed.message, item.message);
assert.match(parsed.usage, /Usage:/);
}
});
test("hidden maintainer commands are accepted with neutral notices", () => {
const coverage = parseArgs(["coverage"]);
assert.equal(coverage.ok, true);
@@ -90,6 +126,16 @@ test("commands accepts workspace json and verbose flags", () => {
assert.equal("options" in parsed && parsed.options.verbose, true);
});
test("commands accepts memory drill-down selector", () => {
const parsed = parseArgs(["commands", "--workspace", "/tmp/workspace", "--memory", "mem-1", "--json", "--verbose"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "commands");
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
assert.equal("options" in parsed && parsed.options.json, true);
assert.equal("options" in parsed && parsed.options.verbose, true);
});
test("revert accepts memory or event selectors and apply flag", () => {
const byMemory = parseArgs(["revert", "--memory", "mem-new", "--workspace", "/tmp/workspace", "--apply"]);
assert.equal(byMemory.ok, true);
File diff suppressed because it is too large Load Diff
+265 -1
View File
@@ -123,6 +123,153 @@ function replacementEvidence(original: LongTermMemoryEntry, replacement: LongTer
});
}
const attributionSafetyTerms = [
"b" + "ug",
"fix" + "ed",
"incor" + "rect",
"wrong" + "ly blocked",
"caused memory" + " loss",
"prevent" + "ed retention",
"should" + " allow",
"policy" + " failure",
"regres" + "sion",
];
function assertNoAttributionSafetyTerms(text: string): void {
for (const term of attributionSafetyTerms) {
assert.doesNotMatch(text, new RegExp(term, "i"));
}
}
async function setupMemoryCommandDetailFixture(root: string): Promise<void> {
await writeWorkspaceStore(root, [
{ ...entry("mem-detail", "Detail drill-down memory remains current", "decision"), source: "explicit" as const },
]);
await appendEvidenceEvents(root, [
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"],
details: { ref: "M3", attemptedAtIso: "2026-05-12T23:55:00.000Z", reinforcedAtIso: "2026-05-12T23:55:01.000Z" },
sessionHash: "command-session-one",
messageHash: "command-message-one",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_same_session"],
details: {
ref: "M3",
blockReason: "same_session",
attemptedAtIso: "2026-05-13T00:05:00.000Z",
lastReinforcedAtIso: "2026-05-12T23:55:01.000Z",
},
sessionHash: "command-session-one",
messageHash: "command-message-two",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked"],
details: { ref: "M3", attemptedAtIso: "2026-05-13T00:06:00.000Z" },
sessionHash: "command-session-two",
messageHash: "command-message-three",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_min_elapsed_window"],
details: {
ref: "M3",
blockReason: "min_elapsed_window",
attemptedAtIso: "2026-05-20T00:05:00.000Z",
lastReinforcedAtIso: "2026-05-13T00:05:00.001Z",
elapsedMs: 604_799_999,
requiredElapsedMs: 604_800_000,
sameSession: true,
sessionID: "raw-session-secret",
lastReinforcedSessionID: "raw-last-session-secret",
},
sessionHash: "command-session-three",
messageHash: "command-message-four",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"],
details: {
ref: "M3",
reinforcementOutcome: "reinforced",
reinforcementMode: "increment",
attemptedAtIso: "2026-05-20T00:05:00.001Z",
lastReinforcedAtIso: "2026-05-13T00:05:00.001Z",
elapsedMs: 604_800_000,
requiredElapsedMs: 604_800_000,
sameSession: false,
sessionID: "raw-increment-session-secret",
},
sessionHash: "command-session-four",
messageHash: "command-message-five",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed", "reinforcement_saturation_refresh"],
details: {
ref: "M3",
reinforcementOutcome: "refreshed",
reinforcementMode: "refresh_only",
attemptedAtIso: "2026-05-27T00:05:00.001Z",
lastReinforcedAtIso: "2026-05-20T00:05:00.001Z",
elapsedMs: 604_800_000,
requiredElapsedMs: 604_800_000,
sameSession: true,
lastReinforcedSessionID: "raw-refresh-last-session-secret",
},
sessionHash: "command-session-five",
messageHash: "command-message-six",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"],
details: {
ref: "M3",
reinforcementOutcome: "reinforced",
reinforcementMode: "increment",
attemptedAtIso: "2026-05-28T00:05:00.001Z",
requiredElapsedMs: 604_800_000,
sameSession: false,
legacyMissingTimestamp: true,
sessionID: "raw-legacy-session-secret",
},
sessionHash: "command-session-six",
messageHash: "command-message-seven",
}),
evidence({
type: "render_selected",
phase: "render",
outcome: "rendered",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["within_caps", "within_char_budget"],
}),
]);
}
test("status handles missing workspace store as empty", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-"));
try {
@@ -214,7 +361,7 @@ test("memory-diag defaults to status when no subcommand is supplied", async () =
test("removed legacy aliases return unknown subcommand", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-"));
try {
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
for (const command of ["health", "rejections", "disappearances", "trace"]) {
await assert.rejects(
runMemoryDiagResult([command, "--workspace", root]),
(error: unknown) => {
@@ -402,6 +549,123 @@ test("memory-diag commands json exposes protected replacement counts", async ()
}
});
test("memory-diag commands memory selector prints reinforcement detail", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-memory-"));
try {
await setupMemoryCommandDetailFixture(root);
const stdout = await runMemoryDiag(["commands", "--memory", "mem-detail", "--workspace", root]);
assert.match(stdout, /Memory command diagnostics for mem-detail/);
assert.match(stdout, /Current memory:/);
assert.match(stdout, /status: active/);
assert.match(stdout, /render: rendered/);
assert.match(stdout, /Reinforcement summary:/);
assert.match(stdout, /attempts: 7/);
assert.match(stdout, /reinforced: 4/);
assert.match(stdout, /rejected\/blocked: 3/);
assert.match(stdout, /window blocked: 3/);
assert.match(stdout, /block reasons: min_elapsed_window=1, same_session=1, unknown=1/);
assert.match(stdout, /block details missing: 1/);
assert.match(stdout, /same-session cross UTC day blocks: 1/);
assert.match(stdout, /refs: M3/);
assert.match(stdout, /blockReason=min_elapsed_window/);
assert.match(stdout, /elapsedMs=604799999/);
assert.match(stdout, /requiredElapsedMs=604800000/);
assert.match(stdout, /sameSession=yes/);
assert.match(stdout, /sameSession=no/);
assert.match(stdout, /reinforcementMode=refresh_only/);
assert.match(stdout, /legacyMissingTimestamp=yes/);
assert.match(stdout, /crossUtcDay=yes/);
assert.doesNotMatch(stdout, /render_selected/);
assert.doesNotMatch(stdout, /raw-session-secret/);
assert.doesNotMatch(stdout, /raw-last-session-secret/);
assert.doesNotMatch(stdout, /raw-refresh-last-session-secret/);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag commands memory selector emits stable JSON", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-memory-json-"));
try {
await setupMemoryCommandDetailFixture(root);
const stdout = await runMemoryDiag(["commands", "--memory", "mem-detail", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as {
version: 1;
memoryId: string;
current: { present: boolean; status?: string; renderStatus?: string };
summary: {
attempts: number;
reinforced: number;
rejectedOrBlocked: number;
blocksByReason: Record<string, number>;
blockDetailsMissing: number;
sameSessionCrossUtcDayBlocks: number;
};
events: Array<{
eventId: string;
outcome: string;
blockReason?: string;
crossUtcDay?: boolean | "unknown";
elapsedMs?: number;
requiredElapsedMs?: number;
sameSession?: boolean;
legacyMissingTimestamp?: boolean;
reinforcementMode?: string;
instrumentationVersion?: number;
}>;
};
assert.equal(parsed.version, 1);
assert.equal(parsed.memoryId, "mem-detail");
assert.equal(parsed.current.present, true);
assert.equal(parsed.current.status, "active");
assert.equal(parsed.current.renderStatus, "rendered");
assert.equal(parsed.summary.attempts, 7);
assert.equal(parsed.summary.reinforced, 4);
assert.equal(parsed.summary.rejectedOrBlocked, 3);
assert.equal(parsed.summary.blocksByReason.min_elapsed_window, 1);
assert.equal(parsed.summary.blocksByReason.same_session, 1);
assert.equal(parsed.summary.blocksByReason.unknown, 1);
assert.equal(parsed.summary.blockDetailsMissing, 1);
assert.equal(parsed.summary.sameSessionCrossUtcDayBlocks, 1);
assert.equal(parsed.events.some(event => event.blockReason === "min_elapsed_window" && event.elapsedMs === 604_799_999 && event.requiredElapsedMs === 604_800_000 && event.sameSession === true), true);
assert.equal(parsed.events.some(event => event.reinforcementMode === "increment" && event.elapsedMs === 604_800_000 && event.sameSession === false), true);
assert.equal(parsed.events.some(event => event.reinforcementMode === "refresh_only" && event.elapsedMs === 604_800_000 && event.sameSession === true), true);
assert.equal(parsed.events.some(event => event.legacyMissingTimestamp === true), true);
assert.equal(parsed.events.every(event => event.instrumentationVersion === 3), true);
assert.equal(parsed.events.some(event => event.blockReason === "same_session" && event.crossUtcDay === true), true);
assert.equal(parsed.events.some(event => event.blockReason === "unknown" && event.crossUtcDay === "unknown"), true);
assert.equal(JSON.stringify(parsed).includes("command-session"), false);
assert.equal(JSON.stringify(parsed).includes("command-message"), false);
assert.equal(JSON.stringify(parsed).includes("Detail drill-down memory remains current"), false);
assert.equal(JSON.stringify(parsed).includes("raw-session-secret"), false);
assert.equal(JSON.stringify(parsed).includes("raw-last-session-secret"), false);
assert.equal(JSON.stringify(parsed).includes("raw-refresh-last-session-secret"), false);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag commands memory selector reports empty evidence neutrally", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-commands-memory-empty-"));
try {
await writeWorkspaceStore(root, [entry("mem-empty", "Memory without reinforcement command evidence", "feedback")]);
const stdout = await runMemoryDiag(["commands", "--memory", "mem-empty", "--workspace", root]);
assert.match(stdout, /Memory command diagnostics for mem-empty/);
assert.match(stdout, /No reinforcement command evidence found for mem-empty\./);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag revert dry-run plans changes without mutating", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-revert-dry-run-"));
try {
+216
View File
@@ -0,0 +1,216 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { appendPendingMemories } from "../src/pending-journal.ts";
import { saveSessionState } from "../src/session-state.ts";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
import { saveWorkspaceMemory } from "../src/workspace-memory.ts";
import {
formatMemoryHelp,
formatMemoryList,
formatMemoryStatus,
getMemoryList,
getMemoryStatus,
renderMemoryCommand,
} from "../src/memory-visibility.ts";
async function tempRoot(): Promise<string> {
return mkdtemp(join(tmpdir(), "memory-visibility-test-"));
}
function memory(id: string, text: string, overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
id,
type: "decision",
text,
source: "compaction",
confidence: 0.8,
status: "active",
createdAt: now,
updatedAt: now,
...overrides,
};
}
test("formats status counts from workspace, session, and pending journal stores", async () => {
const root = await tempRoot();
try {
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "test" },
limits: { maxRenderedChars: 115, maxEntries: 28 },
entries: [
memory("mem-short", "Keep tests focused."),
memory("mem-long", "Long memory with password: sushi ".repeat(20), { type: "reference" }),
memory("mem-old", "Superseded memory should not be active.", { status: "superseded" }),
],
migrations: [],
updatedAt: now,
};
await saveWorkspaceMemory(root, store);
await saveSessionState(root, {
version: 1,
sessionID: "ses_status",
turn: 1,
updatedAt: now,
activeFiles: [],
openErrors: [{
id: "err-1",
category: "typecheck",
summary: "Typecheck failed",
fingerprint: "typecheck",
status: "open",
firstSeen: Date.now(),
lastSeen: Date.now(),
seenCount: 1,
}],
recentDecisions: [{ id: "dec-1", text: "Prefer local rendering", source: "user", createdAt: Date.now() }],
pendingMemories: [memory("pending-session", "Pending for this session", { source: "explicit", pendingOwnerSessionID: "ses_status" })],
compactionMemoryRefs: [],
});
await appendPendingMemories(root, [memory("pending-journal", "Pending in durable journal", { source: "explicit", pendingOwnerSessionID: "ses_status" })]);
const output = formatMemoryStatus(await getMemoryStatus(root, "ses_status"));
assert.match(output, /^## Memory status/);
assert.match(output, /Workspace:/);
assert.match(output, /- Active memories: 2/);
assert.match(output, /- Rendered in prompt: 1/);
assert.match(output, /- Omitted active memories: 1/);
assert.match(output, /- Superseded memories: 1/);
assert.match(output, /Pending:/);
assert.match(output, /- Pending in this session: 1/);
assert.match(output, /- Pending journal memories: 1/);
assert.match(output, /Session:/);
assert.match(output, /- Open errors: 1/);
assert.match(output, /- Recent decisions: 1/);
assert.match(output, /Use \/memory → Current memories to browse current \[M1\]-\[M28\] memory refs\./);
assert.match(output, /Local only: no LLM request was made\./);
assert.equal(output.includes("Recent active memory previews"), false);
assert.equal(output.includes("sushi"), false, "status output should not include memory previews");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("getMemoryStatus redacts previews without rewriting workspace memory", async () => {
const root = await tempRoot();
try {
const now = new Date().toISOString();
const path = await workspaceMemoryPath(root);
await mkdir(dirname(path), { recursive: true });
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "test" },
limits: { maxRenderedChars: 3600, maxEntries: 28 },
entries: [memory("mem-secret", "Remember password: sushi for the fake test fixture.", { createdAt: now, updatedAt: now })],
migrations: [],
updatedAt: now,
};
const before = JSON.stringify(store, null, 2);
await writeFile(path, before, "utf8");
const output = formatMemoryStatus(await getMemoryStatus(root, "ses_readonly"));
const after = await readFile(path, "utf8");
assert.match(output, /- Active memories: 1/);
assert.equal(output.includes("Recent active memory previews"), false);
assert.equal(output.includes("sushi"), false, "status output should not include memory previews");
assert.equal(after, before, "status display must not persist normalization, migration, or redaction changes");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("formats current workspace memories grouped by type with display-local refs", async () => {
const root = await tempRoot();
try {
const now = new Date().toISOString();
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: "test" },
limits: { maxRenderedChars: 3600, maxEntries: 28 },
entries: [
memory("mem-feedback", "Remember password: sushi for the fake test.", { type: "feedback" }),
memory("mem-project", "Project memory should render in its own group.", { type: "project" }),
memory("mem-decision", "Decision memory should render in its own group.", { type: "decision" }),
memory("mem-reference", "Reference memory should render in its own group.", { type: "reference" }),
memory("mem-superseded", "Superseded memory should not be active", { type: "reference", status: "superseded" }),
],
migrations: [],
updatedAt: now,
});
const output = formatMemoryList(await getMemoryList(root));
assert.match(output, /^## Current workspace memories/);
assert.match(output, /Display refs are local to this output/);
assert.match(output, /feedback:\n- \[M\d+\]/);
assert.match(output, /project:\n- \[M\d+\]/);
assert.match(output, /decision:\n- \[M\d+\]/);
assert.match(output, /reference:\n- \[M\d+\]/);
assert.match(output, /Shown: \d+ of \d+ active memories\./);
assert.match(output, /Shown: 4 of 4 active memories\./);
assert.match(output, /Omitted active memories: 0\./);
assert.equal(output.includes("[M1]"), true, "at least one display-local ref should render");
assert.equal(output.includes("sushi"), false, "list previews should redact credential-like text");
assert.equal(output.includes("Superseded memory should not be active"), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("formats empty memory list state", () => {
const output = formatMemoryList({
activeMemories: 0,
renderedMemories: 0,
omittedActiveMemories: 0,
groups: { feedback: [], project: [], decision: [], reference: [] },
});
assert.match(output, /^## Current workspace memories/);
assert.match(output, /No active workspace memories are stored yet\./);
assert.match(output, /Local only: no LLM request was made\./);
assert.equal(output.includes("feedback:"), false);
});
test("formats help text for available display commands", () => {
const output = formatMemoryHelp();
assert.match(output, /^## Memory help/);
assert.match(output, /\/memory — open the local memory menu\./);
assert.match(output, /Status — show local memory statistics\./);
assert.match(output, /Current memories — browse active workspace memories as display-local \[M1\]-\[M28\] refs\./);
assert.match(output, /Help — show this help\./);
for (const removedCommand of ["/memory-" + "status", "/memory-" + "list", "/memory-" + "help"]) {
assert.equal(output.includes(removedCommand), false);
}
assert.equal(output.includes("/memory activity"), false);
assert.equal(output.includes("/memory last"), false);
assert.equal(output.includes("/memory status"), false);
assert.equal(output.includes("/memory help"), false);
assert.match(output, /do not call the LLM/);
});
test("renderMemoryCommand routes list output", async () => {
const root = await tempRoot();
try {
const output = await renderMemoryCommand(root, "ses_list", "list");
assert.match(output, /^## Current workspace memories/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("renderMemoryCommand falls back to help for unknown command values", async () => {
const root = await tempRoot();
try {
const output = await renderMemoryCommand(root, "ses_unknown", "unknown" as never);
assert.match(output, /^## Memory help/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+240 -15
View File
@@ -100,6 +100,60 @@ function createSessionWithError(sessionID: string, error: OpenError) {
};
}
async function withMockedDateNow<T>(now: number, fn: () => Promise<T>): Promise<T> {
const originalDateNow = Date.now;
Date.now = () => now;
try {
return await fn();
} finally {
Date.now = originalDateNow;
}
}
async function withNumberedReinforceScenario(
options: { sessionID: string; nowMs: number; existing: LongTermMemoryEntry; summary?: string },
assertions: (context: {
workspace: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
events: Awaited<ReturnType<typeof queryEvidenceEvents>>;
}) => Promise<void> | void,
): Promise<void> {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push(options.existing);
return store;
});
await saveSessionState(tmpDir, {
version: 1,
sessionID: options.sessionID,
turn: 0,
updatedAt: new Date(options.nowMs).toISOString(),
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [],
compactionMemoryRefs: [compactionRefFor(options.existing)],
});
const plugin = await MemoryV2Plugin({
directory: tmpDir,
client: mockClientWithCompactionSummary(options.summary ?? "Memory candidates:\nREINFORCE [M1]"),
});
await withMockedDateNow(options.nowMs, async () => {
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: options.sessionID } },
});
});
await assertions({
workspace: await loadWorkspaceMemory(tmpDir),
events: await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }),
});
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
test("tool.execute.after: undefined exitCode does NOT create open error", async () => {
// 1. Temp directory for isolated file I/O
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -1032,8 +1086,9 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const now = new Date().toISOString();
const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000;
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const now = new Date(nowMs).toISOString();
const lastReinforcedAt = nowMs - 8 * 24 * 60 * 60 * 1000;
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-memory",
type: "decision",
@@ -1043,7 +1098,10 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: oldRetentionClock,
retentionClock: lastReinforcedAt,
reinforcementCount: 1,
lastReinforcedAt,
lastReinforcedSessionID: "numbered-reinforce-session",
};
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push(existing);
@@ -1065,28 +1123,174 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
directory: tmpDir,
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
});
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
await withMockedDateNow(nowMs, async () => {
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
});
});
const workspace = await loadWorkspaceMemory(tmpDir);
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
assert.equal(reinforced?.reinforcementCount, 1);
assert.equal(reinforced?.reinforcementCount, 2);
assert.equal(reinforced?.lastReinforcedSessionID, "numbered-reinforce-session");
assert.ok((reinforced?.retentionClock ?? 0) > oldRetentionClock);
assert.equal(reinforced?.lastReinforcedAt, nowMs);
assert.equal(reinforced?.retentionClock, nowMs);
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
assert.ok(events.some(event =>
const event = events.find(event =>
event.outcome === "reinforced" &&
event.reasonCodes.includes("numbered_ref_reinforce") &&
event.reasonCodes.includes("reinforcement_window_allowed") &&
event.memory?.memoryId === existing.id
));
);
assert.ok(event, "numbered REINFORCE should emit allowed evidence");
assert.equal(event.instrumentationVersion, 3);
assert.equal(event.details?.reinforcementOutcome, "reinforced");
assert.equal(event.details?.reinforcementMode, "increment");
assert.equal(event.details?.sameSession, true);
assert.equal(event.details?.elapsedMs, 8 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.attemptedAtMs, nowMs);
assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt);
assert.equal(event.details?.previousReinforcementCount, 1);
assert.equal(event.details?.newReinforcementCount, 2);
assert.equal(JSON.stringify(event.details).includes("numbered-reinforce-session"), false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session.compacted allows numbered REINFORCE at exact 7-day boundary", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000;
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-exact-window",
type: "decision",
text: "Use exact rolling windows for memory reinforcement.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: lastReinforcedAt,
reinforcementCount: 5,
lastReinforcedAt,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "exact-window-session", nowMs, existing }, ({ workspace, events }) => {
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
assert.equal(reinforced?.reinforcementCount, 6);
assert.equal(reinforced?.retentionClock, nowMs);
assert.ok(event, "exact 7-day boundary should emit reinforced evidence");
assert.equal(event.details?.reinforcementMode, "increment");
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.instrumentationVersion, 3);
});
});
test("session.compacted blocks different-session numbered REINFORCE below 7-day window", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 1);
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-below-window",
type: "feedback",
text: "User prefers below-window reinforcement blocks.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
reinforcementCount: 1,
lastReinforcedAt,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "different-session", nowMs, existing }, ({ workspace, events }) => {
const blocked = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "rejected" && event.memory?.memoryId === existing.id);
assert.equal(blocked?.reinforcementCount, 1);
assert.ok(event, "below-window different-session attempt should emit rejected evidence");
assert.ok(event.reasonCodes.includes("reinforcement_block_min_elapsed_window"));
assert.equal(event.details?.blockReason, "min_elapsed_window");
assert.equal(event.details?.sameSession, false);
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 1);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.instrumentationVersion, 3);
});
});
test("session.compacted refreshes saturated numbered REINFORCE after rolling window", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000;
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-refresh-only",
type: "decision",
text: "Use refresh-only mode for saturated reinforcement.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000,
reinforcementCount: 6,
lastReinforcedAt,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "refresh-only-session", nowMs, existing }, ({ workspace, events }) => {
const refreshed = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
assert.equal(refreshed?.reinforcementCount, 6);
assert.equal(refreshed?.retentionClock, nowMs);
assert.equal(refreshed?.lastReinforcedAt, nowMs);
assert.equal(refreshed?.lastReinforcedSessionID, "refresh-only-session");
assert.ok(event, "saturated after-window attempt should emit refreshed evidence");
assert.equal(event.details?.reinforcementOutcome, "refreshed");
assert.equal(event.details?.reinforcementMode, "refresh_only");
assert.equal(event.details?.previousReinforcementCount, 6);
assert.equal(event.details?.newReinforcementCount, 6);
assert.ok(event.reasonCodes.includes("reinforcement_saturation_refresh"));
assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false);
assert.equal(event.instrumentationVersion, 3);
});
});
test("session.compacted emits legacy missing timestamp details for numbered REINFORCE", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-legacy-missing",
type: "feedback",
text: "User prefers legacy timestamp anomalies to be visible.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000,
reinforcementCount: 2,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "legacy-missing-session", nowMs, existing }, ({ workspace, events }) => {
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
assert.equal(reinforced?.reinforcementCount, 3);
assert.equal(reinforced?.lastReinforcedAt, nowMs);
assert.equal(reinforced?.retentionClock, nowMs);
assert.equal(event?.details?.legacyMissingTimestamp, true);
assert.equal(event?.details?.reinforcementMode, "increment");
assert.equal(event?.instrumentationVersion, 3);
});
});
test("session.compacted rejects invalid unavailable and stale REINFORCE refs without mutation", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -1163,8 +1367,9 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const nowMs = Date.now();
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const now = new Date(nowMs).toISOString();
const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000);
const existing: LongTermMemoryEntry = {
id: "blocked-numbered-reinforce",
type: "feedback",
@@ -1176,7 +1381,7 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
updatedAt: now,
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
reinforcementCount: 1,
lastReinforcedAt: nowMs - 2 * 60 * 60 * 1000,
lastReinforcedAt,
lastReinforcedSessionID: "blocked-reinforce-session",
};
await updateWorkspaceMemory(tmpDir, store => {
@@ -1199,8 +1404,10 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
directory: tmpDir,
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
});
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
await withMockedDateNow(nowMs, async () => {
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
});
});
const workspace = await loadWorkspaceMemory(tmpDir);
@@ -1209,13 +1416,31 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
assert.equal(blocked?.reinforcementCount, 1);
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
assert.ok(events.some(event =>
const event = events.find(event =>
event.outcome === "rejected" &&
event.reasonCodes.includes("reinforcement_window_blocked") &&
event.reasonCodes.includes("reinforcement_block_min_elapsed_window") &&
event.memory?.memoryId === existing.id &&
event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) &&
!event.relations?.some(relation => relation.role === "reinforced")
));
);
assert.ok(event, "blocked numbered REINFORCE should emit elapsed-window evidence");
assert.equal(event.instrumentationVersion, 3);
assert.equal(event.reasonCodes.includes("reinforcement_block_same_session"), false);
assert.equal(event.reasonCodes.includes("reinforcement_block_same_utc_day"), false);
assert.equal(event.reasonCodes.includes("reinforcement_block_min_interval"), false);
assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false);
assert.equal(event.details?.blockReason, "min_elapsed_window");
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.sameSession, true);
assert.equal(event.details?.attemptedAtMs, nowMs);
assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt);
assert.equal(event.details?.reinforcementCount, 1);
assert.equal(event.details?.maxReinforcementCount, 6);
assert.equal("minIntervalMs" in (event.details ?? {}), false);
assert.equal("reinforcementMode" in (event.details ?? {}), false);
assert.equal(JSON.stringify(event.details).includes("blocked-reinforce-session"), false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
+211
View File
@@ -0,0 +1,211 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
BASE_HALF_LIFE_DAYS,
REINFORCEMENT_MAX_COUNT,
tryReinforceMemory,
} from "../src/retention.ts";
import type { LongTermMemoryEntry } from "../src/types.ts";
const DAY_MS = 24 * 60 * 60 * 1000;
const ROLLING_WINDOW_MS = 7 * DAY_MS;
const baseMemory = (overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry => ({
id: "mem-retention",
type: "decision",
text: "Durable decision for reinforcement",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: "2026-05-10T00:00:00.000Z",
updatedAt: "2026-05-10T00:00:00.000Z",
...overrides,
});
test("tryReinforceMemory allows same session after rolling 8 days", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + 8 * DAY_MS;
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
retentionClock: lastAt,
});
const decision = tryReinforceMemory(memory, "session-a", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "increment");
assert.equal(decision.previousReinforcementCount, 1);
assert.equal(decision.newReinforcementCount, 2);
assert.equal(decision.memory.reinforcementCount, 2);
assert.equal(decision.memory.retentionClock, now);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-a");
assert.equal(decision.sameSession, true);
assert.equal(decision.elapsedMs, 8 * DAY_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory allows exactly 7 rolling days after last reinforcement", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS;
const memory = baseMemory({
reinforcementCount: 5,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
retentionClock: lastAt,
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "increment");
assert.equal(decision.memory.reinforcementCount, 6);
assert.equal(decision.memory.retentionClock, now);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.sameSession, false);
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks 7 rolling days minus 1ms as min_elapsed_window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS - 1;
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-a", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.equal(decision.memory, memory);
assert.equal(decision.sameSession, true);
assert.equal(decision.lastReinforcedAt, lastAt);
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS - 1);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks different session below rolling window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + 3 * DAY_MS;
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.equal(decision.sameSession, false);
assert.equal(decision.elapsedMs, 3 * DAY_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks UTC midnight crossing below rolling window", () => {
const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0);
const now = Date.UTC(2026, 4, 13, 2, 15, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.notEqual(decision.blockReason, "same_utc_day");
assert.equal(decision.elapsedMs, now - lastAt);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory refreshes saturated count after rolling window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS;
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
retentionClock: lastAt,
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "refresh_only");
assert.equal(decision.previousReinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.newReinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.notEqual(decision.memory, memory);
assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.memory.retentionClock, now);
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks saturated count below rolling window as min_elapsed_window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS - 1;
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.notEqual(decision.blockReason, "max_count");
assert.equal(decision.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.maxReinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory normalizes missing legacy timestamp while incrementing", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: 2,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "increment");
assert.equal(decision.legacyMissingTimestamp, true);
assert.equal(decision.memory.reinforcementCount, 3);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.retentionClock, now);
});
test("tryReinforceMemory normalizes invalid legacy timestamp while refresh-only saturated", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: Number.NaN,
lastReinforcedSessionID: "session-a",
retentionClock: Date.UTC(2026, 4, 8, 12, 0, 0),
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "refresh_only");
assert.equal(decision.legacyMissingTimestamp, true);
assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.retentionClock, now);
});
test("BASE_HALF_LIFE_DAYS remains 45", () => {
assert.equal(BASE_HALF_LIFE_DAYS, 45);
});
+51
View File
@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
const maxBuffer = 10 * 1024 * 1024;
function executable(name: "npm" | "npx"): string {
return process.platform === "win32" ? `${name}.cmd` : name;
}
test("packed memory-diag bin runs from a temp consumer project", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-packaging-"));
const packDir = join(tempRoot, "pack");
const consumerDir = join(tempRoot, "consumer");
try {
await mkdir(packDir, { recursive: true });
await mkdir(consumerDir, { recursive: true });
const packResult = await execFileAsync(executable("npm"), [
"pack",
repoRoot,
"--pack-destination",
packDir,
"--silent",
], { cwd: tempRoot, maxBuffer });
const tarballName = packResult.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
assert.ok(tarballName, `npm pack did not report a tarball name. stdout:\n${packResult.stdout}\nstderr:\n${packResult.stderr}`);
const tarballPath = join(packDir, tarballName);
const runResult = await execFileAsync(executable("npx"), [
"--yes",
"--package",
tarballPath,
"memory-diag",
"--help",
], { cwd: consumerDir, maxBuffer });
assert.match(runResult.stdout, /Usage:/);
assert.match(runResult.stdout, /memory-diag \[status\]/);
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
+365
View File
@@ -0,0 +1,365 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TuiCommand } from "@opencode-ai/plugin/tui";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { saveWorkspaceMemory } from "../src/workspace-memory.ts";
// ---------------------------------------------------------------------------
// Mock infrastructure
// ---------------------------------------------------------------------------
type MockDialogSize = "medium" | "large" | "xlarge";
type MockDialogAlertProps = { title: string; message: string; onConfirm?: () => void };
type MockDialogSelectOption<Value = string> = {
title: string;
value: Value;
description?: string;
footer?: string;
category?: string;
disabled?: boolean;
onSelect?: () => void | Promise<void>;
};
type MockDialogSelectProps<Value = string> = {
title: string;
placeholder?: string;
options: MockDialogSelectOption<Value>[];
onSelect?: (option: MockDialogSelectOption<Value>) => void | Promise<void>;
skipFilter?: boolean;
};
type MockDialogElement =
| { type: "DialogAlert"; props: MockDialogAlertProps }
| { type: "DialogSelect"; props: MockDialogSelectProps };
type MockDialogRender = () => MockDialogElement;
type MockDialogContext = {
clear: () => void;
replace?: (render: MockDialogRender, onClose?: () => void) => void;
setSize?: (size: MockDialogSize) => void;
renders: MockDialogElement[];
sizes: MockDialogSize[];
events: string[];
};
type RuntimeCommand = { value: string; suggested?: boolean; slash?: { name: string; aliases?: string[] }; onSelect?: (dialog: MockDialogContext) => void | Promise<void> };
type MockPromptCall = Record<string, unknown>;
interface MockTuiApi {
commands: RuntimeCommand[];
prompts: MockPromptCall[];
toasts: Array<{ variant?: string; message: string }>;
dialog: MockDialogContext;
route: { name: string; params?: Record<string, unknown> };
state: { path: { directory: string } };
command: { register: (cb: () => TuiCommand[]) => () => void };
ui: {
toast: (input: { variant?: string; message: string }) => void;
dialog?: Partial<MockDialogContext>;
DialogAlert?: (props: MockDialogAlertProps) => MockDialogElement;
DialogSelect?: (props: MockDialogSelectProps) => MockDialogElement;
};
client: { session: { prompt: (input: MockPromptCall) => Promise<void> } };
}
function makeMockTuiApi(options: {
route: { name: string; params?: Record<string, unknown> };
directory?: string;
missingDialogAlert?: boolean;
missingDialogSelect?: boolean;
missingDialogReplace?: boolean;
missingDialogSetSize?: boolean;
dialogReplaceThrows?: boolean;
}): MockTuiApi {
const commands: RuntimeCommand[] = [];
const prompts: MockPromptCall[] = [];
const toasts: Array<{ variant?: string; message: string }> = [];
const dialog: MockDialogContext = {
clear: () => { dialog.events.push("clear"); },
replace: (render: MockDialogRender) => {
dialog.events.push("replace");
if (options.dialogReplaceThrows) throw new Error("dialog failure");
dialog.renders.push(render());
},
setSize: (size: MockDialogSize) => {
dialog.events.push(`setSize:${size}`);
dialog.sizes.push(size);
},
renders: [],
sizes: [],
events: [],
};
const uiDialog: Partial<MockDialogContext> = {
clear: dialog.clear,
replace: options.missingDialogReplace ? undefined : dialog.replace,
setSize: options.missingDialogSetSize ? undefined : dialog.setSize,
};
return {
commands,
prompts,
toasts,
dialog,
route: options.route,
state: { path: { directory: options.directory ?? "/mock/workspace" } },
command: {
register: (cb: () => TuiCommand[]) => {
const items = cb();
for (const item of items) {
commands.push({
value: item.value,
suggested: item.suggested,
slash: item.slash,
onSelect: item.onSelect
? (dialogContext: MockDialogContext = dialog) => (item.onSelect as (dialog: MockDialogContext) => void | Promise<void>)(dialogContext)
: undefined,
});
}
return () => {};
},
},
ui: {
toast: (input: { variant?: string; message: string }) => { toasts.push(input); },
dialog: uiDialog,
DialogAlert: options.missingDialogAlert ? undefined : (props: MockDialogAlertProps): MockDialogElement => ({ type: "DialogAlert", props }),
DialogSelect: options.missingDialogSelect ? undefined : (props: MockDialogSelectProps): MockDialogElement => ({ type: "DialogSelect", props }),
},
client: {
session: {
prompt: async (input: MockPromptCall) => { prompts.push(input); },
},
},
};
}
async function selectCommand(api: MockTuiApi, value: string): Promise<void> {
const command = api.commands.find((item): item is RuntimeCommand => item.value === value);
assert.ok(command, `registered command ${value}`);
await command.onSelect?.(api.dialog);
}
function lastDialog(api: MockTuiApi): MockDialogElement {
const hit = api.dialog.renders.at(-1);
assert.ok(hit, "expected a rendered dialog");
return hit;
}
async function chooseSelectOption(api: MockTuiApi, value: string): Promise<void> {
const dialog = lastDialog(api);
assert.equal(dialog.type, "DialogSelect");
const option = dialog.props.options.find(item => item.value === value);
assert.ok(option, `expected select option ${value}`);
// Source evidence: OpenCode's plugin API maps option.onSelect to a zero-arg
// callback and DialogSelect invokes option.onSelect before top-level onSelect.
await option.onSelect?.();
}
async function tempRoot(): Promise<string> {
return mkdtemp(join(tmpdir(), "memory-tui-test-"));
}
function memory(id: string, text: string, overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
id,
type: "decision",
text,
source: "compaction",
confidence: 0.8,
status: "active",
createdAt: now,
updatedAt: now,
...overrides,
};
}
async function seedWorkspaceMemories(root: string): Promise<void> {
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "test" },
limits: { maxRenderedChars: 3600, maxEntries: 28 },
entries: [
memory("mem-feedback", "Remember password: sushi for the fake test.", { type: "feedback" }),
memory("mem-project", "Project memory should render in its group.", { type: "project" }),
memory("mem-decision", "Decision memory should render in its group.", { type: "decision" }),
memory("mem-reference", "Reference memory should render in its group.", { type: "reference" }),
memory("mem-superseded", "Superseded memory should not be active", { type: "reference", status: "superseded" }),
],
migrations: [],
updatedAt: now,
};
await saveWorkspaceMemory(root, store);
}
// Dynamic import to allow module-level mocking
const { MemoryTuiPlugin } = await import("../src/tui-plugin.ts");
test("registers one unsuggested /memory slash command", async () => {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
assert.deepEqual(api.commands.map(command => command.value), ["memory.menu"]);
assert.deepEqual(api.commands.map(command => command.slash?.name).filter(Boolean), ["memory"]);
assert.deepEqual(api.commands.map(command => command.suggested), [undefined]);
for (const removedName of ["memory-" + "status", "memory-" + "list", "memory-" + "help"]) {
assert.equal(api.commands.some(command => command.slash?.name === removedName), false);
}
});
test("opens the memory submenu without prompt injection", async () => {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
assert.equal(api.prompts.length, 0);
const dialog = lastDialog(api);
assert.equal(dialog.type, "DialogSelect");
assert.equal(dialog.props.title, "Memory");
assert.equal(dialog.props.placeholder, "Search memory actions");
assert.deepEqual(dialog.props.options.map(item => item.title), ["Status", "Current memories", "Help"]);
assert.deepEqual(dialog.props.options.map(item => item.value), ["memory.status", "memory.list", "memory.help"]);
assert.ok(api.dialog.events.indexOf("clear") < api.dialog.events.indexOf("replace"));
assert.ok(api.dialog.events.indexOf("replace") < api.dialog.events.indexOf("setSize:large"));
});
test("supports home-route menu, list, and help while status warns", async () => {
const root = await tempRoot();
try {
const api = makeMockTuiApi({ route: { name: "home" }, directory: root });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
assert.equal(lastDialog(api).type, "DialogSelect");
assert.equal(lastDialog(api).props.title, "Memory");
await chooseSelectOption(api, "memory.list");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Current workspace memories");
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.help");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Memory help");
await selectCommand(api, "memory.menu");
const beforeStatusRenders = api.dialog.renders.length;
await chooseSelectOption(api, "memory.status");
assert.equal(api.dialog.renders.length, beforeStatusRenders, "status should not render without an active session");
assert.ok(api.toasts.some(t => t.variant === "warning" && t.message === "Open a session to use memory commands."));
assert.equal(api.prompts.length, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("shows status and help alerts from the submenu", async () => {
const root = await tempRoot();
try {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, directory: root });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.status");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Memory status");
assert.match(lastDialog(api).props.message, /Workspace:/);
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.help");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Memory help");
assert.match(lastDialog(api).props.message, /Status/);
assert.equal(api.prompts.length, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("shows current memories in a grouped DialogSelect with no-op row selection", async () => {
const root = await tempRoot();
try {
await seedWorkspaceMemories(root);
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, directory: root });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.list");
const dialog = lastDialog(api);
assert.equal(dialog.type, "DialogSelect");
assert.equal(dialog.props.title, "Current workspace memories");
assert.equal(dialog.props.placeholder, "Search memory refs");
assert.deepEqual([...new Set(dialog.props.options.map(item => item.category))], ["feedback", "project", "decision", "reference"]);
assert.ok(dialog.props.options.every(item => /^\[M\d+\] /.test(item.title)));
assert.ok(dialog.props.options.every(item => typeof item.footer === "string"));
assert.equal(dialog.props.options.some(item => item.title.includes("sushi")), false);
assert.equal(dialog.props.options.some(item => item.title.includes("Superseded memory should not be active")), false);
assert.equal(api.dialog.sizes.at(-1), "xlarge");
const beforeRenders = api.dialog.renders.length;
const beforeToasts = api.toasts.length;
await chooseSelectOption(api, dialog.props.options[0].value);
assert.equal(api.dialog.renders.length, beforeRenders, "memory row selection should not replace dialog in this wave");
assert.equal(api.toasts.length, beforeToasts, "memory row selection should not expose mutation/action toast");
assert.equal(api.prompts.length, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("shows empty current memories as an alert", async () => {
const root = await tempRoot();
try {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, directory: root });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.list");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Current workspace memories");
assert.match(lastDialog(api).props.message, /No active workspace memories are stored yet\./);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("shows local read failures in a memory error alert", async () => {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } });
api.state.path.directory = undefined as never;
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.status");
assert.equal(api.prompts.length, 0);
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Memory error");
assert.match(lastDialog(api).props.message, /Unable to render local memory visibility output\./);
});
test("shows error toast when dialog runtime API is unavailable", async () => {
for (const options of [
{ missingDialogAlert: true },
{ missingDialogSelect: true },
{ missingDialogReplace: true },
{ missingDialogSetSize: true },
]) {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, ...options });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
assert.equal(api.prompts.length, 0, "should not fall back to prompt injection");
assert.equal(api.dialog.renders.length, 0, "should not partially open a dialog when API guard fails");
assert.ok(api.toasts.some(t => t.variant === "error" && t.message === "Memory dialog UI is unavailable in this OpenCode runtime."));
}
});
test("shows error toast when dialog replacement fails without prompt fallback", async () => {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, dialogReplaceThrows: true });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
assert.equal(api.prompts.length, 0);
assert.equal(api.dialog.renders.length, 0);
assert.equal(api.dialog.sizes.length, 0);
assert.ok(api.toasts.some(t => t.variant === "error" && /^Unable to show memory dialog: dialog failure$/.test(t.message)), "should show error toast on dialog failure");
});
+171 -30
View File
@@ -30,7 +30,7 @@ import {
calculateRetentionStrength,
calculateDormantDays,
calculateEffectiveAgeDays,
reinforceMemory,
tryReinforceMemory,
} from "../src/retention.ts";
import { redactCredentials } from "../src/redaction.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
@@ -72,6 +72,16 @@ function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function withMockedDateNow<T>(now: number, fn: () => T): T {
const originalDateNow = Date.now;
Date.now = () => now;
try {
return fn();
} finally {
Date.now = originalDateNow;
}
}
/** Create an entry with a createdAt offset from now (negative = in the past) */
function agedEntry(
id: string,
@@ -580,10 +590,10 @@ test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for str
assert.deepEqual(result.kept.map(memory => memory.id), ["reinforced-old", "fresh"]);
});
test("reinforceMemory enforces session interval and max guards", () => {
test("reinforceMemory enforces rolling elapsed window and saturation refresh", () => {
const now = Date.UTC(2026, 3, 29);
const base = entry("reinforce", "Durable memory should reinforce only when gated");
const reinforced = reinforceMemory(base, "session-a", now);
const reinforced = tryReinforceMemory(base, "session-a", now).memory;
assert.notEqual(reinforced, base);
assert.equal(reinforced.reinforcementCount, 1);
@@ -591,21 +601,29 @@ test("reinforceMemory enforces session interval and max guards", () => {
assert.equal(reinforced.lastReinforcedSessionID, "session-a");
assert.equal(reinforced.retentionClock, now);
assert.equal(reinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000), reinforced);
assert.equal(reinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000), reinforced);
assert.equal(tryReinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000).memory, reinforced);
assert.equal(tryReinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000).memory, reinforced);
const reinforcedAfterWindow = tryReinforceMemory(reinforced, "session-a", now + 7 * DAY_MS).memory;
assert.notEqual(reinforcedAfterWindow, reinforced);
assert.equal(reinforcedAfterWindow.reinforcementCount, 2);
const atMax: LongTermMemoryEntry = {
...base,
reinforcementCount: 6,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
lastReinforcedAt: now - 7 * DAY_MS,
};
assert.equal(reinforceMemory(atMax, "session-c", now), atMax);
const refreshedAtMax = tryReinforceMemory(atMax, "session-c", now).memory;
assert.notEqual(refreshedAtMax, atMax);
assert.equal(refreshedAtMax.reinforcementCount, 6);
assert.equal(refreshedAtMax.retentionClock, now);
});
test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => {
test("reinforceMemory uses rolling elapsed window instead of UTC calendar days", () => {
const firstReinforcedAt = Date.UTC(2026, 3, 29, 0, 15);
const sameUtcDayMuchLater = Date.UTC(2026, 3, 29, 23, 30);
const nextUtcDayAfterInterval = Date.UTC(2026, 3, 30, 1, 30);
const afterWindow = firstReinforcedAt + 7 * DAY_MS;
const base: LongTermMemoryEntry = {
...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"),
reinforcementCount: 1,
@@ -613,20 +631,21 @@ test("reinforceMemory requires distinct UTC calendar days between reinforcements
lastReinforcedSessionID: "session-a",
};
assert.equal(reinforceMemory(base, "session-b", sameUtcDayMuchLater), base);
assert.equal(tryReinforceMemory(base, "session-b", sameUtcDayMuchLater).memory, base);
assert.equal(tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory, base);
const reinforcedNextDay = reinforceMemory(base, "session-b", nextUtcDayAfterInterval);
assert.notEqual(reinforcedNextDay, base);
assert.equal(reinforcedNextDay.reinforcementCount, 2);
assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval);
assert.equal(reinforcedNextDay.lastReinforcedSessionID, "session-b");
assert.equal(reinforcedNextDay.retentionClock, nextUtcDayAfterInterval);
const reinforcedAfterWindow = tryReinforceMemory(base, "session-b", afterWindow).memory;
assert.notEqual(reinforcedAfterWindow, base);
assert.equal(reinforcedAfterWindow.reinforcementCount, 2);
assert.equal(reinforcedAfterWindow.lastReinforcedAt, afterWindow);
assert.equal(reinforcedAfterWindow.lastReinforcedSessionID, "session-b");
assert.equal(reinforcedAfterWindow.retentionClock, afterWindow);
const atMax: LongTermMemoryEntry = {
...base,
reinforcementCount: 6,
};
assert.equal(reinforceMemory(atMax, "session-c", nextUtcDayAfterInterval), atMax);
assert.equal(tryReinforceMemory(atMax, "session-c", nextUtcDayAfterInterval).memory, atMax);
});
test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => {
@@ -716,37 +735,75 @@ test("reinforced memory with same initial strength and age ranks above unreinfor
assert.deepEqual(kept.map(memory => memory.id), ["reinforced", "unreinforced"]);
});
test("dedupe reinforcement does not increment for same session", () => {
const now = Date.now();
test("dedupe reinforcement allows same session after rolling 8 days with increment evidence", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing", "Use pnpm for package management", "decision"),
source: "manual",
pendingOwnerSessionID: "same-session",
reinforcementCount: 1,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
lastReinforcedAt: now - 8 * DAY_MS,
lastReinforcedSessionID: "same-session",
retentionClock: now - 8 * DAY_MS,
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate", "use pnpm for package management!!!", "decision"),
pendingOwnerSessionID: "same-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing");
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.reinforcementCount, 2);
assert.equal(retained.lastReinforcedSessionID, "same-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
assert.equal(retained.retentionClock, now);
assert.ok(reinforced, "same-session after window should emit reinforced evidence");
assert.equal(reinforced.details?.reinforcementOutcome, "reinforced");
assert.equal(reinforced.details?.reinforcementMode, "increment");
assert.equal(reinforced.details?.sameSession, true);
assert.equal(reinforced.details?.elapsedMs, 8 * DAY_MS);
assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS);
assert.equal(reinforced.details?.attemptedAtMs, now);
assert.equal(reinforced.details?.lastReinforcedAtMs, now - 8 * DAY_MS);
assert.equal(reinforced.details?.previousReinforcementCount, 1);
assert.equal(reinforced.details?.newReinforcementCount, 2);
assert.equal(JSON.stringify(reinforced.details).includes("same-session"), false);
});
test("dedupe reinforcement does not increment under one hour", () => {
const now = Date.now();
test("dedupe reinforcement allows exactly 7 rolling days", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing-exact-window", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 5,
lastReinforcedAt: now - 7 * DAY_MS,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-exact-window", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing-exact-window");
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.equal(retained?.reinforcementCount, 6);
assert.ok(reinforced, "exact 7-day window should emit reinforced evidence");
assert.equal(reinforced.details?.reinforcementMode, "increment");
assert.equal(reinforced.details?.elapsedMs, 7 * DAY_MS);
assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS);
});
test("dedupe reinforcement blocks 7 rolling days minus 1ms with elapsed details", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 1,
lastReinforcedAt: now - 30 * 60 * 1000,
lastReinforcedAt: now - 7 * DAY_MS + 1,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
@@ -754,29 +811,109 @@ test("dedupe reinforcement does not increment under one hour", () => {
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing");
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "old-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(blocked, "below-window duplicate reinforcement should emit rejected evidence");
assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked"));
assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_elapsed_window"));
assert.equal(blocked.reasonCodes.includes("reinforcement_block_min_interval"), false);
assert.equal(blocked.details?.blockReason, "min_elapsed_window");
assert.equal(blocked.details?.sameSession, false);
assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 1);
assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS);
assert.equal(blocked.details?.reinforcementCount, 1);
assert.equal(blocked.details?.maxReinforcementCount, 6);
assert.equal("minIntervalMs" in (blocked.details ?? {}), false);
assert.equal("reinforcementMode" in (blocked.details ?? {}), false);
});
test("dedupe reinforcement does not emit evidence at max reinforcement count", () => {
test("dedupe reinforcement blocks same session at 6d23h as min elapsed window", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing-same-session-window", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
pendingOwnerSessionID: "same-session-window",
reinforcementCount: 1,
lastReinforcedAt: now - (7 * DAY_MS - 60 * 60 * 1000),
lastReinforcedSessionID: "same-session-window",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-same-session-window", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "same-session-window",
};
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
assert.ok(blocked, "6d23h same-session attempt should be rejected");
assert.equal(blocked.details?.blockReason, "min_elapsed_window");
assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 60 * 60 * 1000);
assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS);
assert.equal(blocked.details?.sameSession, true);
assert.equal(blocked.reasonCodes.includes("reinforcement_block_same_session"), false);
});
test("dedupe reinforcement refreshes saturated count after rolling window", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const oldRetentionClock = now - 30 * DAY_MS;
const existing: LongTermMemoryEntry = {
...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 6,
lastReinforcedAt: now - 7 * DAY_MS,
lastReinforcedSessionID: "old-session",
retentionClock: oldRetentionClock,
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing-max");
const refreshed = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
assert.equal(retained?.reinforcementCount, 6);
assert.equal(retained?.retentionClock, now);
assert.equal(retained?.lastReinforcedAt, now);
assert.equal(retained?.lastReinforcedSessionID, "new-session");
assert.ok(refreshed, "saturated after-window attempt should emit refreshed evidence");
assert.equal(refreshed.details?.reinforcementOutcome, "refreshed");
assert.equal(refreshed.details?.reinforcementMode, "refresh_only");
assert.equal(refreshed.details?.previousReinforcementCount, 6);
assert.equal(refreshed.details?.newReinforcementCount, 6);
assert.ok(refreshed.reasonCodes.includes("reinforcement_saturation_refresh"));
assert.equal(refreshed.reasonCodes.includes("reinforcement_block_max_count"), false);
});
test("dedupe reinforcement reports legacy missing timestamp and normalizes it", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing-legacy-missing", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 2,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-legacy-missing", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing-legacy-missing");
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.equal(retained?.reinforcementCount, 3);
assert.equal(retained?.lastReinforcedAt, now);
assert.equal(retained?.retentionClock, now);
assert.equal(reinforced?.details?.legacyMissingTimestamp, true);
assert.equal(reinforced?.details?.reinforcementMode, "increment");
});
test("enforceLongTermLimits orders entries by retention strength", () => {
@@ -2644,6 +2781,10 @@ test("enforceLongTermLimitsWithAccounting keeps 11th and 12th decisions and type
assert.ok(event.relations?.[0]?.memory.memoryKeyHash, "removed relation should include memory key hash");
assert.ok(event.relations?.[0]?.memory.identityKeyHash, "removed relation should include identity key hash");
assert.deepEqual(event.reasonCodes, ["type_cap"]);
assert.equal(typeof event.details?.strengthAtRemoval, "number");
assert.equal(typeof event.details?.rankAtRemoval, "number");
assert.equal(typeof event.details?.typeRankAtRemoval, "number");
assert.equal(typeof event.details?.ageDaysAtRemoval, "number");
}
});
+27
View File
@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"noEmit": false,
"outDir": "dist",
"rootDir": ".",
"declaration": false,
"sourceMap": false,
"rewriteRelativeImportExtensions": true,
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts", "src/**/*.ts"],
"exclude": [
"node_modules",
"dist",
"tests",
"src/plugin.ts",
"src/tui-plugin.ts",
"src/opencode.ts",
"src/session-state.ts",
"src/extractors.ts",
"src/pending-journal.ts",
"src/promotion-accounting.ts",
"src/memory-visibility.ts"
]
}