mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5163ea3b8f | |||
| 9591f85dca | |||
| 93550b2e41 | |||
| 3c4282b241 | |||
| 5bca3432b0 | |||
| e4dfe81d89 | |||
| 9b6955f490 | |||
| e708e77e61 | |||
| 9114b57dc1 | |||
| 2ff17ea1b3 | |||
| 65b3b2f2c3 | |||
| 49bf866de2 |
@@ -5,6 +5,55 @@ 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.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
|
||||
|
||||
@@ -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 OpenCode’s existing compaction flow.
|
||||
- **Numbered memory refs** — compaction can `REINFORCE [M#]` useful memories or safely `REPLACE [M#]` obsolete compaction memories.
|
||||
- **No manual tools** — memory is injected automatically into the system prompt.
|
||||
- **Quality guards** — filters noisy memories, temporary progress snapshots, stack traces, raw errors, and credentials.
|
||||
- **Retention decay** — keeps the strongest memories in prompt context while older or weaker memories fade out naturally; important and reinforced memories decay more slowly.
|
||||
| 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
|
||||
|
||||
|
||||
@@ -1,5 +1,128 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.6.3 (2026-05-14)
|
||||
|
||||
### Diagnostic Quality Review Board
|
||||
|
||||
This patch release focuses on safer memory diagnostics. It adds a read-only quality review board and finer reinforcement drill-downs so reviewers can inspect memory-system evidence without treating historical artifacts as current failures or turning heuristic flags into automatic cleanup decisions.
|
||||
|
||||
The goal is better observability before policy changes: diagnostics show facts, provenance, answerability, and review questions, while leaving judgment to the operator.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Quality review board**: `memory-diag quality` now reports system-mechanism facts for rejection filters, reinforcement rules, eviction/caps, identity/dedup, and active memory content review.
|
||||
- **Version/provenance context**: diagnostics distinguish current producer-instrumented events from historical or unversioned evidence where possible, and label ambiguous evidence conservatively.
|
||||
- **Focused reinforcement drill-down**: `memory-diag commands --memory <memory-id>` shows one memory's reinforcement command evidence, current status, recorded block reasons, missing details, and UTC-day evidence.
|
||||
- **Canonical active-memory JSON surface**: `memory-diag quality --json` now uses `activeMemoryDisplay` as the single active-memory review surface instead of duplicating active memories under `reviewCandidates`.
|
||||
- **Attribution-safe wording**: quality and reinforcement diagnostics avoid claiming that a block is a bug, policy failure, or cause of memory loss; they present recorded evidence and review prompts instead.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes are required.
|
||||
- Existing workspace memory files and evidence logs remain compatible.
|
||||
- If you consume `memory-diag quality --json` from unreleased builds after v1.6.2, read active-memory review data from `activeMemoryDisplay`; `reviewCandidates` is now reserved for system-mechanism candidates.
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory@1.6.3 memory-diag quality
|
||||
npx --package opencode-working-memory@1.6.3 memory-diag quality --json
|
||||
npx --package opencode-working-memory@1.6.3 memory-diag commands --memory <memory-id>
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `node --test --experimental-strip-types tests/memory-diag-quality.test.ts` — 53 tests passing
|
||||
- `node --test --experimental-strip-types tests/memory-diag.test.ts` — 39 tests passing
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 486 tests passing, `TEST_PASS`
|
||||
- `npm run build` — `BUILD_PASS`
|
||||
- `npm run test:pack:memory-diag` — packed tarball smoke test passed
|
||||
|
||||
---
|
||||
|
||||
## 1.6.2 (2026-05-11)
|
||||
|
||||
### 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
|
||||
|
||||
@@ -55,6 +55,30 @@ Long-term memory that persists across sessions within the same workspace. Perfec
|
||||
}
|
||||
```
|
||||
|
||||
### Evidence Log Schema
|
||||
|
||||
Workspace diagnostics also read the append-only evidence log for the current workspace. New evidence records are additive and keep historical records valid:
|
||||
|
||||
```typescript
|
||||
{
|
||||
version: 1,
|
||||
eventId: string,
|
||||
createdAt: string,
|
||||
workspaceKey: string,
|
||||
workspaceRootHash: string,
|
||||
producerName?: string,
|
||||
producerVersion?: string,
|
||||
instrumentationVersion?: number,
|
||||
type: EvidenceEventType,
|
||||
phase: EvidencePhase,
|
||||
outcome: EvidenceOutcome,
|
||||
reasonCodes: string[],
|
||||
details?: Record<string, string | number | boolean | null | string[] | number[]>
|
||||
}
|
||||
```
|
||||
|
||||
Instrumentation version 2 adds optional causal details for future diagnostics without backfilling old JSONL records. Reinforcement-block events may include `details.blockReason` (for example `same_session` or `same_utc_day`). Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior.
|
||||
|
||||
### Entry Types
|
||||
|
||||
| Type | Purpose | Example |
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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 only for events produced after instrumentation version 2. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections.
|
||||
- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and UTC-day grouping; they still require human content judgment.
|
||||
- Producer-instrumented capacity removals with rank/strength snapshots can upgrade `evictionAndCaps` to `partial`; fullness alone remains occupancy inventory, not proof of a capacity problem.
|
||||
|
||||
## Examples
|
||||
|
||||
```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, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode.
|
||||
|
||||
Use `commands --memory <memory-id>` when you need a focused, evidence-only reinforcement view for one memory. It reports current memory status separately from recorded reinforcement attempts, block reasons, missing block details, and UTC-day evidence without judging whether the policy is correct.
|
||||
|
||||
## Dry-run Recovery
|
||||
|
||||
`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
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.3",
|
||||
"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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
const { execFileSync } = require("child_process");
|
||||
const { existsSync } = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function isSupportedNodeVersion(version) {
|
||||
@@ -9,13 +10,19 @@ function isSupportedNodeVersion(version) {
|
||||
}
|
||||
|
||||
if (!isSupportedNodeVersion(process.versions.node)) {
|
||||
process.stderr.write(`memory-diag requires Node >=22.6.0 because it runs TypeScript with --experimental-strip-types. Current Node: v${process.versions.node}.\n`);
|
||||
process.stderr.write(`memory-diag requires Node >=22.6.0 per opencode-working-memory package engines. Current Node: v${process.versions.node}.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binDir = __dirname;
|
||||
const tsScript = path.join(binDir, "memory-diag.ts");
|
||||
const args = ["--experimental-strip-types", tsScript, ...process.argv.slice(2)];
|
||||
const compiledScript = path.join(binDir, "..", "dist", "scripts", "memory-diag.js");
|
||||
|
||||
if (!existsSync(compiledScript)) {
|
||||
process.stderr.write("memory-diag package is missing dist/scripts/memory-diag.js. Reinstall opencode-working-memory or run npm run build before using the local package.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = [compiledScript, ...process.argv.slice(2)];
|
||||
try {
|
||||
execFileSync(process.execPath, args, { stdio: "inherit" });
|
||||
process.exit(0);
|
||||
|
||||
@@ -9,7 +9,8 @@ export function usage(): string {
|
||||
memory-diag rejected [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag missing [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag explain [memory-id] [--workspace <path>] [--raw]
|
||||
memory-diag commands [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag commands [--workspace <path>] [--verbose] [--json] [--memory <id>]
|
||||
memory-diag quality [--workspace <path>] [--verbose] [--json] [--raw] [--no-emoji]
|
||||
memory-diag revert (--memory <replacement-id> | --event <event-id>) [--workspace <path>] [--apply]
|
||||
|
||||
Global options:
|
||||
@@ -103,12 +104,12 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
|
||||
if (command === "status") {
|
||||
if (options.all) return error(`${command} does not accept --all`);
|
||||
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "revert") {
|
||||
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "quality" || command === "revert") {
|
||||
if (options.all) return error(`${command} does not accept --all`);
|
||||
} else {
|
||||
if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`);
|
||||
}
|
||||
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands") {
|
||||
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands" && command !== "quality") {
|
||||
return error(`${command} does not accept --json`);
|
||||
}
|
||||
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
|
||||
@@ -120,7 +121,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
if (command !== "audit" && options.migration) {
|
||||
return error(`${command} does not accept --migration`);
|
||||
}
|
||||
if (command !== "explain" && command !== "revert" && options.memory) {
|
||||
if (command !== "explain" && command !== "revert" && command !== "commands" && options.memory) {
|
||||
return error(`${command} does not accept --memory`);
|
||||
}
|
||||
if (command !== "revert" && options.event) return error(`${command} does not accept --event`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "revert"] as const;
|
||||
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "quality", "revert"] as const;
|
||||
export const HIDDEN_COMMANDS = ["coverage", "audit"] as const;
|
||||
|
||||
export type VisibleCommand = typeof VISIBLE_COMMANDS[number];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { runCommands } from "./commands/commands.ts";
|
||||
import { runCoverage } from "./commands/coverage.ts";
|
||||
import { runExplain } from "./commands/explain.ts";
|
||||
import { runMissing } from "./commands/missing.ts";
|
||||
import { runQuality } from "./commands/quality.ts";
|
||||
import { runRejected } from "./commands/rejected.ts";
|
||||
import { runRevert } from "./commands/revert.ts";
|
||||
import { runStatus } from "./commands/status.ts";
|
||||
@@ -17,6 +18,7 @@ export async function dispatch(command: Command, options: CliOptions): Promise<C
|
||||
case "audit": return runAudit(options);
|
||||
case "explain": return runExplain(options);
|
||||
case "commands": return runCommands(options);
|
||||
case "quality": return runQuality(options);
|
||||
case "revert": return runRevert(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { queryEvidenceEvents, type EvidenceEventV1, type EvidenceOutcome } from "../../../src/evidence-log.ts";
|
||||
import { workspaceKey, workspaceMemoryPath } from "../../../src/paths.ts";
|
||||
import type { WorkspaceMemoryStore } from "../../../src/types.ts";
|
||||
import { accountWorkspaceMemoryRender } from "../../../src/workspace-memory.ts";
|
||||
import { readJSONFile } from "../io.ts";
|
||||
import { objectFromCounts, sortedCounts } from "../text.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
import { normalizedStore } from "../workspace-snapshot.ts";
|
||||
|
||||
type CommandKind = "reinforce" | "replace";
|
||||
|
||||
@@ -33,6 +38,42 @@ type MemoryCommandSummary = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type MemoryCommandDetail = {
|
||||
version: 1;
|
||||
generatedAt: string;
|
||||
memoryId: string;
|
||||
current: {
|
||||
present: boolean;
|
||||
status?: string;
|
||||
renderStatus?: "rendered" | "not_rendered" | "unknown";
|
||||
type?: string;
|
||||
source?: string;
|
||||
};
|
||||
summary: {
|
||||
attempts: number;
|
||||
reinforced: number;
|
||||
rejectedOrBlocked: number;
|
||||
windowBlocked: number;
|
||||
blocksByReason: Record<string, number>;
|
||||
blockDetailsMissing: number;
|
||||
refs: string[];
|
||||
sameSessionCrossUtcDayBlocks: number;
|
||||
};
|
||||
events: Array<{
|
||||
eventId: string;
|
||||
createdAt: string;
|
||||
outcome: EvidenceOutcome;
|
||||
ref?: string;
|
||||
blockReason?: string;
|
||||
reasonCodes: string[];
|
||||
attemptedAtIso?: string;
|
||||
lastReinforcedAtIso?: string;
|
||||
crossUtcDay?: boolean | "unknown";
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const INVALID_COMMAND_REASONS = new Set([
|
||||
"invalid_memory_command",
|
||||
"invalid_memory_ref",
|
||||
@@ -66,6 +107,124 @@ 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 isRejectedOrBlocked(event: EvidenceEventV1): boolean {
|
||||
return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked");
|
||||
}
|
||||
|
||||
function blockReasonFor(event: EvidenceEventV1): string | undefined {
|
||||
if (!isRejectedOrBlocked(event)) return undefined;
|
||||
return stringDetail(event, "blockReason") ?? "unknown";
|
||||
}
|
||||
|
||||
function isCrossUtcDay(attemptedAtIso: string | undefined, lastReinforcedAtIso: string | undefined): boolean | "unknown" {
|
||||
if (!attemptedAtIso || !lastReinforcedAtIso) return "unknown";
|
||||
const attempted = new Date(attemptedAtIso);
|
||||
const lastReinforced = new Date(lastReinforcedAtIso);
|
||||
if (Number.isNaN(attempted.getTime()) || Number.isNaN(lastReinforced.getTime())) return "unknown";
|
||||
return attempted.toISOString().slice(0, 10) !== lastReinforced.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function currentMemoryStatus(root: string, memoryId: string): Promise<MemoryCommandDetail["current"]> {
|
||||
const rawStore = await readJSONFile<WorkspaceMemoryStore>(await workspaceMemoryPath(root));
|
||||
const storeRoot = rawStore?.workspace?.root ?? root;
|
||||
const storeKey = rawStore?.workspace?.key ?? await workspaceKey(root);
|
||||
const store = normalizedStore(rawStore, storeRoot, storeKey);
|
||||
const activeEntry = store.entries.find(entry => entry.id === memoryId && entry.status !== "superseded");
|
||||
const renderAccounting = accountWorkspaceMemoryRender(store);
|
||||
const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id));
|
||||
const omittedIds = new Set(renderAccounting.omitted.map(item => item.memory.id));
|
||||
const renderStatus = renderedIds.has(memoryId)
|
||||
? "rendered"
|
||||
: omittedIds.has(memoryId)
|
||||
? "not_rendered"
|
||||
: "unknown";
|
||||
|
||||
if (!activeEntry) {
|
||||
return { present: false, renderStatus };
|
||||
}
|
||||
|
||||
return {
|
||||
present: true,
|
||||
status: activeEntry.status,
|
||||
renderStatus,
|
||||
type: activeEntry.type,
|
||||
source: activeEntry.source,
|
||||
};
|
||||
}
|
||||
|
||||
function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][number] {
|
||||
const attemptedAtIso = stringDetail(event, "attemptedAtIso");
|
||||
const lastReinforcedAtIso = stringDetail(event, "lastReinforcedAtIso");
|
||||
const blocked = isRejectedOrBlocked(event);
|
||||
const blockReason = blockReasonFor(event);
|
||||
return {
|
||||
eventId: event.eventId,
|
||||
createdAt: event.createdAt,
|
||||
outcome: event.outcome,
|
||||
ref: refFromEvent(event),
|
||||
blockReason,
|
||||
reasonCodes: event.reasonCodes,
|
||||
attemptedAtIso,
|
||||
lastReinforcedAtIso,
|
||||
crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined,
|
||||
producerVersion: event.producerVersion,
|
||||
instrumentationVersion: event.instrumentationVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildMemoryCommandDetail(
|
||||
root: string,
|
||||
memoryId: string,
|
||||
events: EvidenceEventV1[],
|
||||
generatedAt = new Date().toISOString(),
|
||||
): Promise<MemoryCommandDetail> {
|
||||
const reinforcementEvents = events.filter(isReinforcementEvent);
|
||||
const blockReasonCounts = new Map<string, number>();
|
||||
const refs = new Set<string>();
|
||||
let blockDetailsMissing = 0;
|
||||
let sameSessionCrossUtcDayBlocks = 0;
|
||||
|
||||
for (const event of reinforcementEvents) {
|
||||
const ref = refFromEvent(event);
|
||||
if (ref) refs.add(ref);
|
||||
if (!isRejectedOrBlocked(event)) continue;
|
||||
|
||||
const blockReason = blockReasonFor(event) ?? "unknown";
|
||||
blockReasonCounts.set(blockReason, (blockReasonCounts.get(blockReason) ?? 0) + 1);
|
||||
if (!stringDetail(event, "blockReason")) blockDetailsMissing += 1;
|
||||
if (blockReason === "same_session" && isCrossUtcDay(stringDetail(event, "attemptedAtIso"), stringDetail(event, "lastReinforcedAtIso")) === true) {
|
||||
sameSessionCrossUtcDayBlocks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt,
|
||||
memoryId,
|
||||
current: await currentMemoryStatus(root, memoryId),
|
||||
summary: {
|
||||
attempts: reinforcementEvents.length,
|
||||
reinforced: reinforcementEvents.filter(event => event.outcome === "reinforced").length,
|
||||
rejectedOrBlocked: reinforcementEvents.filter(isRejectedOrBlocked).length,
|
||||
windowBlocked: reinforcementEvents.filter(event => hasReason(event, "reinforcement_window_blocked")).length,
|
||||
blocksByReason: objectFromCounts(blockReasonCounts),
|
||||
blockDetailsMissing,
|
||||
refs: [...refs].sort(),
|
||||
sameSessionCrossUtcDayBlocks,
|
||||
},
|
||||
events: reinforcementEvents.map(detailEventJSON),
|
||||
};
|
||||
}
|
||||
|
||||
function latestEventJSON(event: EvidenceEventV1): MemoryCommandSummary["latestEvents"][number] {
|
||||
return {
|
||||
eventId: event.eventId,
|
||||
@@ -145,6 +304,73 @@ function formatLatestEvents(events: MemoryCommandSummary["latestEvents"]): strin
|
||||
});
|
||||
}
|
||||
|
||||
function formatInlineCounts(counts: Record<string, number>): string {
|
||||
const rows = sortedCounts(new Map(Object.entries(counts)));
|
||||
return rows.length > 0 ? rows.map(([reason, count]) => `${reason}=${count}`).join(", ") : "(none)";
|
||||
}
|
||||
|
||||
function formatCrossUtcDay(value: boolean | "unknown" | undefined): string {
|
||||
if (value === true) return "yes";
|
||||
if (value === false) return "no";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] {
|
||||
if (events.length === 0) return [" (none)"];
|
||||
return events.map(event => {
|
||||
const ref = event.ref ? ` ref=${event.ref}` : "";
|
||||
const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : "";
|
||||
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
|
||||
const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : "";
|
||||
const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : "";
|
||||
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${attemptedAt}${lastReinforcedAt}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMemoryCommandDetail(detail: MemoryCommandDetail, options: Pick<CliOptions, "verbose"> = {}): string {
|
||||
const current = detail.current;
|
||||
const lines = [
|
||||
`Memory command diagnostics for ${detail.memoryId}`,
|
||||
"",
|
||||
"Current memory:",
|
||||
` - present: ${current.present ? "yes" : "no"}`,
|
||||
` - status: ${current.status ?? "unknown"}`,
|
||||
` - render: ${current.renderStatus ?? "unknown"}`,
|
||||
];
|
||||
|
||||
if (current.type) lines.push(` - type: ${current.type}`);
|
||||
if (current.source) lines.push(` - source: ${current.source}`);
|
||||
|
||||
lines.push("");
|
||||
if (detail.summary.attempts === 0) {
|
||||
lines.push(`No reinforcement command evidence found for ${detail.memoryId}.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"Reinforcement summary:",
|
||||
` - attempts: ${detail.summary.attempts}`,
|
||||
` - reinforced: ${detail.summary.reinforced}`,
|
||||
` - rejected/blocked: ${detail.summary.rejectedOrBlocked}`,
|
||||
` - window blocked: ${detail.summary.windowBlocked}`,
|
||||
` - block reasons: ${formatInlineCounts(detail.summary.blocksByReason)}`,
|
||||
` - block details missing: ${detail.summary.blockDetailsMissing}`,
|
||||
` - same-session cross UTC day blocks: ${detail.summary.sameSessionCrossUtcDayBlocks}`,
|
||||
` - refs: ${detail.summary.refs.length > 0 ? detail.summary.refs.join(", ") : "(none)"}`,
|
||||
"",
|
||||
);
|
||||
|
||||
const eventRows = options.verbose ? detail.events : detail.events.slice(-10).reverse();
|
||||
if (!options.verbose && detail.events.length > eventRows.length) {
|
||||
lines.push(`Latest reinforcement events (showing ${eventRows.length} of ${detail.events.length}):`);
|
||||
} else {
|
||||
lines.push("Latest reinforcement events:");
|
||||
}
|
||||
lines.push(...formatMemoryCommandDetailEvents(eventRows));
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatMemoryCommandSummary(summary: MemoryCommandSummary, options: Pick<CliOptions, "verbose" | "noEmoji"> = {}): string {
|
||||
const warning = options.noEmoji ? "!" : "⚠";
|
||||
const lines = [
|
||||
@@ -176,6 +402,17 @@ export function formatMemoryCommandSummary(summary: MemoryCommandSummary, option
|
||||
|
||||
export async function runCommands(options: CliOptions): Promise<CommandResult> {
|
||||
const root = options.workspace ?? process.cwd();
|
||||
if (options.memory) {
|
||||
const events = await queryEvidenceEvents(root, { memoryId: options.memory });
|
||||
const detail = await buildMemoryCommandDetail(root, options.memory, events);
|
||||
|
||||
if (options.json) {
|
||||
return { stdout: JSON.stringify(detail, null, 2) };
|
||||
}
|
||||
|
||||
return { stdout: formatMemoryCommandDetail(detail, options) };
|
||||
}
|
||||
|
||||
const events = await queryEvidenceEvents(root);
|
||||
const summary = buildMemoryCommandSummary(events);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { buildQualityJSON, formatQualityReviewBoard } from "../formatters/quality.ts";
|
||||
import { buildInspectionReadModel } from "../inspection-model.ts";
|
||||
import { buildQualityReviewBoard } from "../quality-review-model.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
|
||||
export async function runQuality(options: CliOptions): Promise<CommandResult> {
|
||||
const model = await buildInspectionReadModel(options);
|
||||
const report = buildQualityReviewBoard(model, {
|
||||
verbose: options.verbose,
|
||||
raw: options.raw,
|
||||
json: options.json,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
return { stdout: JSON.stringify(buildQualityJSON(report, options.raw), null, 2) };
|
||||
}
|
||||
|
||||
return { stdout: formatQualityReviewBoard(report, { verbose: options.verbose }) };
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { cleanText, formatDetails } from "../text.ts";
|
||||
import type {
|
||||
CandidateProvenance,
|
||||
HeuristicFlag,
|
||||
ProvenanceClassification,
|
||||
RejectionVersionFacts,
|
||||
ReinforcementVersionFacts,
|
||||
ReviewBoardActiveMemory,
|
||||
ReviewBoardCandidate,
|
||||
ReviewBoardReport,
|
||||
EvictionVersionFacts,
|
||||
VersionAvailability,
|
||||
VersionCoverage,
|
||||
VersionedMechanismFacts,
|
||||
} from "../quality-review-model.ts";
|
||||
|
||||
const PROVENANCE_ORDER: ProvenanceClassification[] = [
|
||||
"explicit_migration_evidence",
|
||||
"legacy_unversioned_format",
|
||||
"reabsorbed_post_rejection",
|
||||
"suspected_pre_migration_legacy",
|
||||
"likely_current_behavior",
|
||||
"unversioned_ambiguous",
|
||||
];
|
||||
|
||||
const REVIEW_FLAG_CAVEAT = "This flag is a prompt for review, not a conclusion.";
|
||||
|
||||
export function buildQualityJSON(report: ReviewBoardReport, raw = false): unknown {
|
||||
if (raw) return report;
|
||||
return redactUnknown(report);
|
||||
}
|
||||
|
||||
export function formatQualityReviewBoard(
|
||||
report: ReviewBoardReport,
|
||||
options: { verbose?: boolean },
|
||||
): string {
|
||||
const bullet = "-";
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("Memory quality review board");
|
||||
lines.push("Purpose: evidence for human/agent review only; no automatic judgment or cleanup.");
|
||||
lines.push("Producer version note: historical records do not include package/plugin version; provenance below is inferred.");
|
||||
lines.push("Primary review purpose: SYSTEM MECHANISM observations (filters, reinforcement, eviction/caps, identity/dedup).");
|
||||
lines.push("Secondary review purpose: MEMORY CONTENT quality (staleness, durability, redundancy, specificity).");
|
||||
lines.push("");
|
||||
|
||||
pushEvidenceProvenance(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushSystemMechanismFacts(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushMemoryContentFacts(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushSystemMechanismCandidates(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushMemoryContentCandidates(lines, report, bullet, options);
|
||||
lines.push("");
|
||||
pushReviewQuestions(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushNextCommands(lines, report, bullet);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function pushEvidenceProvenance(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
const context = report.provenanceContext;
|
||||
const instrumentation = report.facts.systemMechanisms.instrumentation;
|
||||
lines.push("Evidence provenance");
|
||||
lines.push(` ${bullet} method: migration/timestamp/format inference`);
|
||||
lines.push(` ${bullet} confidence: ${context.confidenceDisclaimer}`);
|
||||
lines.push(` ${bullet} Producer coverage: ${instrumentation.evidenceEventsWithProducer} of ${instrumentation.evidenceEventsTotal} evidence events instrumented`);
|
||||
lines.push(` ${bullet} Rejection producer coverage: ${instrumentation.rejectionRecordsWithProducer} of ${instrumentation.rejectionRecordsTotal} rejection records instrumented`);
|
||||
lines.push(` ${bullet} instrumentation versions: ${formatCounts(instrumentation.instrumentationVersions)}`);
|
||||
lines.push(` ${bullet} migration timeline: ${formatMigrationTimeline(context.migrationTimeline)}`);
|
||||
if (context.lastActivityAt) lines.push(` ${bullet} last activity: ${context.lastActivityAt}`);
|
||||
}
|
||||
|
||||
function pushSystemMechanismFacts(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
const facts = report.facts.systemMechanisms;
|
||||
lines.push("Facts - system mechanisms");
|
||||
lines.push(" Provenance counts for mechanism evidence");
|
||||
lines.push(` ${bullet} ${formatProvenanceCounts(report.provenanceContext.countsByClassification)}`);
|
||||
lines.push(" Rejection filters");
|
||||
pushAnswerability(lines, report.answerability?.rejectionFilters, " ");
|
||||
lines.push(` ${bullet} rejected records: ${facts.rejectionFilters.totalRecords} (unique: ${facts.rejectionFilters.uniqueTexts})`);
|
||||
lines.push(` ${bullet} raw reason-code distribution: ${formatCounts(facts.rejectionFilters.byRawReasonCode)}`);
|
||||
lines.push(` ${bullet} type distribution: ${formatCounts(facts.rejectionFilters.byType)}`);
|
||||
lines.push(` ${bullet} ambiguous/architecture-like rejected candidates: ${facts.rejectionFilters.ambiguousOrArchitectureLike}`);
|
||||
lines.push(` ${bullet} status-or-hard-reason evidence: ${facts.rejectionFilters.hardReasonOrNoiseHeuristic}`);
|
||||
lines.push(` ${bullet} re-absorbed rejected texts: ${facts.rejectionFilters.reabsorbedRejectedTexts}`);
|
||||
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.rejectionFilters, facts.versionedFacts.versionCoverage, formatRejectionVersionFacts, bullet);
|
||||
lines.push(" Reinforcement rules");
|
||||
pushAnswerability(lines, report.answerability?.reinforcementRules, " ");
|
||||
lines.push(` ${bullet} reinforce attempts: ${facts.reinforcementRules.reinforceEvents}, reinforced: ${facts.reinforcementRules.reinforcedEvents}, rejected/blocked: ${facts.reinforcementRules.rejectedOrBlockedEvents}`);
|
||||
lines.push(` ${bullet} reinforcement-window blocked: ${facts.reinforcementRules.windowBlockedEvents} (rate: ${formatPercent(facts.reinforcementRules.windowBlockRate)})`);
|
||||
lines.push(` ${bullet} Exact block reasons: ${formatCounts(facts.reinforcementRules.blocksByExactReason)}`);
|
||||
lines.push(` ${bullet} window blocks by UTC day: ${formatCounts(facts.reinforcementRules.windowBlocksByUtcDay)}`);
|
||||
lines.push(` ${bullet} block details missing: ${facts.reinforcementRules.blockDetailsMissing}`);
|
||||
lines.push(` ${bullet} repeated blocks by memory: ${formatRepeatedBlocks(facts.reinforcementRules.repeatedBlocksByMemory)}`);
|
||||
lines.push(` ${bullet} malformed command events: ${facts.reinforcementRules.malformedCommandEvents}`);
|
||||
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.reinforcementRules, facts.versionedFacts.versionCoverage, formatReinforcementVersionFacts, bullet);
|
||||
lines.push(" Eviction and caps");
|
||||
pushAnswerability(lines, report.answerability?.evictionAndCaps, " ");
|
||||
lines.push(` ${bullet} active memories: ${facts.evictionAndCaps.activeMemories} / ${facts.evictionAndCaps.maxEntries}`);
|
||||
lines.push(` ${bullet} rendered memories: ${facts.evictionAndCaps.renderedMemories}`);
|
||||
lines.push(` ${bullet} cap occupancy: ${formatFullCaps(facts.evictionAndCaps.fullCaps, facts.evictionAndCaps.typeCounts, facts.evictionAndCaps.typeCaps, facts.evictionAndCaps.activeMemories, facts.evictionAndCaps.maxEntries)}`);
|
||||
lines.push(` ${bullet} capacity removals: total=${facts.evictionAndCaps.removedByCapacity}, global=${facts.evictionAndCaps.removedByGlobalCap}, type=${facts.evictionAndCaps.removedByTypeCap}`);
|
||||
lines.push(` ${bullet} Removals with snapshot: ${facts.evictionAndCaps.recentCapacityRemovalsWithSnapshot}`);
|
||||
lines.push(` ${bullet} Removals without snapshot: ${facts.evictionAndCaps.capacitySnapshotsMissing} (historical)`);
|
||||
if (facts.evictionAndCaps.highestRankRemoved) lines.push(` ${bullet} highest-rank removed snapshot: ${formatHighestRankRemoved(facts.evictionAndCaps.highestRankRemoved)}`);
|
||||
lines.push(` ${bullet} recent evictions by type: ${formatCounts(facts.evictionAndCaps.recentEvictionsByType)}`);
|
||||
lines.push(` ${bullet} recent evicted content shown: ${facts.evictionAndCaps.recentEvictedContentShown}`);
|
||||
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.evictionAndCaps, facts.versionedFacts.versionCoverage, formatEvictionVersionFacts, bullet);
|
||||
lines.push(" Unknown disappearances");
|
||||
pushAnswerability(lines, report.answerability?.unknownDisappearances, " ");
|
||||
lines.push(` ${bullet} unversioned disappearance inventory: evidence-only=${facts.evictionAndCaps.missingEvidenceOnly}, unknown=${facts.evictionAndCaps.unknownDisappearances}`);
|
||||
lines.push(" Identity and dedup");
|
||||
pushAnswerability(lines, report.answerability?.identityAndDedup, " ");
|
||||
lines.push(` ${bullet} replacements: total=${facts.identityAndDedup.replacementEvents}, same-type=${facts.identityAndDedup.sameTypeReplacementEvents}, cross-type=${facts.identityAndDedup.crossTypeReplacementEvents}`);
|
||||
lines.push(` ${bullet} superseded entries: ${facts.identityAndDedup.supersededEntries}`);
|
||||
lines.push(` ${bullet} exact duplicate/identity groups identified: ${facts.identityAndDedup.duplicateTextOrIdentityGroups}`);
|
||||
}
|
||||
|
||||
function pushMemoryContentFacts(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
const facts = report.facts.memoryContent;
|
||||
lines.push("Facts - memory content");
|
||||
pushAnswerability(lines, report.answerability?.memoryContent, " ");
|
||||
lines.push(` ${bullet} rendered memories: ${facts.renderedMemories}`);
|
||||
lines.push(` ${bullet} evidence coverage: ${facts.evidenceCoverage.covered} / ${facts.evidenceCoverage.total}`);
|
||||
lines.push(` ${bullet} type counts: ${formatTypeCountsWithCaps(facts.typeCounts, facts.typeCaps)}`);
|
||||
lines.push(` ${bullet} weakest/strongest active memory previews: weakest=${formatMemoryPreviews(facts.weakestActiveMemories)}; strongest=${formatMemoryPreviews(facts.strongestActiveMemories)}`);
|
||||
}
|
||||
|
||||
function pushAnswerability(
|
||||
lines: string[],
|
||||
assessment: NonNullable<ReviewBoardReport["answerability"]>[keyof NonNullable<ReviewBoardReport["answerability"]>] | undefined,
|
||||
indent: string,
|
||||
): void {
|
||||
if (!assessment) return;
|
||||
const suffix = assessment.level === "partial" ? " — causal fields exist, but human content judgment is still required" : "";
|
||||
lines.push(`${indent}(Answerability: ${assessment.level}${suffix})`);
|
||||
lines.push(`${indent}Output permission: ${assessment.outputPermission}`);
|
||||
}
|
||||
|
||||
function pushVersionAnalysis<TFacts>(
|
||||
lines: string[],
|
||||
mechanism: VersionedMechanismFacts<TFacts>,
|
||||
coverage: VersionCoverage,
|
||||
formatFacts: (facts: TFacts) => string,
|
||||
bullet: string,
|
||||
): void {
|
||||
lines.push(" Version analysis by producer version");
|
||||
lines.push(` Version-stamp coverage (all evidence/rejection records, not mechanism problem counts): Coverage: ${formatCoveragePercent(coverage.coveragePercent)} of ${formatInteger(coverage.totalEvents)} records carry a version stamp (${formatInteger(coverage.currentVersionEvents)} current, ${formatInteger(coverage.previousVersionEvents)} previous, ${formatInteger(coverage.unknownVersionEvents)} unknown/unversioned). Comparison will become meaningful as new events accumulate.`);
|
||||
if (coverage.isTransitional) {
|
||||
lines.push(" NOTE: Version coverage is below 50%. Current-version comparisons may not be representative.");
|
||||
}
|
||||
lines.push(` ${mechanismOpportunityDescription(mechanism)}`);
|
||||
for (const group of ["current", "previous", "unknown_unversioned"] as const) {
|
||||
const bucket = mechanism.buckets[group];
|
||||
lines.push(` ${bullet} ${bucket.label}: opportunities=${bucket.opportunityCount}, observed=${bucket.observedPatternCount}, sample=${bucket.sampleAssessment}, answerability=${bucket.answerabilityLevel}`);
|
||||
if (Object.keys(bucket.producerVersions).length > 0) lines.push(` producer versions: ${formatCounts(bucket.producerVersions)}`);
|
||||
lines.push(` composition: ${formatVersionAvailability(bucket.versionAvailability)}`);
|
||||
lines.push(` facts: ${formatFacts(bucket.facts)}`);
|
||||
}
|
||||
lines.push(` ${bullet} inference: ${mechanism.inference.message}`);
|
||||
lines.push(` diagnostic strength: ${diagnosticStrengthLabel(mechanism)}`);
|
||||
const diagnosticLine = currentMechanismDiagnosticLine(mechanism);
|
||||
if (diagnosticLine) lines.push(` ${diagnosticLine}`);
|
||||
if (mechanism.diagnosticQuestions) {
|
||||
for (const question of mechanism.diagnosticQuestions) {
|
||||
lines.push(` diagnostic question: ${question.question} Evidence: ${question.evidence.join(", ")}`);
|
||||
}
|
||||
}
|
||||
lines.push(` caveat: ${mechanism.inference.caveat}`);
|
||||
}
|
||||
|
||||
function diagnosticStrengthLabel<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string {
|
||||
const current = mechanism.buckets.current;
|
||||
const strength = mechanism.inference.status === "no_current_version_opportunities" || current.opportunityCount === 0
|
||||
? "unavailable"
|
||||
: current.opportunityCount < mechanism.sampleThreshold
|
||||
? "weak"
|
||||
: "moderate";
|
||||
return hasCurrentCausalDetail(mechanism) ? `${strength}; causal detail available` : strength;
|
||||
}
|
||||
|
||||
function hasCurrentCausalDetail<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): boolean {
|
||||
const currentFacts = mechanism.buckets.current.facts;
|
||||
return isReinforcementVersionFacts(currentFacts) && Object.keys(currentFacts.blocksByExactReason).length > 0;
|
||||
}
|
||||
|
||||
function currentMechanismDiagnosticLine<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string | undefined {
|
||||
const current = mechanism.buckets.current;
|
||||
if (!isReinforcementVersionFacts(current.facts)) return undefined;
|
||||
const facts = current.facts;
|
||||
const parts: string[] = [];
|
||||
if (Object.keys(facts.blocksByExactReason).length > 0) parts.push(`current block reasons=${formatCounts(facts.blocksByExactReason)}`);
|
||||
if (facts.blockDetailsMissing > 0) parts.push(`current block details missing=${facts.blockDetailsMissing}`);
|
||||
if (parts.length === 0) return undefined;
|
||||
parts.push(`sample=${current.opportunityCount} attempts`);
|
||||
return `diagnostic: ${parts.join("; ")}`;
|
||||
}
|
||||
|
||||
function isReinforcementVersionFacts(facts: unknown): facts is ReinforcementVersionFacts {
|
||||
return typeof facts === "object"
|
||||
&& facts !== null
|
||||
&& "blocksByExactReason" in facts
|
||||
&& "blockDetailsMissing" in facts
|
||||
&& "windowBlockedEvents" in facts;
|
||||
}
|
||||
|
||||
function mechanismOpportunityDescription<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string {
|
||||
if (mechanism.opportunityName === "rejection candidates") return "Mechanism opportunities below are reviewable rejection candidates only; render/accounting events are excluded.";
|
||||
if (mechanism.opportunityName === "attempts") return "Mechanism opportunities below are reinforcement attempts only; render/accounting events are excluded.";
|
||||
if (mechanism.opportunityName === "capacity removals") return "Mechanism opportunities below are capacity removals only; render/accounting events are excluded.";
|
||||
return `Mechanism opportunities below are ${mechanism.opportunityName} only; render/accounting events are excluded.`;
|
||||
}
|
||||
|
||||
function formatVersionAvailability(availability: VersionAvailability): string {
|
||||
const parts = [
|
||||
availability.noProducerFields > 0 ? `no producer fields=${availability.noProducerFields}` : undefined,
|
||||
availability.unknownProducerVersion > 0 ? `unknown version=${availability.unknownProducerVersion}` : undefined,
|
||||
availability.emptyProducerVersion > 0 ? `empty version=${availability.emptyProducerVersion}` : undefined,
|
||||
availability.knownProducerVersion > 0 ? `known version=${availability.knownProducerVersion}` : undefined,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
return parts.length === 0 ? "(empty bucket)" : parts.join(", ");
|
||||
}
|
||||
|
||||
function formatRejectionVersionFacts(facts: RejectionVersionFacts): string {
|
||||
return `records=${facts.totalRecords}, candidates=${facts.candidateRecords}, raw reason codes=${formatCounts(facts.byRawReasonCode)}, types=${formatCounts(facts.byType)}`;
|
||||
}
|
||||
|
||||
function formatReinforcementVersionFacts(facts: ReinforcementVersionFacts): string {
|
||||
return `reinforce attempts=${facts.reinforceEvents}, window blocked=${facts.windowBlockedEvents}, exact reasons=${formatCounts(facts.blocksByExactReason)}, block details missing=${facts.blockDetailsMissing}`;
|
||||
}
|
||||
|
||||
function formatEvictionVersionFacts(facts: EvictionVersionFacts): string {
|
||||
return `capacity removals=${facts.removedByCapacity}, with snapshot=${facts.recentCapacityRemovalsWithSnapshot}, missing snapshot=${facts.capacitySnapshotsMissing}`;
|
||||
}
|
||||
|
||||
function pushSystemMechanismCandidates(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
const display = report.provenanceContext.candidateDisplay;
|
||||
if (report.provenanceContext.candidateLimit && display && display.shown < display.total) {
|
||||
lines.push(`System mechanism review candidates (representative; ${display.shown} shown of ${display.total} total; limit ${report.provenanceContext.candidateLimit} per mechanism category)`);
|
||||
} else {
|
||||
lines.push("System mechanism review candidates");
|
||||
}
|
||||
pushCandidateGroup(lines, "Rejection filter evidence", candidatesFor(report, ["rejection_rule_evidence"]), bullet);
|
||||
pushCandidateGroup(lines, "Re-absorption evidence", candidatesFor(report, ["reabsorption_evidence"]), bullet);
|
||||
pushCandidateGroup(lines, "Reinforcement rule evidence", candidatesFor(report, ["numbered_command_evidence"]), bullet);
|
||||
pushCandidateGroup(lines, "Eviction/cap evidence", candidatesFor(report, ["eviction_cap_evidence", "missing_evidence"]), bullet);
|
||||
pushCandidateGroup(lines, "Identity/dedup evidence", candidatesFor(report, ["identity_dedup_evidence"]), bullet);
|
||||
}
|
||||
|
||||
function pushCandidateGroup(lines: string[], title: string, candidates: ReviewBoardCandidate[], bullet: string): void {
|
||||
lines.push(` ${title}`);
|
||||
if (candidates.length === 0) {
|
||||
lines.push(" (none)");
|
||||
return;
|
||||
}
|
||||
const shared = sharedProvenance(candidates);
|
||||
if (shared) lines.push(` shared provenance for displayed candidates in this group: ${formatProvenance(shared)}`);
|
||||
for (const candidate of candidates) pushCandidate(lines, candidate, bullet, shared);
|
||||
}
|
||||
|
||||
function pushCandidate(lines: string[], candidate: ReviewBoardCandidate, bullet: string, groupProvenance?: CandidateProvenance): void {
|
||||
const rawReasonCodes = candidate.evidence.rawReasonCodes && candidate.evidence.rawReasonCodes.length > 0
|
||||
? candidate.evidence.rawReasonCodes.join(", ")
|
||||
: "none";
|
||||
const question = candidate.reviewQuestions[0] ?? "What should a reviewer infer from this evidence?";
|
||||
lines.push(` ${bullet} concern=${formatConcern(candidate.concernKind)} id=${candidate.id} source=${candidate.source} mechanism=${candidate.mechanism ?? "unspecified"} raw reason codes=${rawReasonCodes} question=${question}`);
|
||||
if (candidate.provenance && (!groupProvenance || formatProvenance(candidate.provenance) !== formatProvenance(groupProvenance))) {
|
||||
lines.push(` provenance: ${formatProvenance(candidate.provenance)}`);
|
||||
}
|
||||
if (candidate.evidence.eventIds && candidate.evidence.eventIds.length > 0) lines.push(` event ids: ${candidate.evidence.eventIds.join(", ")}`);
|
||||
if (candidate.evidence.textAvailable) {
|
||||
lines.push(` text preview: ${candidate.evidence.textPreview ?? "available but empty after redaction"}`);
|
||||
} else {
|
||||
lines.push(" text preview: unavailable in historical evidence");
|
||||
}
|
||||
lines.push(` facts: ${formatCandidateFacts(candidate.facts)}`);
|
||||
pushHeuristicFlags(lines, candidate.heuristicFlags, " ", bullet);
|
||||
}
|
||||
|
||||
function pushMemoryContentCandidates(lines: string[], report: ReviewBoardReport, bullet: string, options: { verbose?: boolean }): void {
|
||||
const display = report.activeMemoryDisplay;
|
||||
lines.push("Memory content review candidates");
|
||||
if (report.reviewQuestions.memoryContent.length > 0) {
|
||||
lines.push(" Standard review questions (applicable to all active memories below):");
|
||||
for (const question of report.reviewQuestions.memoryContent) lines.push(` ${bullet} ${question}`);
|
||||
}
|
||||
if (display.total === 0) {
|
||||
lines.push(" Active memories (none)");
|
||||
return;
|
||||
}
|
||||
if (display.total <= display.threshold) {
|
||||
lines.push(` Active memories (showing all ${display.total} because <= ${display.threshold})`);
|
||||
} else if (display.mode === "all" || options.verbose) {
|
||||
lines.push(` Active memories (showing all ${display.total} because --verbose)`);
|
||||
} else {
|
||||
lines.push(` Active memories (showing ${display.shown} of ${display.total})`);
|
||||
lines.push(` Showing ${display.shown} of ${display.total} active memories. Use --verbose or --json for all active memory text.`);
|
||||
}
|
||||
display.items.forEach((item, index) => pushActiveMemory(lines, item, index + 1, bullet, report.reviewQuestions.memoryContent));
|
||||
}
|
||||
|
||||
function pushActiveMemory(lines: string[], item: ReviewBoardActiveMemory, index: number, bullet: string, standardQuestions: string[]): void {
|
||||
const strength = typeof item.strength === "number" ? item.strength.toFixed(3) : "unknown";
|
||||
lines.push(` [${index}] id=${item.id} type=${item.type} source=${item.source} status=${item.status} strength=${strength}`);
|
||||
lines.push(" text: " + indentContinuation(item.text, " "));
|
||||
const rawReasonCodes = item.evidence.rawReasonCodes.length > 0 ? item.evidence.rawReasonCodes.join(", ") : "none";
|
||||
lines.push(` evidence: events=${item.evidence.eventCount} raw reason codes=${rawReasonCodes}`);
|
||||
if (item.provenance) lines.push(` provenance: ${formatProvenance(item.provenance)}`);
|
||||
pushHeuristicFlags(lines, item.heuristicFlags, " ", bullet);
|
||||
if (questionsEqual(item.reviewQuestions, standardQuestions)) return;
|
||||
const additionalQuestions = item.reviewQuestions.filter(question => !standardQuestions.includes(question));
|
||||
if (additionalQuestions.length > 0 && additionalQuestions.length < item.reviewQuestions.length) {
|
||||
lines.push(" additional review questions:");
|
||||
for (const question of additionalQuestions) lines.push(` ${bullet} ${question}`);
|
||||
return;
|
||||
}
|
||||
lines.push(" review questions:");
|
||||
for (const question of item.reviewQuestions) lines.push(` ${bullet} ${question}`);
|
||||
}
|
||||
|
||||
function pushHeuristicFlags(lines: string[], flags: HeuristicFlag[], indent: string, bullet: string): void {
|
||||
if (flags.length === 0) return;
|
||||
lines.push(`${indent}heuristic flags:`);
|
||||
for (const flag of flags) {
|
||||
const caveat = flag.caveat || REVIEW_FLAG_CAVEAT;
|
||||
lines.push(`${indent} ${bullet} ${flag.label}: ${flag.evidence}. ${caveat}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pushReviewQuestions(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
lines.push("Review questions");
|
||||
lines.push(" SYSTEM MECHANISM");
|
||||
for (const question of report.reviewQuestions.systemMechanism) lines.push(` ${bullet} ${question}`);
|
||||
lines.push(" MEMORY CONTENT");
|
||||
for (const question of report.reviewQuestions.memoryContent) lines.push(` ${bullet} ${question}`);
|
||||
}
|
||||
|
||||
function pushNextCommands(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
lines.push("Next commands");
|
||||
for (const command of report.nextCommands) lines.push(` ${bullet} ${command}`);
|
||||
}
|
||||
|
||||
function candidatesFor(report: ReviewBoardReport, sources: ReviewBoardCandidate["source"][]): ReviewBoardCandidate[] {
|
||||
const sourceSet = new Set(sources);
|
||||
return report.reviewCandidates.filter(candidate => candidate.concernKind === "system_mechanism" && sourceSet.has(candidate.source));
|
||||
}
|
||||
|
||||
function sharedProvenance(candidates: ReviewBoardCandidate[]): CandidateProvenance | undefined {
|
||||
if (candidates.length <= 1) return undefined;
|
||||
const first = candidates[0]?.provenance;
|
||||
if (!first) return undefined;
|
||||
const key = formatProvenance(first);
|
||||
return candidates.every(candidate => candidate.provenance && formatProvenance(candidate.provenance) === key) ? first : undefined;
|
||||
}
|
||||
|
||||
function formatConcern(concern: ReviewBoardCandidate["concernKind"]): string {
|
||||
return concern === "system_mechanism" ? "SYSTEM MECHANISM" : "MEMORY CONTENT";
|
||||
}
|
||||
|
||||
function formatMigrationTimeline(timeline: ReviewBoardReport["provenanceContext"]["migrationTimeline"]): string {
|
||||
if (timeline.length === 0) return "(none)";
|
||||
return timeline.map(row => `${row.migrationId}=${row.presentInStore ? "present" : "absent"}${row.firstEvidenceAt ? ` firstEvidenceAt=${row.firstEvidenceAt}` : ""}`).join(", ");
|
||||
}
|
||||
|
||||
function formatProvenanceCounts(counts: Record<ProvenanceClassification, number>): string {
|
||||
return PROVENANCE_ORDER.map(classification => `${classification}=${counts[classification] ?? 0}`).join(", ");
|
||||
}
|
||||
|
||||
function formatCounts(counts: Record<string, number>): string {
|
||||
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
return entries.length === 0 ? "(none)" : entries.map(([key, count]) => `${key}=${count}`).join(", ");
|
||||
}
|
||||
|
||||
function formatTypeCountsWithCaps(counts: Record<string, number>, caps: Record<string, number>): string {
|
||||
const keys = uniqueSorted([...Object.keys(caps), ...Object.keys(counts)]);
|
||||
return keys.length === 0 ? "(none)" : keys.map(key => `${key} ${counts[key] ?? 0}/${caps[key] ?? "?"}`).join(", ");
|
||||
}
|
||||
|
||||
function formatFullCaps(fullCaps: string[], typeCounts: Record<string, number>, typeCaps: Record<string, number>, active: number, maxEntries: number): string {
|
||||
if (fullCaps.length === 0) return "(none)";
|
||||
return fullCaps.map(cap => cap === "global" ? `global ${active}/${maxEntries}` : `${cap} ${typeCounts[cap] ?? 0}/${typeCaps[cap] ?? "?"}`).join(", ");
|
||||
}
|
||||
|
||||
function formatRepeatedBlocks(blocks: ReviewBoardReport["facts"]["systemMechanisms"]["reinforcementRules"]["repeatedBlocksByMemory"]): string {
|
||||
if (blocks.length === 0) return "(none)";
|
||||
return blocks.map(block => `${block.memoryId} count=${block.count} refs=${block.refs.join("|") || "none"} raw reason codes=${block.rawReasonCodes.join("|") || "none"}`).join(", ");
|
||||
}
|
||||
|
||||
function formatHighestRankRemoved(snapshot: NonNullable<ReviewBoardReport["facts"]["systemMechanisms"]["evictionAndCaps"]["highestRankRemoved"]>): string {
|
||||
const parts = [
|
||||
`eventId=${snapshot.eventId}`,
|
||||
snapshot.memoryId ? `memoryId=${snapshot.memoryId}` : undefined,
|
||||
snapshot.type ? `type=${snapshot.type}` : undefined,
|
||||
`rankAtRemoval=${snapshot.rankAtRemoval}`,
|
||||
typeof snapshot.strengthAtRemoval === "number" ? `strengthAtRemoval=${snapshot.strengthAtRemoval}` : undefined,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function formatMemoryPreviews(items: ReviewBoardReport["facts"]["memoryContent"]["weakestActiveMemories"]): string {
|
||||
if (items.length === 0) return "(none)";
|
||||
return items.map(item => `${item.id} type=${item.type} strength=${typeof item.strength === "number" ? item.strength.toFixed(3) : "unknown"} text=${JSON.stringify(item.textPreview)}`).join(" | ");
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(Number.isFinite(value) ? value * 100 : 0).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatCoveragePercent(value: number): string {
|
||||
if (!Number.isFinite(value)) return "0%";
|
||||
return Number.isInteger(value) ? `${value}%` : `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatInteger(value: number): string {
|
||||
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
|
||||
}
|
||||
|
||||
function formatProvenance(provenance: CandidateProvenance): string {
|
||||
return `${provenance.classification} confidence=${provenance.confidence}; basis=${provenance.basis.join("; ") || "unavailable"}; caveat=${provenance.interpretationCaveat}`;
|
||||
}
|
||||
|
||||
function formatCandidateFacts(facts: Record<string, unknown>): string {
|
||||
if (Object.keys(facts).length === 0) return "(none)";
|
||||
return formatDetails(Object.fromEntries(
|
||||
Object.entries(facts).map(([key, value]) => [key, formatFactValue(value)]),
|
||||
));
|
||||
}
|
||||
|
||||
function formatFactValue(value: unknown): string | number | boolean | string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return value.map(item => typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null ? String(item) : stringifyUnknown(item));
|
||||
return stringifyUnknown(value);
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized === undefined ? String(value) : serialized;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function questionsEqual(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
function indentContinuation(text: string, indent: string): string {
|
||||
return text.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
|
||||
}
|
||||
|
||||
function uniqueSorted(values: string[]): string[] {
|
||||
return [...new Set(values)].sort();
|
||||
}
|
||||
|
||||
function redactUnknown(value: unknown): unknown {
|
||||
if (typeof value === "string") return cleanText(value, false);
|
||||
if (Array.isArray(value)) return value.map(item => redactUnknown(item));
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactUnknown(item)]));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,11 @@ export function normalizeRejection(record: RejectionLogRecord): NormalizedReject
|
||||
source: record.source,
|
||||
origin,
|
||||
fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger",
|
||||
producerName: typeof record.producerName === "string" ? record.producerName : undefined,
|
||||
producerVersion: typeof record.producerVersion === "string" ? record.producerVersion : undefined,
|
||||
instrumentationVersion: typeof record.instrumentationVersion === "number" ? record.instrumentationVersion : undefined,
|
||||
decisionLogicName: typeof record.decisionLogicName === "string" ? record.decisionLogicName : undefined,
|
||||
decisionLogicVersion: typeof record.decisionLogicVersion === "number" ? record.decisionLogicVersion : undefined,
|
||||
text: record.text,
|
||||
reasons: record.reasons,
|
||||
};
|
||||
|
||||
@@ -93,6 +93,11 @@ export type RejectionLogRecord = {
|
||||
fromTrigger?: boolean;
|
||||
text?: string;
|
||||
reasons?: string[];
|
||||
producerName?: string;
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
decisionLogicName?: string;
|
||||
decisionLogicVersion?: number;
|
||||
};
|
||||
|
||||
export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
|
||||
@@ -102,6 +107,11 @@ export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp"
|
||||
source?: string;
|
||||
origin: Origin;
|
||||
fromTrigger: boolean;
|
||||
producerName?: string;
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
decisionLogicName?: string;
|
||||
decisionLogicVersion?: number;
|
||||
};
|
||||
|
||||
export type MigrationLogRecord = {
|
||||
|
||||
+8
-1
@@ -4,6 +4,7 @@ import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } fr
|
||||
import { dirname, join } from "node:path";
|
||||
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import { producerFields } from "./instrumentation.ts";
|
||||
|
||||
export type EvidenceEventType =
|
||||
| "extraction_candidate_accepted"
|
||||
@@ -95,6 +96,9 @@ export type EvidenceEventV1 = {
|
||||
workspaceRootHash: string;
|
||||
sessionHash?: string;
|
||||
messageHash?: string;
|
||||
producerName?: string;
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
type: EvidenceEventType;
|
||||
phase: EvidencePhase;
|
||||
outcome: EvidenceOutcome;
|
||||
@@ -273,7 +277,10 @@ function buildEvidenceEvent(
|
||||
if (details) event.details = details;
|
||||
if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax);
|
||||
|
||||
return event;
|
||||
return {
|
||||
...event,
|
||||
...producerFields(),
|
||||
};
|
||||
}
|
||||
|
||||
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import { extractionRejectionLogPath } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import type { EvidenceEventInput } from "./evidence-log.ts";
|
||||
import { producerFields } from "./instrumentation.ts";
|
||||
|
||||
function id(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -329,6 +330,11 @@ type ExtractionRejectionLogEntry = {
|
||||
source: "compaction";
|
||||
workspaceKey?: string;
|
||||
workspaceRootHash?: string;
|
||||
producerName?: string;
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
decisionLogicName?: string;
|
||||
decisionLogicVersion?: number;
|
||||
};
|
||||
|
||||
type WorkspaceMemoryCandidateParseOptions = {
|
||||
@@ -381,6 +387,9 @@ function evaluateWorkspaceMemoryCandidate(
|
||||
source: "compaction",
|
||||
workspaceKey: options.workspaceKey,
|
||||
workspaceRootHash: options.workspaceRootHash,
|
||||
...producerFields(),
|
||||
decisionLogicName: "assessMemoryQuality",
|
||||
decisionLogicVersion: 1,
|
||||
});
|
||||
return { accepted: false, reasons: quality.reasons };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
let cachedVersion: string | undefined;
|
||||
|
||||
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
|
||||
const MEMORY_INSTRUMENTATION_VERSION = 2;
|
||||
|
||||
function producerVersion(): string {
|
||||
if (cachedVersion) return cachedVersion;
|
||||
try {
|
||||
const candidates = [
|
||||
join(__dirname, "..", "package.json"),
|
||||
join(__dirname, "..", "..", "package.json"),
|
||||
// resolve from compiled dist/src/ -> repo root
|
||||
];
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(path, "utf8"));
|
||||
cachedVersion = pkg.version as string;
|
||||
break;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
if (!cachedVersion) cachedVersion = "unknown";
|
||||
} catch {
|
||||
cachedVersion = "unknown";
|
||||
}
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
export function producerFields(): { producerName: string; producerVersion: string; instrumentationVersion: number } {
|
||||
return {
|
||||
producerName: MEMORY_PRODUCER_NAME,
|
||||
producerVersion: producerVersion(),
|
||||
instrumentationVersion: MEMORY_INSTRUMENTATION_VERSION,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -44,7 +44,7 @@ import {
|
||||
workspaceMemoryExactKey,
|
||||
workspaceMemoryIdentityKey,
|
||||
} from "./workspace-memory.ts";
|
||||
import { reinforceMemory } from "./retention.ts";
|
||||
import { tryReinforceMemory } from "./retention.ts";
|
||||
import {
|
||||
appendPendingMemories,
|
||||
clearPendingMemories,
|
||||
@@ -429,17 +429,31 @@ 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,
|
||||
attemptedAtMs: now,
|
||||
attemptedAtIso: new Date(now).toISOString(),
|
||||
...(decision.lastReinforcedAt ? {
|
||||
lastReinforcedAtMs: decision.lastReinforcedAt,
|
||||
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
|
||||
} : {}),
|
||||
reinforcementCount: decision.reinforcementCount,
|
||||
maxReinforcementCount: decision.maxReinforcementCount,
|
||||
minIntervalMs: decision.minIntervalMs,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const reinforced = decision.memory;
|
||||
workspaceMemory.entries[targetIndex] = reinforced;
|
||||
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
reinforcementOutcome: "reinforced",
|
||||
previousReinforcementCount: decision.previousReinforcementCount,
|
||||
newReinforcementCount: decision.newReinforcementCount,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
@@ -662,11 +676,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 });
|
||||
|
||||
+45
-13
@@ -1,5 +1,11 @@
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
|
||||
export type ReinforcementBlockReason = "same_session" | "same_utc_day" | "min_interval" | "max_count";
|
||||
|
||||
export type ReinforcementDecision =
|
||||
| { outcome: "reinforced"; memory: LongTermMemoryEntry; previousReinforcementCount: number; newReinforcementCount: number }
|
||||
| { outcome: "blocked"; memory: LongTermMemoryEntry; blockReason: ReinforcementBlockReason; lastReinforcedAt?: number; reinforcementCount: number; maxReinforcementCount: number; minIntervalMs: number };
|
||||
|
||||
// Retention decay model constants (v1.5)
|
||||
export const BASE_HALF_LIFE_DAYS = 45;
|
||||
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
||||
@@ -116,33 +122,59 @@ function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
|
||||
&& 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 = memory.lastReinforcedAt ?? 0;
|
||||
const lastSession = memory.lastReinforcedSessionID;
|
||||
|
||||
if (lastSession === sessionId) {
|
||||
return blockedDecision(memory, "same_session", count, lastAt);
|
||||
}
|
||||
|
||||
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
|
||||
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
|
||||
return memory;
|
||||
if (count >= REINFORCEMENT_MAX_COUNT) {
|
||||
return blockedDecision(memory, "max_count", count, lastAt);
|
||||
}
|
||||
|
||||
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
|
||||
return memory;
|
||||
if (lastAt > 0 && now < lastAt + REINFORCEMENT_MIN_INTERVAL_MS) {
|
||||
return blockedDecision(memory, "min_interval", count, lastAt);
|
||||
}
|
||||
|
||||
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
|
||||
return memory;
|
||||
if (lastAt > 0 && isSameUTCCalendarDay(lastAt, now)) {
|
||||
return blockedDecision(memory, "same_utc_day", count, lastAt);
|
||||
}
|
||||
|
||||
return {
|
||||
const reinforced: LongTermMemoryEntry = {
|
||||
...memory,
|
||||
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
|
||||
reinforcementCount: count + 1,
|
||||
lastReinforcedAt: now,
|
||||
lastReinforcedSessionID: sessionId,
|
||||
retentionClock: now,
|
||||
};
|
||||
return {
|
||||
outcome: "reinforced",
|
||||
memory: reinforced,
|
||||
previousReinforcementCount: count,
|
||||
newReinforcementCount: count + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function blockedDecision(
|
||||
memory: LongTermMemoryEntry,
|
||||
blockReason: ReinforcementBlockReason,
|
||||
reinforcementCount: number,
|
||||
lastReinforcedAt: number,
|
||||
): ReinforcementDecision {
|
||||
return {
|
||||
outcome: "blocked",
|
||||
memory,
|
||||
blockReason,
|
||||
...(lastReinforcedAt > 0 ? { lastReinforcedAt } : {}),
|
||||
reinforcementCount,
|
||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
minIntervalMs: REINFORCEMENT_MIN_INTERVAL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+80
-11
@@ -9,7 +9,8 @@ import { redactCredentials } from "./redaction.ts";
|
||||
import {
|
||||
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 +529,7 @@ function extractConcreteIdentityKey(text: string): string | null {
|
||||
if (pathIdentity) return pathIdentity;
|
||||
}
|
||||
|
||||
const pathMatch = text.match(/(?:\/[^ | ||||