mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc5c01818 | |||
| 01bda7c134 | |||
| 041115c173 | |||
| a480b734b2 | |||
| 5163ea3b8f | |||
| 9591f85dca | |||
| 93550b2e41 | |||
| 3c4282b241 | |||
| 5bca3432b0 | |||
| e4dfe81d89 | |||
| 9b6955f490 | |||
| e708e77e61 | |||
| 9114b57dc1 | |||
| 2ff17ea1b3 | |||
| 65b3b2f2c3 | |||
| 49bf866de2 |
@@ -208,8 +208,8 @@ const typedData = data as WorkspaceMemoryStore; // Explicit cast after validati
|
||||
// ============================================================================
|
||||
|
||||
// ✅ REQUIRED: Block comments for complex logic
|
||||
// Quality gate: Reject candidates that are git hashes, errors, or path-heavy
|
||||
function shouldAcceptWorkspaceMemoryCandidate(candidate: string): boolean {
|
||||
// Quality gate: return accepted/reasons so rejection evidence stays explainable
|
||||
function evaluateWorkspaceMemoryCandidate(candidate: WorkspaceMemoryCandidate): CandidateEvaluation {
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
+119
@@ -5,6 +5,125 @@ 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.7] - 2026-05-31
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed published OpenCode plugin loading under Node-based plugin loaders by compiling the runtime plugin entry to JavaScript and pointing package entry exports at `dist/`.
|
||||
- Added package smoke coverage for bare, `./server`, and `./tui` imports from an installed tarball so TypeScript entry points cannot regress under `node_modules`.
|
||||
|
||||
## [1.6.6] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Froze hot session state with the existing prompt-epoch model to reduce pre-history prompt churn for better prefix KV-cache reuse.
|
||||
- Switched frozen prompt cache pressure eviction to recency-aware tracking.
|
||||
- Updated README and architecture docs for the new frozen hot snapshot behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed KV prefix-cache instability caused by per-turn hot session prompt changes.
|
||||
|
||||
### Thanks
|
||||
|
||||
- Thanks to @nilo85 for opening PR #5 and surfacing the local-LLM KV cache hit-rate issue that led to this release.
|
||||
|
||||
## [1.6.5] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added `check:package-integrity` to verify `package.json` and on-disk `package-lock.json` root versions stay aligned even though the lockfile remains ignored by git.
|
||||
- Added `tsconfig.unused.json` as a strict unused-symbol audit gate for development and release checks.
|
||||
- Added package-integrity tests covering matching versions, mismatch reporting, and missing-lockfile guidance.
|
||||
- Added storage/evidence contract tests for full-state JSON overwrites and concurrent evidence JSONL appends.
|
||||
- Added workspace-memory render-order characterization and memory-visibility order coverage for the shared memory type order.
|
||||
|
||||
### Changed
|
||||
|
||||
- Centralized the current memory type ordering (`feedback`, `project`, `decision`, `reference`) in a narrow `memory-kind-policy` seam used by workspace rendering, TUI grouping, and memory visibility.
|
||||
- Extracted diagnostics producer-version grouping and inference helpers from `memory-diag quality` into a pure diagnostics-only module while preserving the existing JSON and human output contracts.
|
||||
- Documented storage write-path contracts in code: `updateJSON` is the locked read-modify-write path, `atomicWriteJSON` is the full-state overwrite primitive, and evidence logs remain append-only JSONL with bounded pruning.
|
||||
- Marked legacy parser fixtures and retention caps as intentional compatibility/policy-contract test coverage.
|
||||
- Updated developer docs to reference `evaluateWorkspaceMemoryCandidate` instead of the removed private acceptance wrapper.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- Marked `REINFORCEMENT_MIN_INTERVAL_MS` with JSDoc `@deprecated`; the rolling reinforcement policy uses `REINFORCEMENT_MIN_ELAPSED_MS`.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed unused imports and private unused helpers discovered by the new unused-symbol audit, including the private `shouldAcceptWorkspaceMemoryCandidate` wrapper.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed release hygiene drift detection for the ignored lockfile by adding an explicit package integrity check.
|
||||
- Reduced future diagnostics and memory-kind change risk by extracting small behavior-preserving seams without changing runtime memory behavior.
|
||||
|
||||
## [1.6.4] - 2026-05-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced same-session reinforcement blocking with a rolling 7-day elapsed reinforcement window so long-lived OpenCode sessions can reinforce durable memories after meaningful weekly recurrence.
|
||||
- Kept the 45-day base half-life while changing the max reinforcement count to a growth saturation point: memories at count 6 can refresh retention timestamps weekly without increasing count or effective half-life.
|
||||
- Bumped memory evidence instrumentation to version 3 for the new elapsed-window and refresh-only reinforcement semantics.
|
||||
- Updated `memory-diag commands --memory` to show elapsed-window details, `sameSession` evidence, `reinforcementMode`, and legacy missing timestamp markers without exposing raw session IDs.
|
||||
- Updated `memory-diag quality` to keep historical `same_session` block accounting while preventing new `sameSession` evidence from triggering old same-session diagnostic questions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented long-lived sessions from indefinitely blocking reinforcement solely because the session ID stayed the same across days.
|
||||
- Prevented saturated memories from growing stronger beyond the max reinforcement count while still allowing continued weekly use to keep them fresh.
|
||||
- Preserved historical block reason compatibility for `same_session`, `same_utc_day`, `min_interval`, and `max_count` without producing those reasons from the new policy path.
|
||||
|
||||
## [1.6.3] - 2026-05-14
|
||||
|
||||
### Added
|
||||
|
||||
- Added `memory-diag quality`, a read-only review board for memory-system mechanism evidence, answerability levels, provenance classification, active-memory review surfaces, and JSON review output.
|
||||
- Added producer/version-aware diagnostic facts so current instrumentation can be separated from historical or unversioned evidence when reviewing reinforcement, rejection, and eviction patterns.
|
||||
- Added `memory-diag commands --memory <memory-id>` for focused reinforcement command detail, including current memory status, recorded block reasons, missing block detail counts, UTC-day evidence, and privacy-safe JSON.
|
||||
|
||||
### Changed
|
||||
|
||||
- Made `activeMemoryDisplay` the canonical active-memory review surface in `memory-diag quality` JSON and removed duplicate active-memory `reviewCandidates` entries.
|
||||
- Clarified diagnostic provenance and answerability wording so `memory-diag quality` separates facts, heuristic flags, review questions, and human judgment requirements.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed duplicate active-memory candidate construction from `memory-diag quality` to prevent drift between human output and JSON surfaces.
|
||||
- Kept reinforcement detail diagnostics evidence-only so blocked reinforcement attempts are shown as recorded evidence without claiming policy failure or memory loss.
|
||||
|
||||
## [1.6.2] - 2026-05-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the published `memory-diag` npm bin by compiling the diagnostics CLI before packing and launching the compiled JavaScript runtime instead of type-stripping TypeScript under `node_modules`.
|
||||
|
||||
### Added
|
||||
|
||||
- Added a pack/npx smoke test that runs `memory-diag --help` from a packed tarball outside the repository.
|
||||
|
||||
## [1.6.1] - 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
- Native OpenCode TUI `/memory` submenu for local memory statistics, searchable current workspace memory refs, and help.
|
||||
- Package `./tui` export for OpenCode TUI plugin loading.
|
||||
|
||||
### Changed
|
||||
|
||||
- README documents separate server and TUI plugin configuration.
|
||||
- Recent activity/last TUI commands were removed before release because duplicate-looking slash menu entries were not useful.
|
||||
- Pre-release hyphenated TUI commands were consolidated into `/memory` because native submenu/list dialogs provide better bounded navigation with less slash-menu clutter.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Replaced a literal NUL byte in `workspace-memory.ts` regex source with a `\0` escape so source search tools treat the file as text.
|
||||
|
||||
### Notes / Known UX
|
||||
|
||||
- TUI memory command output opens in transcript-free native TUI dialogs and does not call the LLM.
|
||||
|
||||
## [1.6.0] - 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,22 +132,22 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls.
|
||||
┌──────────────────────────────────────┐
|
||||
│ ⚡ Prompt Context │
|
||||
│ system[1]*: frozen workspace memory │
|
||||
│ system[2+]*: hot session state │
|
||||
│ system[2+]*: frozen hot snapshot │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
\* Conceptually, workspace memory is pushed first when it is non-empty, and hot session state is pushed after workspace memory. If workspace memory is empty, hot state may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
|
||||
\* Conceptually, frozen workspace memory is pushed first when it is non-empty, and the frozen hot snapshot is pushed after workspace memory. If workspace memory is empty, the hot snapshot may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
|
||||
|
||||
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
|
||||
|
||||
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
|
||||
**Cache-friendly layout:** durable workspace memory and hot session state are rendered as separate frozen prompts that share the same epoch lifecycle. Hot state is an epoch-start snapshot: active files and open errors can change after it is created, and the conversation/tool transcript is the source of truth for newer events. The plugin intentionally does not invalidate the hot snapshot on active-file, open-error, recent-decision, or pending-memory changes because doing so would defeat prefix KV-cache reuse. Explicit pending memories remain durable and promote safely at compaction, but after the current epoch caches exist they do not force a prompt refresh.
|
||||
|
||||
The runtime context has three layers:
|
||||
|
||||
| Layer | Purpose | Lifetime |
|
||||
|---|---|---|
|
||||
| Workspace Memory | Durable decisions, preferences, project facts, references | Cross-session |
|
||||
| Hot Session State | Active files, open errors, recent context | Current session |
|
||||
| Hot Session State | Active files, open errors, recent context, pending memories | Current session storage; frozen prompt refreshes at epoch boundaries |
|
||||
| Native OpenCode State | Todos and built-in state | OpenCode-managed |
|
||||
|
||||
## Workspace Memory
|
||||
@@ -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
|
||||
|
||||
@@ -263,40 +261,23 @@ Default behavior:
|
||||
|
||||
- Workspace memory budget: 3600 characters (~900 tokens)
|
||||
- Workspace memory limit: 28 entries
|
||||
- Hot session state budget: 700 characters (~175 tokens)
|
||||
- Hot session state budget: 700 characters (~175 tokens) per frozen hot snapshot
|
||||
- Active files shown: 8
|
||||
- Open errors shown: 3
|
||||
|
||||
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,228 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.6.6 (2026-05-20)
|
||||
|
||||
### KV Cache Stability
|
||||
|
||||
This patch release reduces pre-history prompt churn by freezing hot session state with the existing prompt-epoch model, improving prefix KV-cache reuse for local LLMs.
|
||||
|
||||
Thanks to @nilo85 for opening PR #5 and surfacing the cache hit-rate issue.
|
||||
|
||||
### What Changed
|
||||
|
||||
- Hot session state now uses a frozen epoch snapshot instead of changing on every normal turn.
|
||||
- Frozen prompt caches use recency-aware cache pressure eviction.
|
||||
- The hot-state prompt now labels itself as an epoch snapshot so conversation/tool history remains the source of truth for newer events.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes are required.
|
||||
- Existing workspace memory files, session state files, and evidence logs remain compatible.
|
||||
|
||||
### Validation
|
||||
|
||||
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/session-state.test.ts` — 14 tests passing
|
||||
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/plugin.test.ts` — 67 tests passing
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 509 tests passing, `TEST_PASS`
|
||||
|
||||
---
|
||||
|
||||
## 1.6.5 (2026-05-19)
|
||||
|
||||
### Code Health and Release Hygiene
|
||||
|
||||
This patch release is an internal health release before the next feature wave. It does not change memory extraction, reinforcement policy, TUI behavior, or the `memory-diag` CLI contract. Instead, it makes the codebase easier to audit and safer to modify.
|
||||
|
||||
The release adds package-version integrity checks, a clean unused-symbol audit, focused characterization tests, storage/evidence contract coverage, a narrow shared memory-type ordering seam, and a small diagnostics versioning extraction.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Package integrity check**: added `npm run check:package-integrity` to verify `package.json` and the on-disk `package-lock.json` root versions match, with a clear `run npm install first` message when the ignored lockfile is missing.
|
||||
- **Unused-symbol audit**: added `tsconfig.unused.json` and cleaned the existing unused imports/private helpers so the audit now passes cleanly.
|
||||
- **Memory type order seam**: centralized the current order (`feedback`, `project`, `decision`, `reference`) for workspace rendering, memory visibility, and TUI grouping without creating a broader policy registry.
|
||||
- **Storage/evidence contracts**: documented write-path semantics and added tests for full-state JSON overwrite behavior and concurrent evidence JSONL appends.
|
||||
- **Diagnostics containment**: extracted producer-version grouping and inference helpers from `memory-diag quality` into a pure diagnostics-only module while preserving existing output shape and wording.
|
||||
- **Characterization coverage**: added render-order coverage and labeled compatibility/policy-contract tests so future refactors can distinguish intentional legacy behavior from brittle fixtures.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes are required.
|
||||
- Existing workspace memory files and evidence logs remain compatible.
|
||||
- The `memory-diag` CLI JSON shape and human output wording are intended to be unchanged.
|
||||
- `package-lock.json` remains ignored by git in this repository; run `npm install` before `npm run check:package-integrity` if the lockfile is missing locally.
|
||||
- `REINFORCEMENT_MIN_INTERVAL_MS` remains exported for compatibility but is now marked `@deprecated`; use `REINFORCEMENT_MIN_ELAPSED_MS` for the rolling reinforcement policy.
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run check:package-integrity` — `PACKAGE_INTEGRITY_PASS version=1.6.5`
|
||||
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/package-integrity.test.ts` — 3 tests passing
|
||||
- `./node_modules/.bin/tsc -p tsconfig.unused.json` — no unused-symbol errors
|
||||
- `node --test --experimental-strip-types tests/memory-diag-quality.test.ts tests/memory-diag.test.ts` — 93 tests passing
|
||||
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/storage.test.ts tests/evidence-log.test.ts` — 22 tests passing
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 504 tests passing, `TEST_PASS`
|
||||
- `npm run build` — `BUILD_PASS`
|
||||
|
||||
---
|
||||
|
||||
## 1.6.4 (2026-05-15)
|
||||
|
||||
### Rolling Weekly Reinforcement
|
||||
|
||||
This patch release fixes the reinforcement policy for users who work in long-lived OpenCode sessions. Reinforcement no longer treats `same_session` as a hard block. Instead, each memory uses a rolling 7-day elapsed window, so recurring preferences can be reinforced after meaningful weekly use even when the session stays open.
|
||||
|
||||
The base retention half-life remains 45 days. The max reinforcement count remains 6, but it now acts as a growth saturation point rather than a lifetime hard stop.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **7-day rolling window**: repeated reinforcement is allowed once 7 rolling days have elapsed since the memory's last reinforcement; 7 days minus 1ms still blocks.
|
||||
- **Same-session as evidence**: `sameSession` is recorded for diagnostics but no longer blocks reinforcement by itself.
|
||||
- **Refresh-only saturation**: memories at reinforcement count 6 can refresh `retentionClock`, `lastReinforcedAt`, and session evidence after the weekly window without increasing count or effective half-life.
|
||||
- **Instrumentation v3**: new reinforcement evidence records elapsed-window fields, `sameSession`, `reinforcementMode`, and legacy missing timestamp markers.
|
||||
- **Diagnostics updated**: `memory-diag commands --memory` exposes the new fields, while `memory-diag quality` keeps historical `same_session` block analysis separate from new same-session evidence.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes are required.
|
||||
- Existing workspace memory files and evidence logs remain compatible.
|
||||
- Historical diagnostics may still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, or `max_count`; new instrumentation-version-3 events use the rolling elapsed-window semantics.
|
||||
- Consumers of `memory-diag commands --memory --json` should use `reinforcementMode` to distinguish count-increment reinforcement from refresh-only saturation.
|
||||
|
||||
### Validation
|
||||
|
||||
- `node --test --experimental-strip-types tests/retention.test.ts` — 10 tests passing
|
||||
- `node --test --experimental-strip-types tests/workspace-memory.test.ts tests/plugin.test.ts` — 181 tests passing
|
||||
- `node --test --experimental-strip-types tests/memory-diag.test.ts tests/memory-diag-quality.test.ts` — 93 tests passing
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 498 tests passing, `TEST_PASS`
|
||||
- `npm run build` — `BUILD_PASS`
|
||||
|
||||
---
|
||||
|
||||
## 1.6.3 (2026-05-14)
|
||||
|
||||
### Diagnostic Quality Review Board
|
||||
|
||||
This patch release focuses on safer memory diagnostics. It adds a read-only quality review board and finer reinforcement drill-downs so reviewers can inspect memory-system evidence without treating historical artifacts as current failures or turning heuristic flags into automatic cleanup decisions.
|
||||
|
||||
The goal is better observability before policy changes: diagnostics show facts, provenance, answerability, and review questions, while leaving judgment to the operator.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Quality review board**: `memory-diag quality` now reports system-mechanism facts for rejection filters, reinforcement rules, eviction/caps, identity/dedup, and active memory content review.
|
||||
- **Version/provenance context**: diagnostics distinguish current producer-instrumented events from historical or unversioned evidence where possible, and label ambiguous evidence conservatively.
|
||||
- **Focused reinforcement drill-down**: `memory-diag commands --memory <memory-id>` shows one memory's reinforcement command evidence, current status, recorded block reasons, missing details, and UTC-day evidence.
|
||||
- **Canonical active-memory JSON surface**: `memory-diag quality --json` now uses `activeMemoryDisplay` as the single active-memory review surface instead of duplicating active memories under `reviewCandidates`.
|
||||
- **Attribution-safe wording**: quality and reinforcement diagnostics avoid claiming that a block is a bug, policy failure, or cause of memory loss; they present recorded evidence and review prompts instead.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes are required.
|
||||
- Existing workspace memory files and evidence logs remain compatible.
|
||||
- If you consume `memory-diag quality --json` from unreleased builds after v1.6.2, read active-memory review data from `activeMemoryDisplay`; `reviewCandidates` is now reserved for system-mechanism candidates.
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory@1.6.3 memory-diag quality
|
||||
npx --package opencode-working-memory@1.6.3 memory-diag quality --json
|
||||
npx --package opencode-working-memory@1.6.3 memory-diag commands --memory <memory-id>
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `node --test --experimental-strip-types tests/memory-diag-quality.test.ts` — 53 tests passing
|
||||
- `node --test --experimental-strip-types tests/memory-diag.test.ts` — 39 tests passing
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 486 tests passing, `TEST_PASS`
|
||||
- `npm run build` — `BUILD_PASS`
|
||||
- `npm run test:pack:memory-diag` — packed tarball smoke test passed
|
||||
|
||||
---
|
||||
|
||||
## 1.6.2 (2026-05-11)
|
||||
|
||||
### Published `memory-diag` Bin Fix
|
||||
|
||||
This patch release fixes the published npm package path for `memory-diag`. In v1.6.1 the source-tree CLI tests passed, but the installed package could fail under `npx --package opencode-working-memory memory-diag` because Node refuses TypeScript type stripping for files inside `node_modules`.
|
||||
|
||||
v1.6.2 compiles the diagnostics CLI during packing and makes the npm bin launch the compiled JavaScript runtime.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Compiled diagnostics runtime**: `prepack` now builds `dist/scripts/memory-diag.js` before the package is packed or published.
|
||||
- **Safer npm bin wrapper**: `memory-diag` no longer runs published `.ts` files through `--experimental-strip-types`; it launches the compiled JS artifact and reports a clear reinstall/build message if the artifact is missing.
|
||||
- **Packaged-bin smoke test**: release verification now includes a pack/npx smoke test from a temp consumer project outside the repository.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No config changes are required.
|
||||
- Existing OpenCode server and TUI plugin entry points are unchanged.
|
||||
- If you hit the v1.6.1 bin failure, upgrade and rerun:
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory@1.6.2 memory-diag --help
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run build` — `BUILD_PASS`
|
||||
- `node ./scripts/memory-diag-bin.cjs --help`
|
||||
- `npm run test:pack:memory-diag` — packed tarball smoke test passed
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 421 tests passing, `TEST_PASS`
|
||||
- `npm pack --dry-run` — includes compiled `dist/` diagnostics artifacts
|
||||
|
||||
---
|
||||
|
||||
## 1.6.1 (2026-05-08)
|
||||
|
||||
### Native TUI Memory Menu
|
||||
|
||||
This release adds a native OpenCode TUI memory menu so users can inspect local working memory without asking the model and without adding command output to the conversation transcript.
|
||||
|
||||
Open `/memory` in the TUI to browse memory status, current workspace memories, and help from native dialogs.
|
||||
|
||||
> Memory should stay visible when you need it — and stay out of the transcript when you are only inspecting it.
|
||||
|
||||
```text
|
||||
/memory
|
||||
│
|
||||
├─ Status
|
||||
│ local counts and memory health
|
||||
│
|
||||
├─ Current memories
|
||||
│ searchable grouped [M#] refs
|
||||
│
|
||||
└─ Help
|
||||
local usage notes
|
||||
```
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Single TUI entry point**: `/memory` opens a native submenu instead of exposing multiple memory slash commands.
|
||||
- **Searchable current memory list**: `Current memories` uses OpenCode's native select dialog for bounded scrolling, filtering, and grouping.
|
||||
- **Transcript-free inspection**: memory status, list, help, empty states, and errors render in native dialogs instead of user-style session messages.
|
||||
- **Server and TUI plugin exports**: the package exposes `./server` and `./tui` entry points for OpenCode plugin loading.
|
||||
- **User docs refreshed**: README highlights the `/memory` workflow and moves the full diagnostics CLI reference to `docs/diagnostics.md`.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- Add `.opencode/tui.json` if you want the native `/memory` TUI menu. Existing server-only configuration continues to work.
|
||||
- Restart OpenCode after adding the TUI plugin config.
|
||||
- The TUI menu is read-only and local-only. It does not call the LLM.
|
||||
- Individual memory row selection is intentionally a no-op in this release; use the list for inspection and search.
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
||||
- `npm test` — 421 tests passing, `TEST_PASS`
|
||||
- `npm pack --dry-run`
|
||||
- Real OpenCode TUI smoke test for `/memory` menu, searchable current memories, and transcript-free output.
|
||||
|
||||
---
|
||||
|
||||
## 1.6.0 (2026-05-08)
|
||||
|
||||
### Numbered Memory Refs
|
||||
|
||||
+42
-8
@@ -18,7 +18,8 @@ OpenCode Working Memory implements a **three-layer memory architecture** designe
|
||||
│ LAYER 2: HOT SESSION STATE (Short-term, per-session) │
|
||||
│ • Session-scoped tracking: active files, open errors │
|
||||
│ • Storage: sessions/{sessionID}.json │
|
||||
│ • Auto-extracted from tool usage patterns │
|
||||
│ • Frozen prompt snapshot shares the workspace epoch │
|
||||
│ • Auto-extracted from tool usage and explicit remembers │
|
||||
│ • Cleared: on new session start │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
@@ -55,6 +56,30 @@ Long-term memory that persists across sessions within the same workspace. Perfec
|
||||
}
|
||||
```
|
||||
|
||||
### Evidence Log Schema
|
||||
|
||||
Workspace diagnostics also read the append-only evidence log for the current workspace. New evidence records are additive and keep historical records valid:
|
||||
|
||||
```typescript
|
||||
{
|
||||
version: 1,
|
||||
eventId: string,
|
||||
createdAt: string,
|
||||
workspaceKey: string,
|
||||
workspaceRootHash: string,
|
||||
producerName?: string,
|
||||
producerVersion?: string,
|
||||
instrumentationVersion?: number,
|
||||
type: EvidenceEventType,
|
||||
phase: EvidencePhase,
|
||||
outcome: EvidenceOutcome,
|
||||
reasonCodes: string[],
|
||||
details?: Record<string, string | number | boolean | null | string[] | number[]>
|
||||
}
|
||||
```
|
||||
|
||||
Instrumentation version 2 added optional causal block details for diagnostics without backfilling old JSONL records. Instrumentation version 3 adds elapsed-window reinforcement details such as `details.elapsedMs`, `details.requiredElapsedMs`, `details.sameSession`, `details.reinforcementMode`, and `details.legacyMissingTimestamp`. Historical reinforcement-block events may still include older `details.blockReason` values such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or may have missing block details. Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior.
|
||||
|
||||
### Entry Types
|
||||
|
||||
| Type | Purpose | Example |
|
||||
@@ -130,7 +155,7 @@ Default type caps:
|
||||
|
||||
The type-cap total is 34, intentionally above the global 28-entry cap. These are maximums, not quotas.
|
||||
|
||||
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement.
|
||||
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry only after a rolling 7-day elapsed window. Below reinforcement count 6, an allowed recurrence increments the reinforcement count and refreshes retention timestamps; at count 6 or higher, an allowed recurrence refreshes retention timestamps without increasing the count. Same-session status is recorded as diagnostic evidence, not as a new-policy block reason.
|
||||
|
||||
### Safety-Critical Deprecation
|
||||
|
||||
@@ -158,6 +183,9 @@ Track current session context automatically:
|
||||
- What files are you working on?
|
||||
- What errors are currently open?
|
||||
- What decisions were made recently?
|
||||
- Which explicit memories are pending promotion?
|
||||
|
||||
Hot session state is stored continuously during a session, but it is not rendered as a per-turn dynamic prompt. The prompt layer uses a frozen hot snapshot created or refreshed at the same epoch boundary as frozen workspace memory. Active files and open errors are current at epoch boundaries, not on every normal turn. After epoch start, the conversation/tool transcript is the source of truth for newer events.
|
||||
|
||||
### Storage
|
||||
|
||||
@@ -171,7 +199,8 @@ Track current session context automatically:
|
||||
updatedAt: string,
|
||||
activeFiles: ActiveFile[],
|
||||
openErrors: OpenError[],
|
||||
recentDecisions: SessionDecision[]
|
||||
recentDecisions: SessionDecision[],
|
||||
pendingMemories: LongTermMemoryEntry[]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -218,12 +247,17 @@ Short-term decisions made this session. Candidates for promotion to workspace me
|
||||
|
||||
### System Prompt Injection
|
||||
|
||||
Hot session state is injected after workspace memory:
|
||||
Workspace memory and hot session state are separate cached prompt layers that share a prompt epoch lifecycle:
|
||||
|
||||
```text
|
||||
system[1]*: frozen workspace memory
|
||||
system[2+]*: frozen hot snapshot
|
||||
```
|
||||
|
||||
The hot state example below is included in a frozen hot snapshot when the epoch is created or refreshed, not rendered again on every normal turn. Active files and open errors are current at epoch boundaries, not on every normal turn; the plugin intentionally does not invalidate the hot snapshot on active-file or open-error changes because doing so would defeat prefix KV-cache reuse. Explicit pending memories persist in session state and the pending journal, then promote safely at compaction; once the current epoch caches exist, new pending memories do not force pre-history prompt refresh. After epoch start, the conversation/tool transcript is the source of truth for newer events.
|
||||
|
||||
```
|
||||
---
|
||||
|
||||
Hot session state (current session):
|
||||
Hot session state snapshot (epoch start; conversation history may be newer):
|
||||
|
||||
active_files:
|
||||
- src/plugin.ts (edit, 18x)
|
||||
@@ -256,7 +290,7 @@ OpenCode Working Memory hooks into OpenCode lifecycle events:
|
||||
|
||||
### `experimental.chat.system.transform`
|
||||
|
||||
Injects workspace memory and hot session state into system prompt.
|
||||
Injects cached frozen workspace memory and cached frozen hot snapshot prompts into the system prompt. Normal tool/user churn updates storage but does not mutate these pre-history prompts until a new epoch starts.
|
||||
|
||||
### `tool.execute.after`
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Memory Diagnostics
|
||||
|
||||
Use the read-only diagnostics CLI when you want to understand what OpenCode Working Memory is doing for the current workspace.
|
||||
|
||||
The npm package is `opencode-working-memory`; the installed bin is `memory-diag`, so package-qualified `npx` avoids resolving a different package named `memory-diag`.
|
||||
|
||||
## Commands
|
||||
|
||||
| Question | Command |
|
||||
|---|---|
|
||||
| Is memory healthy? | `npx --package opencode-working-memory memory-diag` or `npx --package opencode-working-memory memory-diag status` |
|
||||
| Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` |
|
||||
| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` |
|
||||
| Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain <memory-id>` |
|
||||
| How are numbered memory commands behaving? | `npx --package opencode-working-memory memory-diag commands` |
|
||||
| What reinforcement evidence exists for one memory? | `npx --package opencode-working-memory memory-diag commands --memory <memory-id>` |
|
||||
| How do I review memory quality without automatic cleanup? | `npx --package opencode-working-memory memory-diag quality` |
|
||||
| Revert a numbered replacement? | `npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>` |
|
||||
|
||||
## Global Options
|
||||
|
||||
- `--workspace <path>` — inspect another workspace; defaults to the current directory.
|
||||
- `--verbose` — show detailed diagnostics.
|
||||
- `--json` — print machine-readable output where supported.
|
||||
|
||||
## Diagnostic Answerability Contract
|
||||
|
||||
Every diagnostic section must document:
|
||||
|
||||
1. **Question:** What does the reviewer want to know?
|
||||
2. **Decision:** What action could the answer inform?
|
||||
3. **Competing explanations:** At least two interpretations of the same metric.
|
||||
4. **Required signals:** What fields/events distinguish those explanations?
|
||||
5. **Current signals:** What currently exists?
|
||||
6. **Answerability level:** `supported` | `partial` | `inventory_only` | `not_instrumented`
|
||||
7. **Output permission:** What the tool may say without overclaiming.
|
||||
|
||||
For `memory-diag quality`:
|
||||
- `reinforcementRules`: `inventory_only` (cannot distinguish spam from legitimate blocks)
|
||||
- `evictionAndCaps`: `inventory_only` (cannot distinguish healthy turnover from premature eviction)
|
||||
- Old evidence remains ambiguous. Answerability improves for producer-instrumented events, including instrumentation version 2 block details and instrumentation version 3 elapsed-window details. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections.
|
||||
- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and, when available, rolling elapsed-window fields; they still require human content judgment.
|
||||
- Producer-instrumented capacity removals with rank/strength snapshots can upgrade `evictionAndCaps` to `partial`; fullness alone remains occupancy inventory, not proof of a capacity problem.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory memory-diag status
|
||||
npx --package opencode-working-memory memory-diag rejected --verbose
|
||||
npx --package opencode-working-memory memory-diag missing --workspace /path/to/project
|
||||
npx --package opencode-working-memory memory-diag status --json
|
||||
npx --package opencode-working-memory memory-diag commands --verbose
|
||||
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
|
||||
npx --package opencode-working-memory memory-diag quality
|
||||
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>
|
||||
```
|
||||
|
||||
## Quality Review Board
|
||||
|
||||
Use `memory-diag quality` for a read-only, answerability-scoped evidence inventory without automatic cleanup.
|
||||
|
||||
- Primarily provides memory-system mechanism observations for human/agent interpretation.
|
||||
- Secondarily helps review active memory content quality.
|
||||
- Prints answerability labels and output permissions so inventory facts are not presented as conclusions.
|
||||
- Separates system-mechanism facts, memory-content facts, heuristic flags, and review questions.
|
||||
- Includes inferred evidence provenance because historical records do not record producer package version.
|
||||
- Labels uncertain provenance as `unversioned_ambiguous` so old artifacts are not treated as current mechanism failures.
|
||||
- Does not decide what to delete or mutate.
|
||||
- Use `--json` for agent/objective review.
|
||||
|
||||
## Numbered Memory Command Reports
|
||||
|
||||
Use `memory-diag commands` to inspect `REINFORCE [M#]` and `REPLACE [M#]` outcomes from compaction.
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory memory-diag commands
|
||||
npx --package opencode-working-memory memory-diag commands --verbose
|
||||
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
|
||||
```
|
||||
|
||||
The report includes successful reinforcements, refresh-only reinforcements, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode.
|
||||
|
||||
Use `commands --memory <memory-id>` when you need a focused, evidence-only reinforcement view for one memory. It reports current memory status separately from recorded reinforcement attempts, block reasons, missing block details, elapsed-window fields (`elapsedMs`, `requiredElapsedMs`), `sameSession` evidence, `reinforcementMode` (`increment` or `refresh_only`), `legacyMissingTimestamp` when true, and historical UTC-day evidence without judging whether the policy is correct.
|
||||
|
||||
Current reinforcement policy uses a rolling 7-day elapsed window. Below reinforcement count 6, allowed attempts increment the count and refresh retention timestamps; at count 6 or higher, allowed attempts refresh retention timestamps without increasing the count. Historical evidence can still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or missing block details because evidence logs are append-only and are not backfilled.
|
||||
|
||||
## Dry-run Recovery
|
||||
|
||||
`memory-diag revert` is dry-run by default. Add `--apply` only after reviewing the planned original/replacement status changes.
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>
|
||||
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id> --apply
|
||||
```
|
||||
|
||||
You can also target a replacement evidence event directly:
|
||||
|
||||
```bash
|
||||
npx --package opencode-working-memory memory-diag revert --event <event-id>
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
# Plan: Package JS Entry for OpenCode Node Loader
|
||||
|
||||
## Goal
|
||||
|
||||
Fix GitHub issue #6 by making the published `opencode-working-memory` package loadable through OpenCode's Node-based plugin loader. The package must not expose TypeScript source files as runtime entry points under `node_modules`.
|
||||
|
||||
## Background
|
||||
|
||||
`opencode-working-memory@1.6.6` currently publishes:
|
||||
|
||||
- `main: "index.ts"`
|
||||
- `exports["."]: "./index.ts"`
|
||||
- `exports["./server"]: "./index.ts"`
|
||||
- `exports["./tui"]: "./src/tui-plugin.ts"`
|
||||
|
||||
Node can import the local repo `index.ts`, but refuses to strip TypeScript types for files under `node_modules`. This means local path testing passes while npm/opencode package-cache loading fails.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
1. Expand the existing dist build config into a full package build.
|
||||
- Update `tsconfig.memory-diag.json` to include `index.ts`, `src/**/*.ts`, `scripts/memory-diag.ts`, and `scripts/memory-diag/**/*.ts`.
|
||||
- Remove the current exclusions for runtime plugin dependencies: `src/plugin.ts`, `src/tui-plugin.ts`, `src/opencode.ts`, `src/session-state.ts`, `src/extractors.ts`, `src/pending-journal.ts`, `src/promotion-accounting.ts`, and `src/memory-visibility.ts`.
|
||||
- Keep `rootDir: "."` and `outDir: "dist"` so `index.ts` emits to `dist/index.js` and `src/tui-plugin.ts` emits to `dist/src/tui-plugin.js`.
|
||||
- Keep `rewriteRelativeImportExtensions: true` so emitted ESM imports point at `.js`.
|
||||
|
||||
2. Update `package.json` runtime entry points.
|
||||
- `main` should point to `dist/index.js`.
|
||||
- `exports["."]` and `exports["./server"]` should point to `./dist/index.js`.
|
||||
- `exports["./tui"]` should point to `./dist/src/tui-plugin.js`.
|
||||
|
||||
3. Update build scripts.
|
||||
- Keep one clean build path that emits both plugin runtime JS and `memory-diag` JS.
|
||||
- Preserve the existing `memory-diag` binary wrapper behavior.
|
||||
- Rename or alias the script so the unified dist build is not hidden behind a memory-diag-only name.
|
||||
|
||||
4. Update packaging tests.
|
||||
- Add a smoke test that builds/packs/installs the tarball into a temporary prefix and imports `opencode-working-memory` from `node_modules`.
|
||||
- Assert the default export id is `working-memory`.
|
||||
- Assert `opencode-working-memory/server` imports and exposes the same default plugin id.
|
||||
- Assert `opencode-working-memory/tui` imports and exposes the TUI plugin id.
|
||||
- Assert package manifest entry points do not point at `.ts`.
|
||||
- Use an isolated npm cache under `/private/tmp` or the test temp root so local `~/.npm` permissions do not affect the smoke test.
|
||||
|
||||
5. Update package file allowlist if needed.
|
||||
- Ensure `dist/`, `scripts/memory-diag-bin.cjs`, `README.md`, and `LICENSE` are included.
|
||||
- Keeping TypeScript source files in the tarball is acceptable only if runtime entry points resolve to JS.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `package.json`
|
||||
- `tsconfig.memory-diag.json` or a new build tsconfig
|
||||
- `tests/smoke/memory-diag-packaging.test.ts` or a new smoke test under `tests/smoke/`
|
||||
- `CHANGELOG.md`
|
||||
- Possibly `package-lock.json` if package metadata changes require npm to refresh it
|
||||
|
||||
## Verification
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
test -f dist/index.js
|
||||
test -f dist/src/tui-plugin.js
|
||||
node -e "import('./dist/index.js').then(m => console.log(m.default.id))"
|
||||
node -e "import('./dist/src/tui-plugin.js').then(m => console.log(m.default.id))"
|
||||
rg 'from\s+\".*\\.ts\"|import\s*\\(.*\\.ts' dist
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run check:package-integrity
|
||||
```
|
||||
|
||||
Package-path smoke:
|
||||
|
||||
```bash
|
||||
rm -rf /private/tmp/owm-pack /private/tmp/owm-install /private/tmp/npm-cache
|
||||
mkdir -p /private/tmp/owm-pack /private/tmp/owm-install /private/tmp/npm-cache
|
||||
npm pack --cache /private/tmp/npm-cache --pack-destination /private/tmp/owm-pack
|
||||
npm install --cache /private/tmp/npm-cache --prefix /private/tmp/owm-install /private/tmp/owm-pack/opencode-working-memory-*.tgz
|
||||
node -e "import('/private/tmp/owm-install/node_modules/opencode-working-memory').then(m => console.log(m.default.id))"
|
||||
node -e "import('/private/tmp/owm-install/node_modules/opencode-working-memory/server').then(m => console.log(m.default.id))"
|
||||
node -e "import('/private/tmp/owm-install/node_modules/opencode-working-memory/tui').then(m => console.log(m.default.id))"
|
||||
```
|
||||
|
||||
Expected output for both import checks:
|
||||
|
||||
```text
|
||||
working-memory
|
||||
```
|
||||
|
||||
For the TUI import, expected output is:
|
||||
|
||||
```text
|
||||
working-memory-tui
|
||||
```
|
||||
|
||||
Also inspect the installed package manifest and assert `main`, `exports["."]`, `exports["./server"]`, and `exports["./tui"]` all point to `.js` files.
|
||||
|
||||
## Release Preparation
|
||||
|
||||
After implementation and verification:
|
||||
|
||||
1. Inspect `git diff`.
|
||||
2. Confirm the working tree contains only this release fix plus the pre-existing unrelated untracked plan.
|
||||
3. Add a `CHANGELOG.md` entry for `1.6.7` describing the Node loader/package entry fix.
|
||||
4. Run `npm version patch` or otherwise bump `package.json` and `package-lock.json` to `1.6.7` consistently.
|
||||
5. If publishing from `main`, prepare the verified package for publish after the final git diff review.
|
||||
|
||||
## Risks
|
||||
|
||||
- Build config may expose TypeScript type-check errors in files that were previously excluded from `tsconfig.memory-diag.json`.
|
||||
- Package smoke tests that call `npm pack` can fail on the developer machine if `~/.npm` has permission problems; tests should use an isolated cache under `/private/tmp`.
|
||||
- OpenCode may resolve `./server` or `./tui` differently from bare package import, so both exports should be checked.
|
||||
- `npm run check:package-integrity` currently checks package-lock version alignment only; entry-point assertions must live in packaging tests or a new entry-point check.
|
||||
- Publishing both `src/` and `dist/` increases tarball size, but runtime correctness depends on the JS entry points rather than removing sources.
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the package entry/build changes and publish a corrected package if the JS entry causes a runtime regression. Existing local-path development remains unaffected by reverting because it can still import TypeScript from outside `node_modules`.
|
||||
@@ -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.
|
||||
+14
-5
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.7",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
".": "./dist/index.js",
|
||||
"./server": "./dist/index.js",
|
||||
"./tui": "./dist/src/tui-plugin.js"
|
||||
},
|
||||
"bin": {
|
||||
"memory-diag": "./scripts/memory-diag-bin.cjs"
|
||||
@@ -16,12 +18,19 @@
|
||||
"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:dist": "npm run clean:dist && tsc -p tsconfig.memory-diag.json",
|
||||
"build:memory-diag": "npm run build:dist",
|
||||
"build": "npm run build:dist && 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",
|
||||
"check:package-integrity": "node --experimental-strip-types scripts/dev/check-package-integrity.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"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
type PackageManifest = {
|
||||
version?: unknown;
|
||||
};
|
||||
|
||||
type PackageLock = {
|
||||
version?: unknown;
|
||||
packages?: Record<string, { version?: unknown } | undefined>;
|
||||
};
|
||||
|
||||
export type PackageVersionMismatch = {
|
||||
field: "package-lock.json version" | "package-lock.json packages[\"\"].version";
|
||||
expected: string;
|
||||
actual: unknown;
|
||||
};
|
||||
|
||||
export function packageVersionMismatches(
|
||||
packageJson: PackageManifest,
|
||||
packageLock: PackageLock,
|
||||
): PackageVersionMismatch[] {
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error("package.json version must be a non-empty string");
|
||||
}
|
||||
|
||||
const expected = packageJson.version;
|
||||
const rootLockVersion = packageLock.version;
|
||||
const rootPackageVersion = packageLock.packages?.[""]?.version;
|
||||
|
||||
const candidates = [
|
||||
{ field: "package-lock.json version" as const, actual: rootLockVersion },
|
||||
{ field: "package-lock.json packages[\"\"].version" as const, actual: rootPackageVersion },
|
||||
];
|
||||
|
||||
return candidates
|
||||
.filter(candidate => candidate.actual !== expected)
|
||||
.map(candidate => ({ ...candidate, expected }));
|
||||
}
|
||||
|
||||
export function formatPackageVersionMismatch(mismatch: PackageVersionMismatch): string {
|
||||
return `${mismatch.field} (${String(mismatch.actual)}) does not match package.json version (${mismatch.expected})`;
|
||||
}
|
||||
|
||||
export function packageLockReadErrorMessage(error: unknown): string {
|
||||
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
||||
if (code === "ENOENT") return "package-lock.json not found; run npm install first";
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Unable to read package-lock.json; run npm install first. ${message}`;
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(path: string): Promise<T> {
|
||||
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const packageJson = await readJsonFile<PackageManifest>(join(repoRoot, "package.json"));
|
||||
let packageLock: PackageLock;
|
||||
try {
|
||||
packageLock = await readJsonFile<PackageLock>(join(repoRoot, "package-lock.json"));
|
||||
} catch (error) {
|
||||
console.error(packageLockReadErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mismatches = packageVersionMismatches(packageJson, packageLock);
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
console.error("Package integrity check failed:");
|
||||
for (const mismatch of mismatches) {
|
||||
console.error(`- ${formatPackageVersionMismatch(mismatch)}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`PACKAGE_INTEGRITY_PASS version=${packageJson.version}`);
|
||||
}
|
||||
|
||||
function isMainModule(): boolean {
|
||||
const invokedPath = process.argv[1];
|
||||
return invokedPath ? import.meta.url === pathToFileURL(resolve(invokedPath)).href : false;
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
await main();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
const { execFileSync } = require("child_process");
|
||||
const { existsSync } = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function isSupportedNodeVersion(version) {
|
||||
@@ -9,13 +10,19 @@ function isSupportedNodeVersion(version) {
|
||||
}
|
||||
|
||||
if (!isSupportedNodeVersion(process.versions.node)) {
|
||||
process.stderr.write(`memory-diag requires Node >=22.6.0 because it runs TypeScript with --experimental-strip-types. Current Node: v${process.versions.node}.\n`);
|
||||
process.stderr.write(`memory-diag requires Node >=22.6.0 per opencode-working-memory package engines. Current Node: v${process.versions.node}.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binDir = __dirname;
|
||||
const tsScript = path.join(binDir, "memory-diag.ts");
|
||||
const args = ["--experimental-strip-types", tsScript, ...process.argv.slice(2)];
|
||||
const compiledScript = path.join(binDir, "..", "dist", "scripts", "memory-diag.js");
|
||||
|
||||
if (!existsSync(compiledScript)) {
|
||||
process.stderr.write("memory-diag package is missing dist/scripts/memory-diag.js. Reinstall opencode-working-memory or run npm run build before using the local package.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = [compiledScript, ...process.argv.slice(2)];
|
||||
try {
|
||||
execFileSync(process.execPath, args, { stdio: "inherit" });
|
||||
process.exit(0);
|
||||
|
||||
@@ -9,7 +9,8 @@ export function usage(): string {
|
||||
memory-diag rejected [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag missing [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag explain [memory-id] [--workspace <path>] [--raw]
|
||||
memory-diag commands [--workspace <path>] [--verbose] [--json]
|
||||
memory-diag commands [--workspace <path>] [--verbose] [--json] [--memory <id>]
|
||||
memory-diag quality [--workspace <path>] [--verbose] [--json] [--raw] [--no-emoji]
|
||||
memory-diag revert (--memory <replacement-id> | --event <event-id>) [--workspace <path>] [--apply]
|
||||
|
||||
Global options:
|
||||
@@ -103,12 +104,12 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
|
||||
if (command === "status") {
|
||||
if (options.all) return error(`${command} does not accept --all`);
|
||||
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "revert") {
|
||||
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "quality" || command === "revert") {
|
||||
if (options.all) return error(`${command} does not accept --all`);
|
||||
} else {
|
||||
if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`);
|
||||
}
|
||||
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands") {
|
||||
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage" && command !== "commands" && command !== "quality") {
|
||||
return error(`${command} does not accept --json`);
|
||||
}
|
||||
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
|
||||
@@ -120,7 +121,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
if (command !== "audit" && options.migration) {
|
||||
return error(`${command} does not accept --migration`);
|
||||
}
|
||||
if (command !== "explain" && command !== "revert" && options.memory) {
|
||||
if (command !== "explain" && command !== "revert" && command !== "commands" && options.memory) {
|
||||
return error(`${command} does not accept --memory`);
|
||||
}
|
||||
if (command !== "revert" && options.event) return error(`${command} does not accept --event`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "revert"] as const;
|
||||
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain", "commands", "quality", "revert"] as const;
|
||||
export const HIDDEN_COMMANDS = ["coverage", "audit"] as const;
|
||||
|
||||
export type VisibleCommand = typeof VISIBLE_COMMANDS[number];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { runCommands } from "./commands/commands.ts";
|
||||
import { runCoverage } from "./commands/coverage.ts";
|
||||
import { runExplain } from "./commands/explain.ts";
|
||||
import { runMissing } from "./commands/missing.ts";
|
||||
import { runQuality } from "./commands/quality.ts";
|
||||
import { runRejected } from "./commands/rejected.ts";
|
||||
import { runRevert } from "./commands/revert.ts";
|
||||
import { runStatus } from "./commands/status.ts";
|
||||
@@ -17,6 +18,7 @@ export async function dispatch(command: Command, options: CliOptions): Promise<C
|
||||
case "audit": return runAudit(options);
|
||||
case "explain": return runExplain(options);
|
||||
case "commands": return runCommands(options);
|
||||
case "quality": return runQuality(options);
|
||||
case "revert": return runRevert(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { queryEvidenceEvents, type EvidenceEventV1, type EvidenceOutcome } from "../../../src/evidence-log.ts";
|
||||
import { workspaceKey, workspaceMemoryPath } from "../../../src/paths.ts";
|
||||
import type { WorkspaceMemoryStore } from "../../../src/types.ts";
|
||||
import { accountWorkspaceMemoryRender } from "../../../src/workspace-memory.ts";
|
||||
import { readJSONFile } from "../io.ts";
|
||||
import { objectFromCounts, sortedCounts } from "../text.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
import { normalizedStore } from "../workspace-snapshot.ts";
|
||||
|
||||
type CommandKind = "reinforce" | "replace";
|
||||
|
||||
@@ -33,6 +38,47 @@ type MemoryCommandSummary = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type MemoryCommandDetail = {
|
||||
version: 1;
|
||||
generatedAt: string;
|
||||
memoryId: string;
|
||||
current: {
|
||||
present: boolean;
|
||||
status?: string;
|
||||
renderStatus?: "rendered" | "not_rendered" | "unknown";
|
||||
type?: string;
|
||||
source?: string;
|
||||
};
|
||||
summary: {
|
||||
attempts: number;
|
||||
reinforced: number;
|
||||
rejectedOrBlocked: number;
|
||||
windowBlocked: number;
|
||||
blocksByReason: Record<string, number>;
|
||||
blockDetailsMissing: number;
|
||||
refs: string[];
|
||||
sameSessionCrossUtcDayBlocks: number;
|
||||
};
|
||||
events: Array<{
|
||||
eventId: string;
|
||||
createdAt: string;
|
||||
outcome: EvidenceOutcome;
|
||||
ref?: string;
|
||||
blockReason?: string;
|
||||
reasonCodes: string[];
|
||||
attemptedAtIso?: string;
|
||||
lastReinforcedAtIso?: string;
|
||||
elapsedMs?: number;
|
||||
requiredElapsedMs?: number;
|
||||
sameSession?: boolean;
|
||||
legacyMissingTimestamp?: boolean;
|
||||
reinforcementMode?: string;
|
||||
crossUtcDay?: boolean | "unknown";
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const INVALID_COMMAND_REASONS = new Set([
|
||||
"invalid_memory_command",
|
||||
"invalid_memory_ref",
|
||||
@@ -66,6 +112,139 @@ function refFromEvent(event: EvidenceEventV1): string | undefined {
|
||||
return typeof ref === "string" ? ref : undefined;
|
||||
}
|
||||
|
||||
function isReinforcementEvent(event: EvidenceEventV1): boolean {
|
||||
return event.type === "memory_reinforced";
|
||||
}
|
||||
|
||||
function stringDetail(event: EvidenceEventV1, key: string): string | undefined {
|
||||
const value = event.details?.[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function numberDetail(event: EvidenceEventV1, key: string): number | undefined {
|
||||
const value = event.details?.[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function booleanDetail(event: EvidenceEventV1, key: string): boolean | undefined {
|
||||
const value = event.details?.[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function isRejectedOrBlocked(event: EvidenceEventV1): boolean {
|
||||
return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked");
|
||||
}
|
||||
|
||||
function blockReasonFor(event: EvidenceEventV1): string | undefined {
|
||||
if (!isRejectedOrBlocked(event)) return undefined;
|
||||
return stringDetail(event, "blockReason") ?? "unknown";
|
||||
}
|
||||
|
||||
function isCrossUtcDay(attemptedAtIso: string | undefined, lastReinforcedAtIso: string | undefined): boolean | "unknown" {
|
||||
if (!attemptedAtIso || !lastReinforcedAtIso) return "unknown";
|
||||
const attempted = new Date(attemptedAtIso);
|
||||
const lastReinforced = new Date(lastReinforcedAtIso);
|
||||
if (Number.isNaN(attempted.getTime()) || Number.isNaN(lastReinforced.getTime())) return "unknown";
|
||||
return attempted.toISOString().slice(0, 10) !== lastReinforced.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function currentMemoryStatus(root: string, memoryId: string): Promise<MemoryCommandDetail["current"]> {
|
||||
const rawStore = await readJSONFile<WorkspaceMemoryStore>(await workspaceMemoryPath(root));
|
||||
const storeRoot = rawStore?.workspace?.root ?? root;
|
||||
const storeKey = rawStore?.workspace?.key ?? await workspaceKey(root);
|
||||
const store = normalizedStore(rawStore, storeRoot, storeKey);
|
||||
const activeEntry = store.entries.find(entry => entry.id === memoryId && entry.status !== "superseded");
|
||||
const renderAccounting = accountWorkspaceMemoryRender(store);
|
||||
const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id));
|
||||
const omittedIds = new Set(renderAccounting.omitted.map(item => item.memory.id));
|
||||
const renderStatus = renderedIds.has(memoryId)
|
||||
? "rendered"
|
||||
: omittedIds.has(memoryId)
|
||||
? "not_rendered"
|
||||
: "unknown";
|
||||
|
||||
if (!activeEntry) {
|
||||
return { present: false, renderStatus };
|
||||
}
|
||||
|
||||
return {
|
||||
present: true,
|
||||
status: activeEntry.status,
|
||||
renderStatus,
|
||||
type: activeEntry.type,
|
||||
source: activeEntry.source,
|
||||
};
|
||||
}
|
||||
|
||||
function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][number] {
|
||||
const attemptedAtIso = stringDetail(event, "attemptedAtIso");
|
||||
const lastReinforcedAtIso = stringDetail(event, "lastReinforcedAtIso");
|
||||
const blocked = isRejectedOrBlocked(event);
|
||||
const blockReason = blockReasonFor(event);
|
||||
return {
|
||||
eventId: event.eventId,
|
||||
createdAt: event.createdAt,
|
||||
outcome: event.outcome,
|
||||
ref: refFromEvent(event),
|
||||
blockReason,
|
||||
reasonCodes: event.reasonCodes,
|
||||
attemptedAtIso,
|
||||
lastReinforcedAtIso,
|
||||
elapsedMs: numberDetail(event, "elapsedMs"),
|
||||
requiredElapsedMs: numberDetail(event, "requiredElapsedMs"),
|
||||
sameSession: booleanDetail(event, "sameSession"),
|
||||
legacyMissingTimestamp: booleanDetail(event, "legacyMissingTimestamp") === true ? true : undefined,
|
||||
reinforcementMode: stringDetail(event, "reinforcementMode"),
|
||||
crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined,
|
||||
producerVersion: event.producerVersion,
|
||||
instrumentationVersion: event.instrumentationVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildMemoryCommandDetail(
|
||||
root: string,
|
||||
memoryId: string,
|
||||
events: EvidenceEventV1[],
|
||||
generatedAt = new Date().toISOString(),
|
||||
): Promise<MemoryCommandDetail> {
|
||||
const reinforcementEvents = events.filter(isReinforcementEvent);
|
||||
const blockReasonCounts = new Map<string, number>();
|
||||
const refs = new Set<string>();
|
||||
let blockDetailsMissing = 0;
|
||||
let sameSessionCrossUtcDayBlocks = 0;
|
||||
|
||||
for (const event of reinforcementEvents) {
|
||||
const ref = refFromEvent(event);
|
||||
if (ref) refs.add(ref);
|
||||
if (!isRejectedOrBlocked(event)) continue;
|
||||
|
||||
const blockReason = blockReasonFor(event) ?? "unknown";
|
||||
blockReasonCounts.set(blockReason, (blockReasonCounts.get(blockReason) ?? 0) + 1);
|
||||
if (!stringDetail(event, "blockReason")) blockDetailsMissing += 1;
|
||||
if (blockReason === "same_session" && isCrossUtcDay(stringDetail(event, "attemptedAtIso"), stringDetail(event, "lastReinforcedAtIso")) === true) {
|
||||
sameSessionCrossUtcDayBlocks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt,
|
||||
memoryId,
|
||||
current: await currentMemoryStatus(root, memoryId),
|
||||
summary: {
|
||||
attempts: reinforcementEvents.length,
|
||||
reinforced: reinforcementEvents.filter(event => event.outcome === "reinforced").length,
|
||||
rejectedOrBlocked: reinforcementEvents.filter(isRejectedOrBlocked).length,
|
||||
windowBlocked: reinforcementEvents.filter(event => hasReason(event, "reinforcement_window_blocked")).length,
|
||||
blocksByReason: objectFromCounts(blockReasonCounts),
|
||||
blockDetailsMissing,
|
||||
refs: [...refs].sort(),
|
||||
sameSessionCrossUtcDayBlocks,
|
||||
},
|
||||
events: reinforcementEvents.map(detailEventJSON),
|
||||
};
|
||||
}
|
||||
|
||||
function latestEventJSON(event: EvidenceEventV1): MemoryCommandSummary["latestEvents"][number] {
|
||||
return {
|
||||
eventId: event.eventId,
|
||||
@@ -145,6 +324,82 @@ function formatLatestEvents(events: MemoryCommandSummary["latestEvents"]): strin
|
||||
});
|
||||
}
|
||||
|
||||
function formatInlineCounts(counts: Record<string, number>): string {
|
||||
const rows = sortedCounts(new Map(Object.entries(counts)));
|
||||
return rows.length > 0 ? rows.map(([reason, count]) => `${reason}=${count}`).join(", ") : "(none)";
|
||||
}
|
||||
|
||||
function formatCrossUtcDay(value: boolean | "unknown" | undefined): string {
|
||||
if (value === true) return "yes";
|
||||
if (value === false) return "no";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatBoolean(value: boolean): string {
|
||||
return value ? "yes" : "no";
|
||||
}
|
||||
|
||||
function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] {
|
||||
if (events.length === 0) return [" (none)"];
|
||||
return events.map(event => {
|
||||
const ref = event.ref ? ` ref=${event.ref}` : "";
|
||||
const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : "";
|
||||
const reinforcementMode = event.reinforcementMode ? ` reinforcementMode=${event.reinforcementMode}` : "";
|
||||
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
|
||||
const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : "";
|
||||
const elapsedMs = event.elapsedMs !== undefined ? ` elapsedMs=${event.elapsedMs}` : "";
|
||||
const requiredElapsedMs = event.requiredElapsedMs !== undefined ? ` requiredElapsedMs=${event.requiredElapsedMs}` : "";
|
||||
const sameSession = event.sameSession !== undefined ? ` sameSession=${formatBoolean(event.sameSession)}` : "";
|
||||
const legacyMissingTimestamp = event.legacyMissingTimestamp === true ? " legacyMissingTimestamp=yes" : "";
|
||||
const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : "";
|
||||
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${reinforcementMode}${attemptedAt}${lastReinforcedAt}${elapsedMs}${requiredElapsedMs}${sameSession}${legacyMissingTimestamp}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMemoryCommandDetail(detail: MemoryCommandDetail, options: Pick<CliOptions, "verbose"> = {}): string {
|
||||
const current = detail.current;
|
||||
const lines = [
|
||||
`Memory command diagnostics for ${detail.memoryId}`,
|
||||
"",
|
||||
"Current memory:",
|
||||
` - present: ${current.present ? "yes" : "no"}`,
|
||||
` - status: ${current.status ?? "unknown"}`,
|
||||
` - render: ${current.renderStatus ?? "unknown"}`,
|
||||
];
|
||||
|
||||
if (current.type) lines.push(` - type: ${current.type}`);
|
||||
if (current.source) lines.push(` - source: ${current.source}`);
|
||||
|
||||
lines.push("");
|
||||
if (detail.summary.attempts === 0) {
|
||||
lines.push(`No reinforcement command evidence found for ${detail.memoryId}.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"Reinforcement summary:",
|
||||
` - attempts: ${detail.summary.attempts}`,
|
||||
` - reinforced: ${detail.summary.reinforced}`,
|
||||
` - rejected/blocked: ${detail.summary.rejectedOrBlocked}`,
|
||||
` - window blocked: ${detail.summary.windowBlocked}`,
|
||||
` - block reasons: ${formatInlineCounts(detail.summary.blocksByReason)}`,
|
||||
` - block details missing: ${detail.summary.blockDetailsMissing}`,
|
||||
` - same-session cross UTC day blocks: ${detail.summary.sameSessionCrossUtcDayBlocks}`,
|
||||
` - refs: ${detail.summary.refs.length > 0 ? detail.summary.refs.join(", ") : "(none)"}`,
|
||||
"",
|
||||
);
|
||||
|
||||
const eventRows = options.verbose ? detail.events : detail.events.slice(-10).reverse();
|
||||
if (!options.verbose && detail.events.length > eventRows.length) {
|
||||
lines.push(`Latest reinforcement events (showing ${eventRows.length} of ${detail.events.length}):`);
|
||||
} else {
|
||||
lines.push("Latest reinforcement events:");
|
||||
}
|
||||
lines.push(...formatMemoryCommandDetailEvents(eventRows));
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatMemoryCommandSummary(summary: MemoryCommandSummary, options: Pick<CliOptions, "verbose" | "noEmoji"> = {}): string {
|
||||
const warning = options.noEmoji ? "!" : "⚠";
|
||||
const lines = [
|
||||
@@ -176,6 +431,17 @@ export function formatMemoryCommandSummary(summary: MemoryCommandSummary, option
|
||||
|
||||
export async function runCommands(options: CliOptions): Promise<CommandResult> {
|
||||
const root = options.workspace ?? process.cwd();
|
||||
if (options.memory) {
|
||||
const events = await queryEvidenceEvents(root, { memoryId: options.memory });
|
||||
const detail = await buildMemoryCommandDetail(root, options.memory, events);
|
||||
|
||||
if (options.json) {
|
||||
return { stdout: JSON.stringify(detail, null, 2) };
|
||||
}
|
||||
|
||||
return { stdout: formatMemoryCommandDetail(detail, options) };
|
||||
}
|
||||
|
||||
const events = await queryEvidenceEvents(root);
|
||||
const summary = buildMemoryCommandSummary(events);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { buildQualityJSON, formatQualityReviewBoard } from "../formatters/quality.ts";
|
||||
import { buildInspectionReadModel } from "../inspection-model.ts";
|
||||
import { buildQualityReviewBoard } from "../quality-review-model.ts";
|
||||
import type { CliOptions, CommandResult } from "../types.ts";
|
||||
|
||||
export async function runQuality(options: CliOptions): Promise<CommandResult> {
|
||||
const model = await buildInspectionReadModel(options);
|
||||
const report = buildQualityReviewBoard(model, {
|
||||
verbose: options.verbose,
|
||||
raw: options.raw,
|
||||
json: options.json,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
return { stdout: JSON.stringify(buildQualityJSON(report, options.raw), null, 2) };
|
||||
}
|
||||
|
||||
return { stdout: formatQualityReviewBoard(report, { verbose: options.verbose }) };
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { cleanText, formatDetails } from "../text.ts";
|
||||
import type {
|
||||
CandidateProvenance,
|
||||
HeuristicFlag,
|
||||
ProvenanceClassification,
|
||||
RejectionVersionFacts,
|
||||
ReinforcementVersionFacts,
|
||||
ReviewBoardActiveMemory,
|
||||
ReviewBoardCandidate,
|
||||
ReviewBoardReport,
|
||||
EvictionVersionFacts,
|
||||
VersionAvailability,
|
||||
VersionCoverage,
|
||||
VersionedMechanismFacts,
|
||||
} from "../quality-review-model.ts";
|
||||
|
||||
const PROVENANCE_ORDER: ProvenanceClassification[] = [
|
||||
"explicit_migration_evidence",
|
||||
"legacy_unversioned_format",
|
||||
"reabsorbed_post_rejection",
|
||||
"suspected_pre_migration_legacy",
|
||||
"likely_current_behavior",
|
||||
"unversioned_ambiguous",
|
||||
];
|
||||
|
||||
const REVIEW_FLAG_CAVEAT = "This flag is a prompt for review, not a conclusion.";
|
||||
|
||||
export function buildQualityJSON(report: ReviewBoardReport, raw = false): unknown {
|
||||
if (raw) return report;
|
||||
return redactUnknown(report);
|
||||
}
|
||||
|
||||
export function formatQualityReviewBoard(
|
||||
report: ReviewBoardReport,
|
||||
options: { verbose?: boolean },
|
||||
): string {
|
||||
const bullet = "-";
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("Memory quality review board");
|
||||
lines.push("Purpose: evidence for human/agent review only; no automatic judgment or cleanup.");
|
||||
lines.push("Producer version note: historical records do not include package/plugin version; provenance below is inferred.");
|
||||
lines.push("Primary review purpose: SYSTEM MECHANISM observations (filters, reinforcement, eviction/caps, identity/dedup).");
|
||||
lines.push("Secondary review purpose: MEMORY CONTENT quality (staleness, durability, redundancy, specificity).");
|
||||
lines.push("");
|
||||
|
||||
pushEvidenceProvenance(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushSystemMechanismFacts(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushMemoryContentFacts(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushSystemMechanismCandidates(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushMemoryContentCandidates(lines, report, bullet, options);
|
||||
lines.push("");
|
||||
pushReviewQuestions(lines, report, bullet);
|
||||
lines.push("");
|
||||
pushNextCommands(lines, report, bullet);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function pushEvidenceProvenance(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
const context = report.provenanceContext;
|
||||
const instrumentation = report.facts.systemMechanisms.instrumentation;
|
||||
lines.push("Evidence provenance");
|
||||
lines.push(` ${bullet} method: migration/timestamp/format inference`);
|
||||
lines.push(` ${bullet} confidence: ${context.confidenceDisclaimer}`);
|
||||
lines.push(` ${bullet} Producer coverage: ${instrumentation.evidenceEventsWithProducer} of ${instrumentation.evidenceEventsTotal} evidence events instrumented`);
|
||||
lines.push(` ${bullet} Rejection producer coverage: ${instrumentation.rejectionRecordsWithProducer} of ${instrumentation.rejectionRecordsTotal} rejection records instrumented`);
|
||||
lines.push(` ${bullet} instrumentation versions: ${formatCounts(instrumentation.instrumentationVersions)}`);
|
||||
lines.push(` ${bullet} migration timeline: ${formatMigrationTimeline(context.migrationTimeline)}`);
|
||||
if (context.lastActivityAt) lines.push(` ${bullet} last activity: ${context.lastActivityAt}`);
|
||||
}
|
||||
|
||||
function pushSystemMechanismFacts(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
const facts = report.facts.systemMechanisms;
|
||||
lines.push("Facts - system mechanisms");
|
||||
lines.push(" Provenance counts for mechanism evidence");
|
||||
lines.push(` ${bullet} ${formatProvenanceCounts(report.provenanceContext.countsByClassification)}`);
|
||||
lines.push(" Rejection filters");
|
||||
pushAnswerability(lines, report.answerability?.rejectionFilters, " ");
|
||||
lines.push(` ${bullet} rejected records: ${facts.rejectionFilters.totalRecords} (unique: ${facts.rejectionFilters.uniqueTexts})`);
|
||||
lines.push(` ${bullet} raw reason-code distribution: ${formatCounts(facts.rejectionFilters.byRawReasonCode)}`);
|
||||
lines.push(` ${bullet} type distribution: ${formatCounts(facts.rejectionFilters.byType)}`);
|
||||
lines.push(` ${bullet} ambiguous/architecture-like rejected candidates: ${facts.rejectionFilters.ambiguousOrArchitectureLike}`);
|
||||
lines.push(` ${bullet} status-or-hard-reason evidence: ${facts.rejectionFilters.hardReasonOrNoiseHeuristic}`);
|
||||
lines.push(` ${bullet} re-absorbed rejected texts: ${facts.rejectionFilters.reabsorbedRejectedTexts}`);
|
||||
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.rejectionFilters, facts.versionedFacts.versionCoverage, formatRejectionVersionFacts, bullet);
|
||||
lines.push(" Reinforcement rules");
|
||||
pushAnswerability(lines, report.answerability?.reinforcementRules, " ");
|
||||
lines.push(` ${bullet} reinforce attempts: ${facts.reinforcementRules.reinforceEvents}, reinforced: ${facts.reinforcementRules.reinforcedEvents}, rejected/blocked: ${facts.reinforcementRules.rejectedOrBlockedEvents}`);
|
||||
lines.push(` ${bullet} reinforcement-window blocked: ${facts.reinforcementRules.windowBlockedEvents} (rate: ${formatPercent(facts.reinforcementRules.windowBlockRate)})`);
|
||||
lines.push(` ${bullet} Exact block reasons: ${formatCounts(facts.reinforcementRules.blocksByExactReason)}`);
|
||||
lines.push(` ${bullet} window blocks by UTC day: ${formatCounts(facts.reinforcementRules.windowBlocksByUtcDay)}`);
|
||||
lines.push(` ${bullet} block details missing: ${facts.reinforcementRules.blockDetailsMissing}`);
|
||||
lines.push(` ${bullet} repeated blocks by memory: ${formatRepeatedBlocks(facts.reinforcementRules.repeatedBlocksByMemory)}`);
|
||||
lines.push(` ${bullet} malformed command events: ${facts.reinforcementRules.malformedCommandEvents}`);
|
||||
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.reinforcementRules, facts.versionedFacts.versionCoverage, formatReinforcementVersionFacts, bullet);
|
||||
lines.push(" Eviction and caps");
|
||||
pushAnswerability(lines, report.answerability?.evictionAndCaps, " ");
|
||||
lines.push(` ${bullet} active memories: ${facts.evictionAndCaps.activeMemories} / ${facts.evictionAndCaps.maxEntries}`);
|
||||
lines.push(` ${bullet} rendered memories: ${facts.evictionAndCaps.renderedMemories}`);
|
||||
lines.push(` ${bullet} cap occupancy: ${formatFullCaps(facts.evictionAndCaps.fullCaps, facts.evictionAndCaps.typeCounts, facts.evictionAndCaps.typeCaps, facts.evictionAndCaps.activeMemories, facts.evictionAndCaps.maxEntries)}`);
|
||||
lines.push(` ${bullet} capacity removals: total=${facts.evictionAndCaps.removedByCapacity}, global=${facts.evictionAndCaps.removedByGlobalCap}, type=${facts.evictionAndCaps.removedByTypeCap}`);
|
||||
lines.push(` ${bullet} Removals with snapshot: ${facts.evictionAndCaps.recentCapacityRemovalsWithSnapshot}`);
|
||||
lines.push(` ${bullet} Removals without snapshot: ${facts.evictionAndCaps.capacitySnapshotsMissing} (historical)`);
|
||||
if (facts.evictionAndCaps.highestRankRemoved) lines.push(` ${bullet} highest-rank removed snapshot: ${formatHighestRankRemoved(facts.evictionAndCaps.highestRankRemoved)}`);
|
||||
lines.push(` ${bullet} recent evictions by type: ${formatCounts(facts.evictionAndCaps.recentEvictionsByType)}`);
|
||||
lines.push(` ${bullet} recent evicted content shown: ${facts.evictionAndCaps.recentEvictedContentShown}`);
|
||||
if (facts.versionedFacts) pushVersionAnalysis(lines, facts.versionedFacts.evictionAndCaps, facts.versionedFacts.versionCoverage, formatEvictionVersionFacts, bullet);
|
||||
lines.push(" Unknown disappearances");
|
||||
pushAnswerability(lines, report.answerability?.unknownDisappearances, " ");
|
||||
lines.push(` ${bullet} unversioned disappearance inventory: evidence-only=${facts.evictionAndCaps.missingEvidenceOnly}, unknown=${facts.evictionAndCaps.unknownDisappearances}`);
|
||||
lines.push(" Identity and dedup");
|
||||
pushAnswerability(lines, report.answerability?.identityAndDedup, " ");
|
||||
lines.push(` ${bullet} replacements: total=${facts.identityAndDedup.replacementEvents}, same-type=${facts.identityAndDedup.sameTypeReplacementEvents}, cross-type=${facts.identityAndDedup.crossTypeReplacementEvents}`);
|
||||
lines.push(` ${bullet} superseded entries: ${facts.identityAndDedup.supersededEntries}`);
|
||||
lines.push(` ${bullet} exact duplicate/identity groups identified: ${facts.identityAndDedup.duplicateTextOrIdentityGroups}`);
|
||||
}
|
||||
|
||||
function pushMemoryContentFacts(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
const facts = report.facts.memoryContent;
|
||||
lines.push("Facts - memory content");
|
||||
pushAnswerability(lines, report.answerability?.memoryContent, " ");
|
||||
lines.push(` ${bullet} rendered memories: ${facts.renderedMemories}`);
|
||||
lines.push(` ${bullet} evidence coverage: ${facts.evidenceCoverage.covered} / ${facts.evidenceCoverage.total}`);
|
||||
lines.push(` ${bullet} type counts: ${formatTypeCountsWithCaps(facts.typeCounts, facts.typeCaps)}`);
|
||||
lines.push(` ${bullet} weakest/strongest active memory previews: weakest=${formatMemoryPreviews(facts.weakestActiveMemories)}; strongest=${formatMemoryPreviews(facts.strongestActiveMemories)}`);
|
||||
}
|
||||
|
||||
function pushAnswerability(
|
||||
lines: string[],
|
||||
assessment: NonNullable<ReviewBoardReport["answerability"]>[keyof NonNullable<ReviewBoardReport["answerability"]>] | undefined,
|
||||
indent: string,
|
||||
): void {
|
||||
if (!assessment) return;
|
||||
const suffix = assessment.level === "partial" ? " — causal fields exist, but human content judgment is still required" : "";
|
||||
lines.push(`${indent}(Answerability: ${assessment.level}${suffix})`);
|
||||
lines.push(`${indent}Output permission: ${assessment.outputPermission}`);
|
||||
}
|
||||
|
||||
function pushVersionAnalysis<TFacts>(
|
||||
lines: string[],
|
||||
mechanism: VersionedMechanismFacts<TFacts>,
|
||||
coverage: VersionCoverage,
|
||||
formatFacts: (facts: TFacts) => string,
|
||||
bullet: string,
|
||||
): void {
|
||||
lines.push(" Version analysis by producer version");
|
||||
lines.push(` Version-stamp coverage (all evidence/rejection records, not mechanism problem counts): Coverage: ${formatCoveragePercent(coverage.coveragePercent)} of ${formatInteger(coverage.totalEvents)} records carry a version stamp (${formatInteger(coverage.currentVersionEvents)} current, ${formatInteger(coverage.previousVersionEvents)} previous, ${formatInteger(coverage.unknownVersionEvents)} unknown/unversioned). Comparison will become meaningful as new events accumulate.`);
|
||||
if (coverage.isTransitional) {
|
||||
lines.push(" NOTE: Version coverage is below 50%. Current-version comparisons may not be representative.");
|
||||
}
|
||||
lines.push(` ${mechanismOpportunityDescription(mechanism)}`);
|
||||
for (const group of ["current", "previous", "unknown_unversioned"] as const) {
|
||||
const bucket = mechanism.buckets[group];
|
||||
lines.push(` ${bullet} ${bucket.label}: opportunities=${bucket.opportunityCount}, observed=${bucket.observedPatternCount}, sample=${bucket.sampleAssessment}, answerability=${bucket.answerabilityLevel}`);
|
||||
if (Object.keys(bucket.producerVersions).length > 0) lines.push(` producer versions: ${formatCounts(bucket.producerVersions)}`);
|
||||
lines.push(` composition: ${formatVersionAvailability(bucket.versionAvailability)}`);
|
||||
lines.push(` facts: ${formatFacts(bucket.facts)}`);
|
||||
}
|
||||
lines.push(` ${bullet} inference: ${mechanism.inference.message}`);
|
||||
lines.push(` diagnostic strength: ${diagnosticStrengthLabel(mechanism)}`);
|
||||
const diagnosticLine = currentMechanismDiagnosticLine(mechanism);
|
||||
if (diagnosticLine) lines.push(` ${diagnosticLine}`);
|
||||
if (mechanism.diagnosticQuestions) {
|
||||
for (const question of mechanism.diagnosticQuestions) {
|
||||
lines.push(` diagnostic question: ${question.question} Evidence: ${question.evidence.join(", ")}`);
|
||||
}
|
||||
}
|
||||
lines.push(` caveat: ${mechanism.inference.caveat}`);
|
||||
}
|
||||
|
||||
function diagnosticStrengthLabel<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string {
|
||||
const current = mechanism.buckets.current;
|
||||
const strength = mechanism.inference.status === "no_current_version_opportunities" || current.opportunityCount === 0
|
||||
? "unavailable"
|
||||
: current.opportunityCount < mechanism.sampleThreshold
|
||||
? "weak"
|
||||
: "moderate";
|
||||
return hasCurrentCausalDetail(mechanism) ? `${strength}; causal detail available` : strength;
|
||||
}
|
||||
|
||||
function hasCurrentCausalDetail<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): boolean {
|
||||
const currentFacts = mechanism.buckets.current.facts;
|
||||
return isReinforcementVersionFacts(currentFacts) && Object.keys(currentFacts.blocksByExactReason).length > 0;
|
||||
}
|
||||
|
||||
function currentMechanismDiagnosticLine<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string | undefined {
|
||||
const current = mechanism.buckets.current;
|
||||
if (!isReinforcementVersionFacts(current.facts)) return undefined;
|
||||
const facts = current.facts;
|
||||
const parts: string[] = [];
|
||||
if (Object.keys(facts.blocksByExactReason).length > 0) parts.push(`current block reasons=${formatCounts(facts.blocksByExactReason)}`);
|
||||
if (facts.blockDetailsMissing > 0) parts.push(`current block details missing=${facts.blockDetailsMissing}`);
|
||||
if (parts.length === 0) return undefined;
|
||||
parts.push(`sample=${current.opportunityCount} attempts`);
|
||||
return `diagnostic: ${parts.join("; ")}`;
|
||||
}
|
||||
|
||||
function isReinforcementVersionFacts(facts: unknown): facts is ReinforcementVersionFacts {
|
||||
return typeof facts === "object"
|
||||
&& facts !== null
|
||||
&& "blocksByExactReason" in facts
|
||||
&& "blockDetailsMissing" in facts
|
||||
&& "windowBlockedEvents" in facts;
|
||||
}
|
||||
|
||||
function mechanismOpportunityDescription<TFacts>(mechanism: VersionedMechanismFacts<TFacts>): string {
|
||||
if (mechanism.opportunityName === "rejection candidates") return "Mechanism opportunities below are reviewable rejection candidates only; render/accounting events are excluded.";
|
||||
if (mechanism.opportunityName === "attempts") return "Mechanism opportunities below are reinforcement attempts only; render/accounting events are excluded.";
|
||||
if (mechanism.opportunityName === "capacity removals") return "Mechanism opportunities below are capacity removals only; render/accounting events are excluded.";
|
||||
return `Mechanism opportunities below are ${mechanism.opportunityName} only; render/accounting events are excluded.`;
|
||||
}
|
||||
|
||||
function formatVersionAvailability(availability: VersionAvailability): string {
|
||||
const parts = [
|
||||
availability.noProducerFields > 0 ? `no producer fields=${availability.noProducerFields}` : undefined,
|
||||
availability.unknownProducerVersion > 0 ? `unknown version=${availability.unknownProducerVersion}` : undefined,
|
||||
availability.emptyProducerVersion > 0 ? `empty version=${availability.emptyProducerVersion}` : undefined,
|
||||
availability.knownProducerVersion > 0 ? `known version=${availability.knownProducerVersion}` : undefined,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
return parts.length === 0 ? "(empty bucket)" : parts.join(", ");
|
||||
}
|
||||
|
||||
function formatRejectionVersionFacts(facts: RejectionVersionFacts): string {
|
||||
return `records=${facts.totalRecords}, candidates=${facts.candidateRecords}, raw reason codes=${formatCounts(facts.byRawReasonCode)}, types=${formatCounts(facts.byType)}`;
|
||||
}
|
||||
|
||||
function formatReinforcementVersionFacts(facts: ReinforcementVersionFacts): string {
|
||||
return `reinforce attempts=${facts.reinforceEvents}, window blocked=${facts.windowBlockedEvents}, exact reasons=${formatCounts(facts.blocksByExactReason)}, block details missing=${facts.blockDetailsMissing}`;
|
||||
}
|
||||
|
||||
function formatEvictionVersionFacts(facts: EvictionVersionFacts): string {
|
||||
return `capacity removals=${facts.removedByCapacity}, with snapshot=${facts.recentCapacityRemovalsWithSnapshot}, missing snapshot=${facts.capacitySnapshotsMissing}`;
|
||||
}
|
||||
|
||||
function pushSystemMechanismCandidates(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
const display = report.provenanceContext.candidateDisplay;
|
||||
if (report.provenanceContext.candidateLimit && display && display.shown < display.total) {
|
||||
lines.push(`System mechanism review candidates (representative; ${display.shown} shown of ${display.total} total; limit ${report.provenanceContext.candidateLimit} per mechanism category)`);
|
||||
} else {
|
||||
lines.push("System mechanism review candidates");
|
||||
}
|
||||
pushCandidateGroup(lines, "Rejection filter evidence", candidatesFor(report, ["rejection_rule_evidence"]), bullet);
|
||||
pushCandidateGroup(lines, "Re-absorption evidence", candidatesFor(report, ["reabsorption_evidence"]), bullet);
|
||||
pushCandidateGroup(lines, "Reinforcement rule evidence", candidatesFor(report, ["numbered_command_evidence"]), bullet);
|
||||
pushCandidateGroup(lines, "Eviction/cap evidence", candidatesFor(report, ["eviction_cap_evidence", "missing_evidence"]), bullet);
|
||||
pushCandidateGroup(lines, "Identity/dedup evidence", candidatesFor(report, ["identity_dedup_evidence"]), bullet);
|
||||
}
|
||||
|
||||
function pushCandidateGroup(lines: string[], title: string, candidates: ReviewBoardCandidate[], bullet: string): void {
|
||||
lines.push(` ${title}`);
|
||||
if (candidates.length === 0) {
|
||||
lines.push(" (none)");
|
||||
return;
|
||||
}
|
||||
const shared = sharedProvenance(candidates);
|
||||
if (shared) lines.push(` shared provenance for displayed candidates in this group: ${formatProvenance(shared)}`);
|
||||
for (const candidate of candidates) pushCandidate(lines, candidate, bullet, shared);
|
||||
}
|
||||
|
||||
function pushCandidate(lines: string[], candidate: ReviewBoardCandidate, bullet: string, groupProvenance?: CandidateProvenance): void {
|
||||
const rawReasonCodes = candidate.evidence.rawReasonCodes && candidate.evidence.rawReasonCodes.length > 0
|
||||
? candidate.evidence.rawReasonCodes.join(", ")
|
||||
: "none";
|
||||
const question = candidate.reviewQuestions[0] ?? "What should a reviewer infer from this evidence?";
|
||||
lines.push(` ${bullet} concern=${formatConcern(candidate.concernKind)} id=${candidate.id} source=${candidate.source} mechanism=${candidate.mechanism ?? "unspecified"} raw reason codes=${rawReasonCodes} question=${question}`);
|
||||
if (candidate.provenance && (!groupProvenance || formatProvenance(candidate.provenance) !== formatProvenance(groupProvenance))) {
|
||||
lines.push(` provenance: ${formatProvenance(candidate.provenance)}`);
|
||||
}
|
||||
if (candidate.evidence.eventIds && candidate.evidence.eventIds.length > 0) lines.push(` event ids: ${candidate.evidence.eventIds.join(", ")}`);
|
||||
if (candidate.evidence.textAvailable) {
|
||||
lines.push(` text preview: ${candidate.evidence.textPreview ?? "available but empty after redaction"}`);
|
||||
} else {
|
||||
lines.push(" text preview: unavailable in historical evidence");
|
||||
}
|
||||
lines.push(` facts: ${formatCandidateFacts(candidate.facts)}`);
|
||||
pushHeuristicFlags(lines, candidate.heuristicFlags, " ", bullet);
|
||||
}
|
||||
|
||||
function pushMemoryContentCandidates(lines: string[], report: ReviewBoardReport, bullet: string, options: { verbose?: boolean }): void {
|
||||
const display = report.activeMemoryDisplay;
|
||||
lines.push("Memory content review candidates");
|
||||
if (report.reviewQuestions.memoryContent.length > 0) {
|
||||
lines.push(" Standard review questions (applicable to all active memories below):");
|
||||
for (const question of report.reviewQuestions.memoryContent) lines.push(` ${bullet} ${question}`);
|
||||
}
|
||||
if (display.total === 0) {
|
||||
lines.push(" Active memories (none)");
|
||||
return;
|
||||
}
|
||||
if (display.total <= display.threshold) {
|
||||
lines.push(` Active memories (showing all ${display.total} because <= ${display.threshold})`);
|
||||
} else if (display.mode === "all" || options.verbose) {
|
||||
lines.push(` Active memories (showing all ${display.total} because --verbose)`);
|
||||
} else {
|
||||
lines.push(` Active memories (showing ${display.shown} of ${display.total})`);
|
||||
lines.push(` Showing ${display.shown} of ${display.total} active memories. Use --verbose or --json for all active memory text.`);
|
||||
}
|
||||
display.items.forEach((item, index) => pushActiveMemory(lines, item, index + 1, bullet, report.reviewQuestions.memoryContent));
|
||||
}
|
||||
|
||||
function pushActiveMemory(lines: string[], item: ReviewBoardActiveMemory, index: number, bullet: string, standardQuestions: string[]): void {
|
||||
const strength = typeof item.strength === "number" ? item.strength.toFixed(3) : "unknown";
|
||||
lines.push(` [${index}] id=${item.id} type=${item.type} source=${item.source} status=${item.status} strength=${strength}`);
|
||||
lines.push(" text: " + indentContinuation(item.text, " "));
|
||||
const rawReasonCodes = item.evidence.rawReasonCodes.length > 0 ? item.evidence.rawReasonCodes.join(", ") : "none";
|
||||
lines.push(` evidence: events=${item.evidence.eventCount} raw reason codes=${rawReasonCodes}`);
|
||||
if (item.provenance) lines.push(` provenance: ${formatProvenance(item.provenance)}`);
|
||||
pushHeuristicFlags(lines, item.heuristicFlags, " ", bullet);
|
||||
if (questionsEqual(item.reviewQuestions, standardQuestions)) return;
|
||||
const additionalQuestions = item.reviewQuestions.filter(question => !standardQuestions.includes(question));
|
||||
if (additionalQuestions.length > 0 && additionalQuestions.length < item.reviewQuestions.length) {
|
||||
lines.push(" additional review questions:");
|
||||
for (const question of additionalQuestions) lines.push(` ${bullet} ${question}`);
|
||||
return;
|
||||
}
|
||||
lines.push(" review questions:");
|
||||
for (const question of item.reviewQuestions) lines.push(` ${bullet} ${question}`);
|
||||
}
|
||||
|
||||
function pushHeuristicFlags(lines: string[], flags: HeuristicFlag[], indent: string, bullet: string): void {
|
||||
if (flags.length === 0) return;
|
||||
lines.push(`${indent}heuristic flags:`);
|
||||
for (const flag of flags) {
|
||||
const caveat = flag.caveat || REVIEW_FLAG_CAVEAT;
|
||||
lines.push(`${indent} ${bullet} ${flag.label}: ${flag.evidence}. ${caveat}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pushReviewQuestions(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
lines.push("Review questions");
|
||||
lines.push(" SYSTEM MECHANISM");
|
||||
for (const question of report.reviewQuestions.systemMechanism) lines.push(` ${bullet} ${question}`);
|
||||
lines.push(" MEMORY CONTENT");
|
||||
for (const question of report.reviewQuestions.memoryContent) lines.push(` ${bullet} ${question}`);
|
||||
}
|
||||
|
||||
function pushNextCommands(lines: string[], report: ReviewBoardReport, bullet: string): void {
|
||||
lines.push("Next commands");
|
||||
for (const command of report.nextCommands) lines.push(` ${bullet} ${command}`);
|
||||
}
|
||||
|
||||
function candidatesFor(report: ReviewBoardReport, sources: ReviewBoardCandidate["source"][]): ReviewBoardCandidate[] {
|
||||
const sourceSet = new Set(sources);
|
||||
return report.reviewCandidates.filter(candidate => candidate.concernKind === "system_mechanism" && sourceSet.has(candidate.source));
|
||||
}
|
||||
|
||||
function sharedProvenance(candidates: ReviewBoardCandidate[]): CandidateProvenance | undefined {
|
||||
if (candidates.length <= 1) return undefined;
|
||||
const first = candidates[0]?.provenance;
|
||||
if (!first) return undefined;
|
||||
const key = formatProvenance(first);
|
||||
return candidates.every(candidate => candidate.provenance && formatProvenance(candidate.provenance) === key) ? first : undefined;
|
||||
}
|
||||
|
||||
function formatConcern(concern: ReviewBoardCandidate["concernKind"]): string {
|
||||
return concern === "system_mechanism" ? "SYSTEM MECHANISM" : "MEMORY CONTENT";
|
||||
}
|
||||
|
||||
function formatMigrationTimeline(timeline: ReviewBoardReport["provenanceContext"]["migrationTimeline"]): string {
|
||||
if (timeline.length === 0) return "(none)";
|
||||
return timeline.map(row => `${row.migrationId}=${row.presentInStore ? "present" : "absent"}${row.firstEvidenceAt ? ` firstEvidenceAt=${row.firstEvidenceAt}` : ""}`).join(", ");
|
||||
}
|
||||
|
||||
function formatProvenanceCounts(counts: Record<ProvenanceClassification, number>): string {
|
||||
return PROVENANCE_ORDER.map(classification => `${classification}=${counts[classification] ?? 0}`).join(", ");
|
||||
}
|
||||
|
||||
function formatCounts(counts: Record<string, number>): string {
|
||||
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
return entries.length === 0 ? "(none)" : entries.map(([key, count]) => `${key}=${count}`).join(", ");
|
||||
}
|
||||
|
||||
function formatTypeCountsWithCaps(counts: Record<string, number>, caps: Record<string, number>): string {
|
||||
const keys = uniqueSorted([...Object.keys(caps), ...Object.keys(counts)]);
|
||||
return keys.length === 0 ? "(none)" : keys.map(key => `${key} ${counts[key] ?? 0}/${caps[key] ?? "?"}`).join(", ");
|
||||
}
|
||||
|
||||
function formatFullCaps(fullCaps: string[], typeCounts: Record<string, number>, typeCaps: Record<string, number>, active: number, maxEntries: number): string {
|
||||
if (fullCaps.length === 0) return "(none)";
|
||||
return fullCaps.map(cap => cap === "global" ? `global ${active}/${maxEntries}` : `${cap} ${typeCounts[cap] ?? 0}/${typeCaps[cap] ?? "?"}`).join(", ");
|
||||
}
|
||||
|
||||
function formatRepeatedBlocks(blocks: ReviewBoardReport["facts"]["systemMechanisms"]["reinforcementRules"]["repeatedBlocksByMemory"]): string {
|
||||
if (blocks.length === 0) return "(none)";
|
||||
return blocks.map(block => `${block.memoryId} count=${block.count} refs=${block.refs.join("|") || "none"} raw reason codes=${block.rawReasonCodes.join("|") || "none"}`).join(", ");
|
||||
}
|
||||
|
||||
function formatHighestRankRemoved(snapshot: NonNullable<ReviewBoardReport["facts"]["systemMechanisms"]["evictionAndCaps"]["highestRankRemoved"]>): string {
|
||||
const parts = [
|
||||
`eventId=${snapshot.eventId}`,
|
||||
snapshot.memoryId ? `memoryId=${snapshot.memoryId}` : undefined,
|
||||
snapshot.type ? `type=${snapshot.type}` : undefined,
|
||||
`rankAtRemoval=${snapshot.rankAtRemoval}`,
|
||||
typeof snapshot.strengthAtRemoval === "number" ? `strengthAtRemoval=${snapshot.strengthAtRemoval}` : undefined,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function formatMemoryPreviews(items: ReviewBoardReport["facts"]["memoryContent"]["weakestActiveMemories"]): string {
|
||||
if (items.length === 0) return "(none)";
|
||||
return items.map(item => `${item.id} type=${item.type} strength=${typeof item.strength === "number" ? item.strength.toFixed(3) : "unknown"} text=${JSON.stringify(item.textPreview)}`).join(" | ");
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(Number.isFinite(value) ? value * 100 : 0).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatCoveragePercent(value: number): string {
|
||||
if (!Number.isFinite(value)) return "0%";
|
||||
return Number.isInteger(value) ? `${value}%` : `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatInteger(value: number): string {
|
||||
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
|
||||
}
|
||||
|
||||
function formatProvenance(provenance: CandidateProvenance): string {
|
||||
return `${provenance.classification} confidence=${provenance.confidence}; basis=${provenance.basis.join("; ") || "unavailable"}; caveat=${provenance.interpretationCaveat}`;
|
||||
}
|
||||
|
||||
function formatCandidateFacts(facts: Record<string, unknown>): string {
|
||||
if (Object.keys(facts).length === 0) return "(none)";
|
||||
return formatDetails(Object.fromEntries(
|
||||
Object.entries(facts).map(([key, value]) => [key, formatFactValue(value)]),
|
||||
));
|
||||
}
|
||||
|
||||
function formatFactValue(value: unknown): string | number | boolean | string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return value.map(item => typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null ? String(item) : stringifyUnknown(item));
|
||||
return stringifyUnknown(value);
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized === undefined ? String(value) : serialized;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function questionsEqual(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
function indentContinuation(text: string, indent: string): string {
|
||||
return text.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
|
||||
}
|
||||
|
||||
function uniqueSorted(values: string[]): string[] {
|
||||
return [...new Set(values)].sort();
|
||||
}
|
||||
|
||||
function redactUnknown(value: unknown): unknown {
|
||||
if (typeof value === "string") return cleanText(value, false);
|
||||
if (Array.isArray(value)) return value.map(item => redactUnknown(item));
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactUnknown(item)]));
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
RETENTION_TYPE_MAX,
|
||||
} from "../../../src/retention.ts";
|
||||
import { TYPES } from "../constants.ts";
|
||||
import { daysSinceIso, formatStrength } from "../retention-model.ts";
|
||||
import { formatStrength } from "../retention-model.ts";
|
||||
import { cleanText, truncate } from "../text.ts";
|
||||
import type { MemoryInspectionReadModel, RetentionDiagItem } from "../types.ts";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
|
||||
import type { LongTermType } from "../../src/types.ts";
|
||||
import { countBy, objectFromCounts, uniqueStrings } from "./text.ts";
|
||||
import { countBy, objectFromCounts } from "./text.ts";
|
||||
import { groupEvidenceByMemoryId } from "./evidence-model.ts";
|
||||
import { loadRejectionRecords } from "./rejections-model.ts";
|
||||
import { snapshotForOptions } from "./workspace-snapshot.ts";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
export type AnswerabilityLevel = "supported" | "partial" | "inventory_only" | "not_instrumented";
|
||||
|
||||
export type ProducerVersionGroup = "current" | "previous" | "unknown_unversioned";
|
||||
|
||||
export type ProducerBearingRecord = {
|
||||
producerName?: string;
|
||||
producerVersion?: string;
|
||||
instrumentationVersion?: number;
|
||||
};
|
||||
|
||||
export type VersionSampleAssessment =
|
||||
| "observed"
|
||||
| "not_observed_but_sample_small"
|
||||
| "not_observed_with_sufficient_sample"
|
||||
| "no_current_version_opportunities";
|
||||
|
||||
export type VersionAvailability = {
|
||||
noProducerFields: number;
|
||||
unknownProducerVersion: number;
|
||||
emptyProducerVersion: number;
|
||||
knownProducerVersion: number;
|
||||
};
|
||||
|
||||
export type VersionCoverage = {
|
||||
totalEvents: number;
|
||||
currentVersionEvents: number;
|
||||
previousVersionEvents: number;
|
||||
unknownVersionEvents: number;
|
||||
coveragePercent: number;
|
||||
isTransitional: boolean;
|
||||
};
|
||||
|
||||
export const VERSION_ANALYSIS_SAMPLE_THRESHOLD = 5;
|
||||
export const VERSION_GROUPS: ProducerVersionGroup[] = ["current", "previous", "unknown_unversioned"];
|
||||
export const VERSION_GROUPING_CAVEAT = "Version grouping is based only on producerVersion strings in evidence" as const;
|
||||
|
||||
export type VersionedMechanismInference = {
|
||||
status:
|
||||
| "current_recurrence_detected"
|
||||
| "pattern_persists_across_versions"
|
||||
| "no_current_evidence_observed"
|
||||
| "no_current_evidence_sample_small"
|
||||
| "no_current_version_opportunities"
|
||||
| "no_previous_pattern_observed";
|
||||
message: string;
|
||||
caveat: typeof VERSION_GROUPING_CAVEAT;
|
||||
};
|
||||
|
||||
export type VersionBucketFacts<TFacts> = {
|
||||
group: ProducerVersionGroup;
|
||||
label: string;
|
||||
opportunityCount: number;
|
||||
observedPatternCount: number;
|
||||
producerVersions: Record<string, number>;
|
||||
versionAvailability: VersionAvailability;
|
||||
answerabilityLevel: AnswerabilityLevel;
|
||||
sampleAssessment: VersionSampleAssessment;
|
||||
facts: TFacts;
|
||||
};
|
||||
|
||||
export type VersionedMechanismDiagnosticQuestion = {
|
||||
mechanism: "reinforcement_rule";
|
||||
group: ProducerVersionGroup;
|
||||
question: string;
|
||||
evidence: string[];
|
||||
};
|
||||
|
||||
export type VersionedMechanismFacts<TFacts> = {
|
||||
currentPackageVersion: string;
|
||||
opportunityName: string;
|
||||
sampleThreshold: number;
|
||||
buckets: Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
|
||||
inference: VersionedMechanismInference;
|
||||
diagnosticQuestions?: VersionedMechanismDiagnosticQuestion[];
|
||||
};
|
||||
|
||||
export function buildVersionBuckets<TRecord extends ProducerBearingRecord, TFacts>(
|
||||
records: TRecord[],
|
||||
currentPackageVersion: string,
|
||||
summarize: (records: TRecord[]) => { facts: TFacts; opportunityCount: number; observedPatternCount: number },
|
||||
): Record<ProducerVersionGroup, VersionBucketFacts<TFacts>> {
|
||||
const grouped = Object.fromEntries(VERSION_GROUPS.map(group => [group, []])) as Record<ProducerVersionGroup, TRecord[]>;
|
||||
for (const record of records) grouped[producerVersionGroupFor(record, currentPackageVersion)].push(record);
|
||||
return Object.fromEntries(VERSION_GROUPS.map(group => {
|
||||
const bucketRecords = grouped[group];
|
||||
const summary = summarize(bucketRecords);
|
||||
return [group, {
|
||||
group,
|
||||
label: versionGroupLabel(group, currentPackageVersion),
|
||||
opportunityCount: summary.opportunityCount,
|
||||
observedPatternCount: summary.observedPatternCount,
|
||||
producerVersions: producerVersionCounts(bucketRecords),
|
||||
versionAvailability: buildVersionAvailability(bucketRecords),
|
||||
answerabilityLevel: group === "current" && summary.opportunityCount > 0 ? "partial" : "inventory_only",
|
||||
sampleAssessment: sampleAssessmentFor(group, summary.opportunityCount, summary.observedPatternCount, currentPackageVersion),
|
||||
facts: summary.facts,
|
||||
} satisfies VersionBucketFacts<TFacts>];
|
||||
})) as Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
|
||||
}
|
||||
|
||||
export function computeVersionedInference<TFacts>(
|
||||
mechanism: Omit<VersionedMechanismFacts<TFacts>, "inference">,
|
||||
text: { observedPattern: string; patternName: string },
|
||||
): VersionedMechanismInference {
|
||||
const current = mechanism.buckets.current;
|
||||
const previous = mechanism.buckets.previous;
|
||||
const currentFact = `Current version: ${current.observedPatternCount} ${text.observedPattern} in ${current.opportunityCount} ${mechanism.opportunityName}.`;
|
||||
const previousFact = `Previous versions: ${previous.observedPatternCount} ${text.observedPattern} in ${previous.opportunityCount} ${mechanism.opportunityName}.`;
|
||||
const unknownUnversioned = mechanism.buckets.unknown_unversioned;
|
||||
if (!isAssessableCurrentPackageVersion(mechanism.currentPackageVersion) || current.opportunityCount === 0) {
|
||||
return inference("no_current_version_opportunities", "Current package version is unknown or has no events; cannot assess recurrence.");
|
||||
}
|
||||
if (current.observedPatternCount > 0 && previous.observedPatternCount === 0 && unknownUnversioned.observedPatternCount === 0) {
|
||||
return inference("no_previous_pattern_observed", `${currentFact} No previous pattern observed — this is a new pattern, not a recurrence.`);
|
||||
}
|
||||
if (current.observedPatternCount > 0) {
|
||||
if (previous.observedPatternCount > 0) {
|
||||
return inference("pattern_persists_across_versions", `${currentFact} ${previousFact} Current recurrence detected — ${text.patternName} observed in current version. Pattern persists across versions.`);
|
||||
}
|
||||
// Current has signal, previous has none, but unknown/unversioned has signal
|
||||
return inference("current_recurrence_detected", `${currentFact} No known previous-version pattern observed, but unknown/unversioned evidence shows ${unknownUnversioned.observedPatternCount} ${text.observedPattern}. Pattern may persist — version grouping cannot confirm or deny.`);
|
||||
}
|
||||
if (current.opportunityCount < mechanism.sampleThreshold) {
|
||||
return inference("no_current_evidence_sample_small", `${currentFact} ${previousFact} No current evidence observed, but current-version opportunity count is ${current.opportunityCount} (<${mechanism.sampleThreshold}); do not infer absence.`);
|
||||
}
|
||||
return inference("no_current_evidence_observed", `${currentFact} ${previousFact} No recurrence observed with sufficient current-version sample.`);
|
||||
}
|
||||
|
||||
export function hasProducerFields(record: ProducerBearingRecord): boolean {
|
||||
return typeof record.producerName === "string"
|
||||
&& record.producerName.length > 0
|
||||
&& typeof record.producerVersion === "string"
|
||||
&& record.producerVersion.length > 0
|
||||
&& typeof record.instrumentationVersion === "number";
|
||||
}
|
||||
|
||||
export function hasKnownProducerVersion(record: ProducerBearingRecord): boolean {
|
||||
if (typeof record.producerVersion !== "string") return false;
|
||||
const producerVersion = record.producerVersion.trim();
|
||||
return producerVersion.length > 0 && producerVersion !== "unknown";
|
||||
}
|
||||
|
||||
export function producerVersionGroupFor(record: ProducerBearingRecord, currentPackageVersion: string): ProducerVersionGroup {
|
||||
if (!hasKnownProducerVersion(record)) return "unknown_unversioned";
|
||||
const producerVersion = String(record.producerVersion).trim();
|
||||
const currentVersion = currentPackageVersion.trim();
|
||||
if (currentVersion.length > 0 && currentVersion !== "unknown" && producerVersion === currentVersion) return "current";
|
||||
return "previous";
|
||||
}
|
||||
|
||||
export function buildVersionCoverage(records: ProducerBearingRecord[], currentPackageVersion: string): VersionCoverage {
|
||||
const coverage: VersionCoverage = {
|
||||
totalEvents: records.length,
|
||||
currentVersionEvents: 0,
|
||||
previousVersionEvents: 0,
|
||||
unknownVersionEvents: 0,
|
||||
coveragePercent: 0,
|
||||
isTransitional: true,
|
||||
};
|
||||
for (const record of records) {
|
||||
const group = producerVersionGroupFor(record, currentPackageVersion);
|
||||
if (group === "current") coverage.currentVersionEvents += 1;
|
||||
if (group === "previous") coverage.previousVersionEvents += 1;
|
||||
if (group === "unknown_unversioned") coverage.unknownVersionEvents += 1;
|
||||
}
|
||||
coverage.coveragePercent = coverage.totalEvents === 0
|
||||
? 0
|
||||
: Math.round(((coverage.currentVersionEvents + coverage.previousVersionEvents) / coverage.totalEvents) * 1000) / 10;
|
||||
coverage.isTransitional = coverage.coveragePercent < 50;
|
||||
return coverage;
|
||||
}
|
||||
|
||||
function producerVersionCounts(records: ProducerBearingRecord[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const record of records) {
|
||||
if (!hasKnownProducerVersion(record)) continue;
|
||||
const version = String(record.producerVersion).trim();
|
||||
counts[version] = (counts[version] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function versionGroupLabel(group: ProducerVersionGroup, currentPackageVersion: string): string {
|
||||
if (group === "current") return `current version ${currentPackageVersion}`;
|
||||
if (group === "previous") return "previous versions";
|
||||
return "unknown/unversioned";
|
||||
}
|
||||
|
||||
function sampleAssessmentFor(
|
||||
group: ProducerVersionGroup,
|
||||
opportunityCount: number,
|
||||
observedPatternCount: number,
|
||||
currentPackageVersion: string,
|
||||
): VersionSampleAssessment {
|
||||
if (observedPatternCount > 0) return "observed";
|
||||
if (group === "current" && (!isAssessableCurrentPackageVersion(currentPackageVersion) || opportunityCount === 0)) return "no_current_version_opportunities";
|
||||
if (opportunityCount < VERSION_ANALYSIS_SAMPLE_THRESHOLD) return "not_observed_but_sample_small";
|
||||
return "not_observed_with_sufficient_sample";
|
||||
}
|
||||
|
||||
function isAssessableCurrentPackageVersion(currentPackageVersion: string): boolean {
|
||||
const trimmed = currentPackageVersion.trim();
|
||||
return trimmed.length > 0 && trimmed !== "unknown";
|
||||
}
|
||||
|
||||
function inference(status: VersionedMechanismInference["status"], message: string): VersionedMechanismInference {
|
||||
return { status, message, caveat: VERSION_GROUPING_CAVEAT };
|
||||
}
|
||||
|
||||
function buildVersionAvailability(records: ProducerBearingRecord[]): VersionAvailability {
|
||||
const availability: VersionAvailability = {
|
||||
noProducerFields: 0,
|
||||
unknownProducerVersion: 0,
|
||||
emptyProducerVersion: 0,
|
||||
knownProducerVersion: 0,
|
||||
};
|
||||
for (const record of records) {
|
||||
const hasAnyProducerField = typeof record.producerName === "string"
|
||||
|| typeof record.producerVersion === "string"
|
||||
|| typeof record.instrumentationVersion === "number";
|
||||
if (!hasAnyProducerField) {
|
||||
availability.noProducerFields += 1;
|
||||
continue;
|
||||
}
|
||||
if (typeof record.producerVersion !== "string" || record.producerVersion.trim().length === 0) {
|
||||
availability.emptyProducerVersion += 1;
|
||||
continue;
|
||||
}
|
||||
if (record.producerVersion.trim() === "unknown") {
|
||||
availability.unknownProducerVersion += 1;
|
||||
continue;
|
||||
}
|
||||
availability.knownProducerVersion += 1;
|
||||
}
|
||||
return availability;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
+14
-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,10 +277,16 @@ 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> {
|
||||
// Evidence logs are JSONL append streams, not JSON store read-modify-write
|
||||
// documents. Appends intentionally use appendFile so independent evidence
|
||||
// writers do not need to share the JSON store lock path.
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, `${line}\n`, "utf8");
|
||||
@@ -287,6 +297,9 @@ async function safeAppendEvidenceLine(path: string, line: string): Promise<void>
|
||||
}
|
||||
|
||||
async function maybePruneEvidenceLog(path: string): Promise<void> {
|
||||
// Bounded pruning is a separate best-effort compaction of the append-only log.
|
||||
// It rewrites the JSONL file only at configured append intervals and never
|
||||
// routes through updateJSON because evidence is not a single JSON document.
|
||||
const nextCount = (appendCounts.get(path) ?? 0) + 1;
|
||||
appendCounts.set(path, nextCount);
|
||||
if (nextCount % EVIDENCE_LOG_LIMITS.pruneEveryAppendCount !== 0) return;
|
||||
|
||||
+9
-12
@@ -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 };
|
||||
}
|
||||
@@ -388,18 +397,6 @@ function evaluateWorkspaceMemoryCandidate(
|
||||
return { accepted: true, reasons: ["quality_gate_passed"] };
|
||||
}
|
||||
|
||||
function shouldAcceptWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
},
|
||||
options: {
|
||||
fromMemoryTrigger?: boolean;
|
||||
} & WorkspaceMemoryCandidateParseOptions = {},
|
||||
): boolean {
|
||||
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
|
||||
}
|
||||
|
||||
function commandAttemptReason(line: string): string {
|
||||
const normalized = line.replace(/^\s*-\s*/, "").trim();
|
||||
const reinforceMatch = normalized.match(/^REINFORCE\s+(.+)$/i);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
let cachedVersion: string | undefined;
|
||||
|
||||
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
|
||||
const MEMORY_INSTRUMENTATION_VERSION = 3;
|
||||
|
||||
function producerVersion(): string {
|
||||
if (cachedVersion) return cachedVersion;
|
||||
try {
|
||||
const candidates = [
|
||||
join(__dirname, "..", "package.json"),
|
||||
join(__dirname, "..", "..", "package.json"),
|
||||
// resolve from compiled dist/src/ -> repo root
|
||||
];
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(path, "utf8"));
|
||||
cachedVersion = pkg.version as string;
|
||||
break;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
if (!cachedVersion) cachedVersion = "unknown";
|
||||
} catch {
|
||||
cachedVersion = "unknown";
|
||||
}
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
export function producerFields(): { producerName: string; producerVersion: string; instrumentationVersion: number } {
|
||||
return {
|
||||
producerName: MEMORY_PRODUCER_NAME,
|
||||
producerVersion: producerVersion(),
|
||||
instrumentationVersion: MEMORY_INSTRUMENTATION_VERSION,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LongTermType } from "./types.ts";
|
||||
|
||||
// Current workspace-memory display/render order. This is intentionally a narrow
|
||||
// shared constant, not a broader memory-kind policy registry.
|
||||
export const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermType[];
|
||||
|
||||
export function emptyMemoryTypeGroups<T>(): Record<LongTermType, T[]> {
|
||||
return {
|
||||
feedback: [],
|
||||
project: [],
|
||||
decision: [],
|
||||
reference: [],
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
import { MEMORY_TYPE_ORDER, emptyMemoryTypeGroups } from "./memory-kind-policy.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;
|
||||
|
||||
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 emptyMemoryTypeGroups<MemoryListItem>();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
+148
-49
@@ -3,21 +3,24 @@
|
||||
*
|
||||
* Architecture:
|
||||
* - Layer 1: Stable Workspace Memory (frozen per session cache epoch, refreshed at compaction)
|
||||
* - Layer 2: Hot Session State (active files, open errors, recent decisions, pending memories)
|
||||
* - Layer 2: Frozen Hot Session State (active files, open errors, recent decisions, pending memories)
|
||||
* - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction)
|
||||
*
|
||||
* Cache Epoch Model:
|
||||
* - Each session creates a frozen workspace memory snapshot on first transform.
|
||||
* - Normal turns reuse the exact rendered string (system[1] remains stable).
|
||||
* - Compaction starts a new cache epoch: pending memories are promoted, the cache is cleared,
|
||||
* and the next transform re-renders workspace memory.
|
||||
* - Each session creates frozen workspace memory and hot session snapshots on first transform.
|
||||
* - Normal turns reuse the exact rendered strings (pre-history system prompts remain stable).
|
||||
* - Normal tool/user churn updates session storage but does not mutate pre-history prompts
|
||||
* until compaction, session restart, or process restart starts a new epoch; conversation
|
||||
* and tool history are the source of truth for newer events after epoch start.
|
||||
* - Compaction starts a new cache epoch: pending memories are promoted, caches are cleared,
|
||||
* and the next transform re-renders workspace memory and hot session state.
|
||||
* - Explicit memory ("remember X") goes to SessionState.pendingMemories + durable journal,
|
||||
* visible in ephemeral system[2+] for the current epoch, promoted to system[1] after compaction.
|
||||
* visible in the hot snapshot only if processed before epoch creation, promoted after compaction.
|
||||
*
|
||||
* This plugin:
|
||||
* - Caches frozen workspace memory per sessionID
|
||||
* - Caches frozen workspace memory and hot session state per sessionID epoch
|
||||
* - Processes explicit memory from latest user text once per message id
|
||||
* - Injects frozen workspace memory and dynamic hot session state into system prompt
|
||||
* - Injects frozen workspace memory and frozen hot session state into system prompt
|
||||
* - Updates session state after tool execution
|
||||
* - Augments compaction context with numbered memory refs, todos, and instruction
|
||||
* - Parses compaction summaries for memory candidates and merges them
|
||||
@@ -37,14 +40,13 @@ import {
|
||||
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import {
|
||||
loadWorkspaceMemory,
|
||||
updateWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
accountWorkspaceMemoryRender,
|
||||
accountWorkspaceMemoryCompactionRefs,
|
||||
workspaceMemoryExactKey,
|
||||
workspaceMemoryIdentityKey,
|
||||
} from "./workspace-memory.ts";
|
||||
import { reinforceMemory } from "./retention.ts";
|
||||
import { REINFORCEMENT_MAX_COUNT, tryReinforceMemory, type ReinforcementDecision } from "./retention.ts";
|
||||
import {
|
||||
appendPendingMemories,
|
||||
clearPendingMemories,
|
||||
@@ -259,6 +261,18 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
|
||||
renderedPrompt: string;
|
||||
loadedAt: number;
|
||||
lastAccessedAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Cache for frozen hot session state per session epoch.
|
||||
// Lifecycle is unified with frozenWorkspaceMemoryCache; do not clear independently.
|
||||
const frozenHotSessionStateCache = new Map<
|
||||
string,
|
||||
{
|
||||
renderedPrompt: string;
|
||||
loadedAt: number;
|
||||
lastAccessedAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -311,6 +325,21 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
};
|
||||
}
|
||||
|
||||
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
|
||||
return {
|
||||
attemptedAtMs: decision.attemptedAt,
|
||||
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
|
||||
...(decision.lastReinforcedAt !== undefined ? {
|
||||
lastReinforcedAtMs: decision.lastReinforcedAt,
|
||||
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
|
||||
} : {}),
|
||||
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
|
||||
requiredElapsedMs: decision.requiredElapsedMs,
|
||||
sameSession: decision.sameSession,
|
||||
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function replacementMemoryId(): string {
|
||||
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -429,17 +458,33 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
|
||||
const { refSnapshot, target, targetIndex } = resolution;
|
||||
if (command.kind === "REINFORCE") {
|
||||
const reinforced = reinforceMemory(target, sessionID, now);
|
||||
if (reinforced === target) {
|
||||
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked"], {
|
||||
const decision = tryReinforceMemory(target, sessionID, now);
|
||||
if (decision.outcome === "blocked") {
|
||||
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
blockReason: decision.blockReason,
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
reinforcementCount: decision.reinforcementCount,
|
||||
maxReinforcementCount: decision.maxReinforcementCount,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const reinforced = decision.memory;
|
||||
workspaceMemory.entries[targetIndex] = reinforced;
|
||||
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
|
||||
const reasonCodes = ["numbered_ref_reinforce", "reinforcement_window_allowed"];
|
||||
if (decision.reinforcementMode === "refresh_only") {
|
||||
reasonCodes.push("reinforcement_saturation_refresh");
|
||||
}
|
||||
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", reasonCodes, {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
|
||||
reinforcementMode: decision.reinforcementMode,
|
||||
...reinforcementDecisionTimingDetails(decision),
|
||||
previousReinforcementCount: decision.previousReinforcementCount,
|
||||
newReinforcementCount: decision.newReinforcementCount,
|
||||
reinforcementCount: decision.newReinforcementCount,
|
||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
@@ -509,18 +554,39 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
})));
|
||||
}
|
||||
|
||||
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||
function clearFrozenPromptEpoch(sessionID: string): void {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
frozenHotSessionStateCache.delete(sessionID);
|
||||
}
|
||||
|
||||
function pruneFrozenPromptEpochCaches(): void {
|
||||
const lastAccessedAtBySession = new Map<string, number>();
|
||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
}
|
||||
lastAccessedAtBySession.set(
|
||||
sessionID,
|
||||
Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt),
|
||||
);
|
||||
}
|
||||
for (const [sessionID, cached] of frozenHotSessionStateCache) {
|
||||
lastAccessedAtBySession.set(
|
||||
sessionID,
|
||||
Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt),
|
||||
);
|
||||
}
|
||||
|
||||
while (frozenWorkspaceMemoryCache.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
|
||||
const oldest = [...frozenWorkspaceMemoryCache.entries()]
|
||||
.sort((a, b) => a[1].loadedAt - b[1].loadedAt)[0]?.[0];
|
||||
if (!oldest) break;
|
||||
frozenWorkspaceMemoryCache.delete(oldest);
|
||||
const sorted = [...lastAccessedAtBySession.entries()].sort((a, b) => a[1] - b[1]);
|
||||
while (lastAccessedAtBySession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
|
||||
const [oldestSessionID] = sorted.shift() ?? [];
|
||||
if (!oldestSessionID) break;
|
||||
lastAccessedAtBySession.delete(oldestSessionID);
|
||||
clearFrozenPromptEpoch(oldestSessionID);
|
||||
}
|
||||
|
||||
for (const sessionID of frozenWorkspaceMemoryCache.keys()) {
|
||||
if (!lastAccessedAtBySession.has(sessionID)) frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
}
|
||||
for (const sessionID of frozenHotSessionStateCache.keys()) {
|
||||
if (!lastAccessedAtBySession.has(sessionID)) frozenHotSessionStateCache.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,11 +728,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 });
|
||||
@@ -735,7 +802,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
});
|
||||
return state;
|
||||
});
|
||||
clearFrozenWorkspaceMemoryCache(sessionID);
|
||||
clearFrozenPromptEpoch(sessionID);
|
||||
}
|
||||
|
||||
if (accounting.clearableKeys.size > 0) {
|
||||
@@ -780,13 +847,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
renderedPrompt: string;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
const cached = frozenWorkspaceMemoryCache.get(sessionID);
|
||||
|
||||
// Cache is valid for the current session cache epoch.
|
||||
// It is intentionally invalidated after compaction so promoted memories
|
||||
// become visible in the next compacted context (new epoch starts).
|
||||
if (cached) {
|
||||
cached.lastAccessedAt = now;
|
||||
return { store: cached.store, renderedPrompt: cached.renderedPrompt };
|
||||
}
|
||||
|
||||
@@ -797,16 +864,42 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now, lastAccessedAt: now });
|
||||
pruneFrozenPromptEpochCaches();
|
||||
return { store, renderedPrompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear frozen workspace memory cache (e.g., after compaction).
|
||||
* Get frozen hot session state snapshot for a session.
|
||||
* Loads and renders from disk once per prompt epoch, then reuses the exact rendered string.
|
||||
*/
|
||||
function clearFrozenWorkspaceMemoryCache(sessionID: string): void {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
async function getFrozenHotSessionStateSnapshot(
|
||||
root: string,
|
||||
sessionID: string,
|
||||
): Promise<{ renderedPrompt: string }> {
|
||||
const now = Date.now();
|
||||
const cached = frozenHotSessionStateCache.get(sessionID);
|
||||
if (cached) {
|
||||
cached.lastAccessedAt = now;
|
||||
return { renderedPrompt: cached.renderedPrompt };
|
||||
}
|
||||
|
||||
const sessionState = await loadSessionState(root, sessionID);
|
||||
const renderedPrompt = renderHotSessionState(sessionState, root);
|
||||
frozenHotSessionStateCache.set(sessionID, { renderedPrompt, loadedAt: now, lastAccessedAt: now });
|
||||
pruneFrozenPromptEpochCaches();
|
||||
return { renderedPrompt };
|
||||
}
|
||||
|
||||
async function promoteUnownedBacklogForEpochSnapshot(sessionID: string): Promise<void> {
|
||||
if (frozenWorkspaceMemoryCache.has(sessionID) || frozenHotSessionStateCache.has(sessionID)) return;
|
||||
if (!await hasPendingJournalEntries(directory)) return;
|
||||
|
||||
try {
|
||||
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
|
||||
} catch (error) {
|
||||
await warnMemoryHook("chat.system.transform.promote_unowned", error, directory);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIDFromEventProperties(properties: unknown): string | undefined {
|
||||
@@ -822,13 +915,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
}
|
||||
|
||||
return {
|
||||
// Inject workspace memory and hot session state into system prompt
|
||||
// Inject frozen workspace memory and frozen hot session state into system prompt
|
||||
"experimental.chat.system.transform": async (hookInput, output) => {
|
||||
const { sessionID } = hookInput;
|
||||
if (!sessionID) return;
|
||||
|
||||
try {
|
||||
pruneFrozenWorkspaceMemoryCache();
|
||||
pruneFrozenPromptEpochCaches();
|
||||
pruneProcessedUserMessagesCache();
|
||||
|
||||
// Sub-agents are short-lived - skip memory system
|
||||
@@ -838,28 +931,33 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// sub-agent guard so child sessions never append to the parent journal.
|
||||
await processLatestUserMessage(sessionID);
|
||||
|
||||
// Before first snapshot in this session, promote durable unowned backlog from
|
||||
// prior sessions. Current-turn owned explicit memory remains pending and only
|
||||
// appears in hot state for this transform.
|
||||
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
||||
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
|
||||
// Before first snapshot in this session, best-effort promote durable
|
||||
// unowned backlog from prior sessions. Current-turn owned explicit memory
|
||||
// remains pending and appears in hot state only if the epoch snapshot is new.
|
||||
await promoteUnownedBacklogForEpochSnapshot(sessionID);
|
||||
|
||||
let workspaceSnapshot: Awaited<ReturnType<typeof getFrozenWorkspaceMemorySnapshot>> | undefined;
|
||||
try {
|
||||
workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
} catch (error) {
|
||||
await warnMemoryHook("chat.system.transform.workspace_snapshot", error, directory);
|
||||
}
|
||||
|
||||
// Get frozen workspace memory snapshot (loaded and rendered once per session)
|
||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
|
||||
// Get current hot session state
|
||||
const sessionState = await loadSessionState(directory, sessionID);
|
||||
let hotSnapshot: Awaited<ReturnType<typeof getFrozenHotSessionStateSnapshot>> | undefined;
|
||||
try {
|
||||
hotSnapshot = await getFrozenHotSessionStateSnapshot(directory, sessionID);
|
||||
} catch (error) {
|
||||
await warnMemoryHook("chat.system.transform.hot_snapshot", error, directory);
|
||||
}
|
||||
|
||||
// Inject frozen workspace memory snapshot
|
||||
if (workspaceSnapshot.renderedPrompt) {
|
||||
if (workspaceSnapshot?.renderedPrompt) {
|
||||
output.system.push(workspaceSnapshot.renderedPrompt);
|
||||
}
|
||||
|
||||
// Render and inject hot session state
|
||||
const hotPrompt = renderHotSessionState(sessionState, directory);
|
||||
if (hotPrompt) {
|
||||
output.system.push(hotPrompt);
|
||||
// Inject frozen hot session state snapshot
|
||||
if (hotSnapshot?.renderedPrompt) {
|
||||
output.system.push(hotSnapshot.renderedPrompt);
|
||||
}
|
||||
} catch (error) {
|
||||
await warnMemoryHook("chat.system.transform", error, directory);
|
||||
@@ -1029,6 +1127,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
} finally {
|
||||
await clearCompactionMemoryRefs(sessionID);
|
||||
clearFrozenPromptEpoch(sessionID);
|
||||
}
|
||||
} catch (error) {
|
||||
// Keep pending memories in session/journal for retry on next event/session.
|
||||
@@ -1046,7 +1145,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
|
||||
promoted = true;
|
||||
if (promoted) {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
clearFrozenPromptEpoch(sessionID);
|
||||
processedUserMessages.delete(sessionID);
|
||||
sessionParentCache.delete(sessionID);
|
||||
}
|
||||
|
||||
+97
-29
@@ -1,13 +1,53 @@
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
|
||||
export type ReinforcementBlockReason =
|
||||
| "min_elapsed_window"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "same_session"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "same_utc_day"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "min_interval"
|
||||
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
|
||||
| "max_count";
|
||||
|
||||
export type ReinforcementMode = "increment" | "refresh_only";
|
||||
|
||||
type ReinforcementDecisionMetadata = {
|
||||
attemptedAt: number;
|
||||
lastReinforcedAt?: number;
|
||||
elapsedMs?: number;
|
||||
requiredElapsedMs: number;
|
||||
sameSession: boolean;
|
||||
legacyMissingTimestamp?: boolean;
|
||||
};
|
||||
|
||||
export type ReinforcementDecision =
|
||||
| ({
|
||||
outcome: "reinforced";
|
||||
memory: LongTermMemoryEntry;
|
||||
previousReinforcementCount: number;
|
||||
newReinforcementCount: number;
|
||||
reinforcementMode: ReinforcementMode;
|
||||
} & ReinforcementDecisionMetadata)
|
||||
| ({
|
||||
outcome: "blocked";
|
||||
memory: LongTermMemoryEntry;
|
||||
blockReason: ReinforcementBlockReason;
|
||||
reinforcementCount: number;
|
||||
maxReinforcementCount: number;
|
||||
} & ReinforcementDecisionMetadata);
|
||||
|
||||
// Retention decay model constants (v1.5)
|
||||
export const BASE_HALF_LIFE_DAYS = 45;
|
||||
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
||||
export const REINFORCEMENT_MAX_COUNT = 6;
|
||||
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
export const REINFORCEMENT_MIN_ELAPSED_MS = 7 * DAY_MS;
|
||||
/** @deprecated Compatibility constant; new policy uses REINFORCEMENT_MIN_ELAPSED_MS. */
|
||||
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000;
|
||||
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
||||
export const DORMANT_DECAY_MULTIPLIER = 0.25;
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const TYPE_FACTOR = {
|
||||
reference: 1.0,
|
||||
@@ -108,41 +148,69 @@ export function calculateEffectiveAgeDays(
|
||||
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
|
||||
}
|
||||
|
||||
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
|
||||
const d1 = new Date(ts1);
|
||||
const d2 = new Date(ts2);
|
||||
return d1.getUTCFullYear() === d2.getUTCFullYear()
|
||||
&& d1.getUTCMonth() === d2.getUTCMonth()
|
||||
&& d1.getUTCDate() === d2.getUTCDate();
|
||||
}
|
||||
|
||||
export function reinforceMemory(
|
||||
export function tryReinforceMemory(
|
||||
memory: LongTermMemoryEntry,
|
||||
sessionId: string,
|
||||
now: number,
|
||||
): LongTermMemoryEntry {
|
||||
if (memory.lastReinforcedSessionID === sessionId) {
|
||||
return memory;
|
||||
): ReinforcementDecision {
|
||||
const count = memory.reinforcementCount ?? 0;
|
||||
const lastAt = validLastReinforcedAt(memory.lastReinforcedAt);
|
||||
const lastSession = memory.lastReinforcedSessionID;
|
||||
const sameSession = lastSession === sessionId;
|
||||
const legacyMissingTimestamp = count > 0 && lastAt === undefined;
|
||||
const metadata: ReinforcementDecisionMetadata = {
|
||||
attemptedAt: now,
|
||||
...(lastAt !== undefined ? {
|
||||
lastReinforcedAt: lastAt,
|
||||
elapsedMs: now - lastAt,
|
||||
} : {}),
|
||||
requiredElapsedMs: REINFORCEMENT_MIN_ELAPSED_MS,
|
||||
sameSession,
|
||||
...(legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
|
||||
};
|
||||
|
||||
if (lastAt !== undefined && now - lastAt < REINFORCEMENT_MIN_ELAPSED_MS) {
|
||||
return blockedDecision(memory, "min_elapsed_window", count, metadata);
|
||||
}
|
||||
|
||||
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
|
||||
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
return {
|
||||
const reinforcementMode: ReinforcementMode = count >= REINFORCEMENT_MAX_COUNT
|
||||
? "refresh_only"
|
||||
: "increment";
|
||||
const newReinforcementCount = reinforcementMode === "refresh_only" ? count : count + 1;
|
||||
const reinforced: LongTermMemoryEntry = {
|
||||
...memory,
|
||||
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
|
||||
reinforcementCount: newReinforcementCount,
|
||||
lastReinforcedAt: now,
|
||||
lastReinforcedSessionID: sessionId,
|
||||
retentionClock: now,
|
||||
};
|
||||
return {
|
||||
outcome: "reinforced",
|
||||
memory: reinforced,
|
||||
previousReinforcementCount: count,
|
||||
newReinforcementCount,
|
||||
reinforcementMode,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function validLastReinforcedAt(value: unknown): number | undefined {
|
||||
if (typeof value !== "number") return undefined;
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function blockedDecision(
|
||||
memory: LongTermMemoryEntry,
|
||||
blockReason: ReinforcementBlockReason,
|
||||
reinforcementCount: number,
|
||||
metadata: ReinforcementDecisionMetadata,
|
||||
): ReinforcementDecision {
|
||||
return {
|
||||
outcome: "blocked",
|
||||
memory,
|
||||
blockReason,
|
||||
reinforcementCount,
|
||||
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ type HotStateRenderSection = {
|
||||
items: HotStateRenderItem[];
|
||||
};
|
||||
|
||||
const HOT_STATE_PREFIX = "Hot session state (current session):";
|
||||
const HOT_STATE_PREFIX = "Hot session state snapshot (epoch start; conversation history may be newer):";
|
||||
|
||||
export function accountHotSessionStateRender(state: SessionState, workspaceRoot: string): HotSessionStateRenderAccounting {
|
||||
const maxRenderedChars = HOT_STATE_LIMITS.maxRenderedChars;
|
||||
|
||||
@@ -164,6 +164,9 @@ async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
|
||||
// Full-state overwrite primitive: callers must already own the complete next
|
||||
// JSON document. Do not use this for read-modify-write updates that must
|
||||
// preserve concurrent changes; use updateJSON for that contract instead.
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
|
||||
@@ -175,6 +178,9 @@ export async function updateJSON<T>(
|
||||
fallback: () => T,
|
||||
updater: (current: T) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
// Locked read-modify-write path: serializes in-process callers and uses a
|
||||
// filesystem lock for cross-process callers before reading, updating, and
|
||||
// atomically replacing the JSON document.
|
||||
const previous = fileLocks.get(path) ?? Promise.resolve();
|
||||
let release: () => void = () => {};
|
||||
const currentLock = new Promise<void>(resolve => {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
formatMemoryHelp,
|
||||
formatMemoryList,
|
||||
getMemoryList,
|
||||
renderMemoryCommand,
|
||||
type MemoryVisibilityCommand,
|
||||
} from "./memory-visibility.ts";
|
||||
import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.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");
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
+101
-23
@@ -7,12 +7,15 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import {
|
||||
REINFORCEMENT_MAX_COUNT,
|
||||
RETENTION_TYPE_MAX,
|
||||
calculateRetentionStrength,
|
||||
reinforceMemory,
|
||||
tryReinforceMemory,
|
||||
type ReinforcementDecision,
|
||||
} from "./retention.ts";
|
||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
import { appendEvidenceEvents } from "./evidence-log.ts";
|
||||
import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
const MIN_ENVELOPE_LENGTH = 80;
|
||||
@@ -528,7 +531,7 @@ function extractConcreteIdentityKey(text: string): string | null {
|
||||
if (pathIdentity) return pathIdentity;
|
||||
}
|
||||
|
||||
const pathMatch = text.match(/(?:\/[^ | ||||