Compare commits

..

50 Commits

Author SHA1 Message Date
Ralph Chang 0ed61d1ba2 Merge branch 'feat/deprecate-safety-critical' 2026-04-30 19:54:50 +08:00
Ralph Chang 2dbfb50080 chore(release): prepare v1.5.1 2026-04-30 19:54:24 +08:00
Ralph Chang d569297c30 fix(retention): add UTC calendar-day diversity gate to reinforceMemory
Implement OQ-2 decision: allow at most one reinforcement per memory
identity per UTC calendar day. Same-day reinforcement is blocked
regardless of session or interval. This prevents repetitive-task
gaming where a daily recurring task could reach MAX_COUNT=6 in hours.

Guard order: same-session → calendar-day → 1-hour → max-count
(existing guards kept as defense-in-depth)

1 hour guard is redundant within same day but preserved for
sub-hour edge cases.
2026-04-30 18:38:29 +08:00
Ralph Chang 4f1c0348b4 feat(explainability): add diagnostics JSON, per-memory explain, lifecycle trace
Phase 4 Tasks 4.1-4.3:
- memory-diag health --json: machine-readable MemoryDiagJSON output
- memory-diag explain: per-memory render status with strength, reasons,
  evidence event IDs
- memory-diag trace --memory <id>: lifecycle history from evidence events
  and relations (superseded_by, reinforced_by)
- MemoryRenderStatus type with 9 statuses
- All diagnostics are read-only, no storage mutations
- Privacy-safe: redacted text previews, no raw secrets
- 270 tests pass, typecheck pass
2026-04-30 18:06:28 +08:00
Ralph Chang bc0847e3ed feat(evidence): wire evidence events into extraction, promotion, reinforcement, render, storage, and hook lifecycle
Phase 3 Tasks 3.2-3.6:
- Extraction evidence: accepted/rejected/explicit_detected/explicit_ignored
- Promotion evidence with relation edges (superseded/superseded_by, absorbed/retained)
- Reinforcement evidence with reinforced/reinforced_by relations
- Render accounting helper with render_selected/render_omitted evidence
- Storage evidence: corrupt_json_quarantined, stale_lock_recovered, lock_timeout
- Hook failure evidence in plugin
- All evidence failures swallowed, never throw into memory behavior
- Privacy-safe textPreview (redacted + truncated)
- 266 tests pass, typecheck pass
2026-04-30 17:54:13 +08:00
Ralph Chang 6a81fc384c feat(evidence): add evidence infrastructure - types, append, query, retention
Phase 3 Task 3.1:
- Create src/evidence-log.ts with EvidenceEventType, EvidencePhase,
  EvidenceOutcome, MemoryEvidenceRef, EvidenceRelation, EvidenceEventV1,
  EvidenceEventInput types
- Add appendEvidenceEvent/appendEvidenceEvents with safe write, privacy
  hashing (SHA-256 truncated), textPreview redaction, bounded retention
- Add queryEvidenceEvents, summarizeMemoryEvidence, traceMemoryLifecycle
- Add workspaceEvidenceLogPath to src/paths.ts
- Add 8 evidence-log tests: round-trip, privacy, query, resilience, retention
- Relations limited to wiring roles only (no kind/derived_from/validates)
- 253 tests pass
2026-04-30 17:33:40 +08:00
Ralph Chang ed4590ca18 refactor(retention): extract retention module from workspace-memory
Move retention constants and math to a focused src/retention.ts module:
- All half-life, reinforcement, dormancy constants
- TYPE_FACTOR, SOURCE_FACTOR, USER_IMPORTANCE_FACTOR
- RETENTION_TYPE_MAX (renamed from TYPE_MAX)
- calculateInitialStrength, calculateEffectiveHalfLife,
  calculateRetentionStrength, calculateDormantDays,
  calculateEffectiveAgeDays, reinforceMemory

No behavior changes. retention.ts imports only types from types.ts.
Workspace-memory.ts still owns storage, consolidation, and rendering.
2026-04-30 17:28:31 +08:00
Ralph Chang 09cc4a2ffb feat(deprecation): remove safetyCritical retention multiplier and type-cap bypass
- Remove SAFETY_CRITICAL_FACTOR = 6.0 from workspace-memory.ts
- Remove safetyFactor from calculateInitialStrength() - all memories now
  fade according to the same rules
- Remove safetyCritical bypass from applyTypeMaxCaps() - safetyCritical
  entries compete normally under TYPE_MAX caps
- Preserve safetyCritical?: boolean in LongTermMemoryEntry type for
  backward compatibility (no producer sets it to true)
- Update memory-diag to show deprecation warning instead of capacity alert
- Update tests: add backward-compatibility fixture test, deprecation
  strength test, normal cap competition test
- Update docs/architecture.md, RELEASE_NOTES.md, CHANGELOG.md,
  docs/configuration.md

Phase 1.5 complete: safetyCritical is now a deprecated field with no
active behavior. Safety rules belong in user-controlled agent.md files.
2026-04-30 17:23:01 +08:00
Ralph Chang c0ebd84d7e fix(security): harden hooks, quarantine corrupt JSON, test locks, fix promotion dedupe
- Wrap hooks with try/catch to prevent OpenCode disruption
- Add warnMemoryHook() for safe error logging
- Quarantine corrupt JSON files before fallback
- Add cross-process lock safety tests
- Fix pending promotion same-batch dedupe
- Update docs/architecture.md with lock semantics
- 242 tests passing
2026-04-30 11:52:01 +08:00
Ralph Chang 20a6cfe1a6 chore(release): prepare v1.5.0 2026-04-29 16:56:47 +08:00
Ralph Chang 36b78ea91c feat(memory): add retention model test gaps and health diagnostics
Wave 1 - P0 Test Gaps:
- Add hard stale prune removed regression test
- Add dormant overlap tests (entry created during dormancy)
- Add invalid timestamp NaN protection test
- Add reinforcement ordering test with reference type
- Add dedupe same-session/under-1hr guard tests
- Fix NaN handling with Number.isFinite check

Wave 2 - Helper Functions:
- Add timestampMs() for safe timestamp conversion
- Add isSafetyCriticalForDiag() aligned with runtime

Wave 3 - Health Output Format:
- Fix top rendered candidates sorted by strength (not text length)
- Add stored vs rendered counts breakdown
- Add type caps and global cap overflow display
- Track globalCapped array explicitly
- Add dormant status section

Wave 4 - Monitoring Metrics:
- Add high_importance_ratio (alert > 30%)
- Add safety_critical_count (alert > 5)
- Add max_reinforced_count (alert > 10% active)

Wave 5 - Integration Fixture:
- Add 34-entry over-cap test
- Add mixed retention regression fixture
- Test TYPE_MAX caps, safety-critical exemption, reinforcement ordering

Tests: 224 → 237
2026-04-29 15:26:44 +08:00
Ralph Chang 406c160c9f fix(memory): correct dormant formula, remove hard prune, integrate reinforcement
P0.1 - Fix dormant effective age formula:
- Use overlap logic: only apply dormancy to entry's lifetime
- Formula: activeDays + dormantOverlapDays * 0.25
- calculateDormantDays now returns total days (not excess past grace)
- Test: 28 dormant days → 17.5 effective days

P0.2 - Remove hard stale pruning:
- Remove isPrunableByAge from enforcement
- Remove rejected_stale from accounting reasons
- Elimination now by cap competition only

P0.3 - Integrate reinforcement:
- Call reinforceMemory in dedupe absorption path
- Call reinforceMemory in promotion duplicate path
- Update retentionClock on reinforcement

A1 - Retention clock reset on reinforcement

A4 - Fix tests to encode correct formula
2026-04-29 14:55:25 +08:00
Ralph Chang 968aedd5c5 feat(memory): add dormant tracking and reinforcement mechanism
Wave 2c - Dormant workspace tracking:
- Add lastActivityAt to WorkspaceMemoryStore
- Implement calculateDormantDays with 14-day grace period
- Wire dormant days into retention-strength calculation

Wave 3 - Reinforcement:
- Add lastReinforcedSessionID to LongTermMemoryEntry
- Implement reinforceMemory with guards (same-session, 1hr interval, max 6)
- Set retentionClock on memory creation in extractors.ts and plugin.ts

Tests: 219 → 222, all pass
2026-04-29 14:32:39 +08:00
Ralph Chang d4053b2d35 feat(memory): implement retention decay model with strength-based ordering
- Add retention model constants (45-day half-life, 6.0 safety factor)
- Add TYPE_MAX caps (feedback:10, decision:10, project:8, reference:6)
- Add strength calculation: initialStrength × 2^(-age/halfLife)
- Integrate strength-based sorting into enforceLongTermLimits
- Safety-critical entries bypass type caps
- Add fields: retentionClock, reinforcementCount, userImportance, safetyCritical
2026-04-29 14:18:51 +08:00
Ralph Chang 85e11be2b9 feat: add maintainer diagnostics for memory quality calibration
- health: inspect workspace memory store, pending journal, offline quality checks
- rejections: review extraction rejection log with origin inference
- audit: review migration logs with risky supersede heuristic
- Maintainer-only, offline, no telemetry, no API calls
2026-04-29 10:25:35 +08:00
sdwolf4103 5b0083efae Merge pull request #4 from sdwolf4103/feat/memory-quality-cleanup
Release v1.4.0 memory quality cleanup
2026-04-28 14:53:12 +08:00
Ralph Chang 60b9ca75c8 fix(memory): isolate test workspace cleanup 2026-04-28 14:50:30 +08:00
Ralph Chang 8da39c7a9d fix(memory): address quality cleanup audit findings 2026-04-28 14:29:28 +08:00
Ralph Chang e8c95a62ec docs(memory): document conservative quality cleanup migration 2026-04-28 14:19:18 +08:00
Ralph Chang 56d7ef9a68 test(memory): add real workspace quality cleanup regression fixture 2026-04-28 14:17:43 +08:00
Ralph Chang 7427221640 feat(memory): add local quality cleanup audit logs 2026-04-28 14:17:17 +08:00
Ralph Chang 9991c95ff6 fix(memory): make quality cleanup migration conservative 2026-04-28 14:15:34 +08:00
Ralph Chang f7139f0844 chore: prepare v1.4.0 release 2026-04-28 13:37:14 +08:00
Ralph Chang 465edfabf1 fix: unify all memory quality rules in single module 2026-04-28 13:34:33 +08:00
Ralph Chang 6a80f4b047 fix: auto-supersede low-quality compaction memories 2026-04-28 13:29:28 +08:00
Ralph Chang b21347c12b fix: tighten compaction memory candidate prompt 2026-04-28 13:24:43 +08:00
Ralph Chang ffb0477251 fix: unify workspace memory quality gate 2026-04-28 13:21:15 +08:00
Ralph Chang a1b9bf4fbc chore: prepare v1.3.3 release 2026-04-28 13:06:14 +08:00
Ralph Chang a762e863d1 fix: owner scope in global unowned promotion
Problem: clearPendingMemories() and recordPromotionRejections() would
incorrectly clear or mutate owned entries during global unowned promotion.

Fixes:
1. clearPendingMemories() now respects owner/unowned scope:
   - global clearUnowned only clears unowned same-key entries
   - owned same-key entries are preserved
   - explicit global clear-all-by-key fallback still works

2. recordPromotionRejections() now has includeUnownedOnly option:
   - global unowned rejection only increments/exhausts unowned entries
   - owned same-key entries are preserved

3. Added regression tests:
   - global unowned clear keeps owned same-key entries
   - global unowned rejection only exhausts unowned same-key entries

Tests: 182 pass, 0 fail
2026-04-28 12:27:46 +08:00
Ralph Chang 222bae28de fix: cross-process lock stale judgment and heartbeat
Problem: CI test "updateJSON serializes writes across separate node processes"
was failing with expect 100 but got 89/97. The root cause was isLockStale()
being too aggressive - it could mistakenly delete locks held by other processes.

Fixes:
1. isLockStale() now uses mtime only - fresh locks are never stale
2. Added heartbeat mechanism during lock hold to support long updaters
3. Removed PID check that was unreliable in CI/containers
4. Fixed ENOENT race when lock is released between EEXIST and stat

Tests: 180 pass, 0 fail
2026-04-28 12:24:56 +08:00
Ralph Chang 53aa6d3c31 feat: implement Plan 1 - Critical Stability fixes
Wave 1: Storage and Journal Safety
- Add frozen cache TTL (1h) and size bounds (50 sessions)
- Add pending journal source-aware retention (compaction-only TTL)
- Add inter-process file lock with stale recovery
- Move processLatestUserMessage to first transform (after isSubAgent guard)

Wave 2: Promotion Ownership and Bounded Rejection
- Add pendingOwnerSessionID/pendingMessageID metadata
- Add owner-aware pending journal clearing
- Add explicit/manual bounded retry (max 3 attempts)
- Fix session.deleted cleanup idempotency

Wave 3: Normalize, Security, and Cache Hardening
- Fix load-time write loop (only write on security/migration change)
- Add deterministic sort tie-breaker (createdAt -> id)
- Add Bearer token redaction
- Add processed message cache bounds
- Remove priorityWithFreshness dead code

Tests: 180 pass, 0 fail
2026-04-28 11:59:29 +08:00
Ralph Chang 034dfe8d91 fix: run compatibility CI on Node 24 2026-04-27 22:13:23 +08:00
Ralph Chang e3ebdbfe9f fix: support CI installs without lockfile 2026-04-27 22:04:11 +08:00
Ralph Chang fdebd304f6 chore: prepare v1.3.1 release 2026-04-27 22:00:04 +08:00
Ralph Chang 77d60abf5f refactor: make memory dedupe repo-agnostic 2026-04-27 21:19:42 +08:00
Ralph Chang 560f63f96b docs: note PR 3 security hardening 2026-04-27 20:22:26 +08:00
Ralph Chang 11361abc91 test: cover security hardening edge cases 2026-04-27 20:22:09 +08:00
Ralph Chang e071095422 merge: integrate PR #3 security hardening 2026-04-27 20:14:08 +08:00
Ralph Chang 909d6c7767 docs: document concise compatibility limitations 2026-04-27 19:57:21 +08:00
Ralph Chang c697f63c67 fix: cap and prune pending memory journal 2026-04-27 18:54:44 +08:00
Ralph Chang 25b673fbb7 test: add opencode plugin compatibility checks 2026-04-27 18:54:14 +08:00
Steven Choo acaa829df4 feat: implement indirect prompt injection protection and expanded secret redaction 2026-04-27 12:42:20 +02:00
Ralph Chang fe6ce36e09 docs: prepare v1.3.0 release notes 2026-04-27 17:06:43 +08:00
Ralph Chang 3cc6dff7ae feat: add consolidation accounting for workspace memory promotion
P0 implementation with four waves:

Wave 1: Dedup with accounting
- Add dedupeLongTermEntriesWithAccounting()
- Classify exact duplicate, identity duplicate, topic duplicate

Wave 2: Normalization with accounting
- Add normalizeWorkspaceMemoryWithAccounting()
- Chain redaction → migration → enforceLongTermLimitsWithAccounting

Wave 3: Promotion accounting integration
- Update accountPendingPromotions() to use new accounting API
- Add supersededKeys to classification
- Distinguish promoted / absorbed / superseded / rejected

Wave 4: Integration tests
- End-to-end tests covering full pipeline

Bug fixes:
- Fix active vs superseded boundary (superseded entries no longer block promotion)
- Remove unused rejected_duplicate_lower_quality type
- Defer pending journal safety cap (TODO added)

Tests: 135 passing (up from 115)
2026-04-27 16:45:55 +08:00
Ralph Chang 1c748f3ee2 chore: ignore superpowers plans and update architecture doc
- Add docs/superpowers/plans/ to .gitignore
- Remove tracked plan files from git
- Update docs/architecture.md:
  - Change primary extraction format from XML to 'Memory candidates:'
  - Mark XML format as legacy/deprecated
  - Fix hot session state injection example
2026-04-27 14:53:07 +08:00
Ralph Chang ca68b7f55c feat: sharpen compaction memory extraction prompt
Wave 3 of memory quality optimization plan.

- Add good memory examples in buildCompactionPrompt()
- Add bad memory examples to skip (test counts, commit hashes, etc.)
- Add prompt assertions in tests to prevent regression
- Emphasize 'useful if a new agent opens this workspace next week'
2026-04-27 14:40:32 +08:00
Ralph Chang 023589a905 test: add memory quality eval fixtures
Wave 2 of memory quality optimization plan.

- 5 accepted cases: durable facts that should be kept
- 7 rejected cases: noise that should be filtered
- Parser-level regression guard (zero API call)
- All cases pass against current extractors.ts
2026-04-27 14:34:53 +08:00
Ralph Chang 24f807fed0 fix: account for absorbed pending memories
- Add workspaceMemoryIdentityKey() to unify dedup/supersession identity semantics
- Add accountPendingPromotions() to distinguish promoted/absorbed/rejected
- Wire promotion accounting into promotePendingMemories()
- Add clearableKeys.size > 0 guard to prevent journal wipe
- Add regression tests for absorbed duplicate, cap-rejected, all-rejected edge cases

Wave 1 of memory quality optimization plan.
2026-04-27 14:27:43 +08:00
Ralph Chang 097235e43b docs: add memory quality optimization implementation plan
P0 implementation plan with 3 waves:
- Wave 1: Promotion accounting (fix absorbed duplicate data loss)
- Wave 2: Memory quality eval (fixture-based regression guard)
- Wave 3: Compaction prompt negative examples

Addresses architecture review feedback:
- Distinguish promoted/absorbed/rejected pending memories
- Add clearableKeys.size > 0 guard to prevent journal wipe
- Add regression tests for all edge cases
2026-04-27 14:16:46 +08:00
sdwolf4103 14bbb76cf1 Fix formatting in README.md 2026-04-27 13:00:26 +08:00
74 changed files with 1367 additions and 14084 deletions
-2
View File
@@ -51,8 +51,6 @@ pnpm-lock.yaml
# Superpowers local planning artifacts
docs/superpowers/plans/
docs/plans/
docs/disruptions/
# Local dev/admin script inputs
scripts/dev/run-migration-roots.local.txt
-137
View File
@@ -5,143 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6.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
- Numbered compaction memory references (`[M1]`, `[M2]`, ...) for existing rendered workspace memories.
- Compaction memory commands: `REINFORCE [M#]` for retention reinforcement and `REPLACE [M#] [type] text` for protected replacement.
- `CompactionMemoryRef` session-state snapshots with optional compaction IDs for overlap detection.
- Evidence events for numbered memory command outcomes: `memory_reinforced`, `memory_replaced_numbered_ref`, and `memory_reverted_numbered_ref`.
- Public `memory-diag commands` report for command counts, outcomes, rejection reasons, protected replacement blocks, malformed commands, and latest command events.
- Public dry-run-first `memory-diag revert` command for manually reverting successful numbered replacements by replacement memory ID or evidence event ID.
- Hard quality rejection reasons for unresolved questions, transient bug/debug state, and deployment snapshots.
- Soft `terse_label` diagnostic for very short label-like candidates.
- Regression tests for command parsing, REINFORCE, protected REPLACE, revert behavior, compaction ref validation, overlap protection, and fallback behavior when the model omits the compaction snapshot ID.
### Changed
- Compaction prompts now include numbered memory refs and concise memory-operation rules instead of asking the model to reuse existing wording exactly.
- Compaction no longer duplicates hot session state inside the compaction prompt; hot state remains available in normal prompt context.
- Duplicate maintenance now prefers explicit REINFORCE or protected REPLACE evidence over silent duplicate restatement.
- Rendered decision memory cap increased from 10 to 12 while keeping the global rendered cap at 28.
- Rejected memory command evidence now uses neutral `target` relations instead of lifecycle-mutating `reinforced` or `superseded` relation roles.
- `memory-diag` public command metadata now includes `commands` and `revert` alongside `status`, `rejected`, `missing`, and `explain`.
### Fixed
- Overlapping same-session compactions can no longer silently apply numbered commands against the wrong snapshot when the snapshot ID is present.
- Numbered command resolution now rejects stale refs whose memory ID, status, or exact key no longer match the current workspace memory entry.
- Protected replacements are surfaced as first-class diagnostics instead of being buried in generic rejection counts.
### Recovery note
Successful numbered replacements supersede the original memory and add a replacement. To inspect or recover one, run `memory-diag commands --verbose`, then dry-run `memory-diag revert --memory <replacement-memory-id>` or `memory-diag revert --event <event-id>` before adding `--apply`.
## [1.5.5] - 2026-05-05
### Added
- Section-aware greedy line accumulator for hot session state rendering that fits whole lines instead of truncating mid-line.
- `accountHotSessionStateRender()` accounting function returning prompt text, omitted items, and char budget for v2 evidence extension.
- Exported types for render accounting diagnostics: `HotStateSection`, `HotStateOmissionReason`, `HotStateOmittedItem`, `HotSessionStateRenderAccounting`.
- 9 direct unit tests for hot session state rendering covering empty state, ranking, section caps, char budget, boundary conditions, header suppression, wrapper parity, and newline counting.
- Two-layer omission model: section caps first (`section_cap`), then char budget (`char_budget`), with clear per-item omission reasons.
### Changed
- Hot session state rendering no longer uses blunt `.slice(0, maxRenderedChars)` prompt truncation.
- Header-only sections are suppressed: a section is only rendered if at least one entry line fits the char budget.
- `renderHotSessionState()` is now a delegation wrapper to `accountHotSessionStateRender().prompt`, preserving backward compatibility.
- Fixed `docs/configuration.md` active-file ranking formula: was `count * action_weight * recency_decay` with weights 4/3/2/1, now correctly `ACTION_WEIGHT[action] + count * 3` with weights edit 50, write 45, grep 30, read 20, tie-break by `lastSeen` descending.
- Clarified `README.md` system prompt injection order: workspace memory and hot state index positions depend on whether workspace memory state is available.
### Fixed
- Hot session state prompt could be truncated mid-line under the old `.slice()` budget enforcement.
## [1.5.4] - 2026-05-02
### Changed
- Centralized the supported `memory-diag` command metadata for the official public commands: `status`, `rejected`, `missing`, and `explain`.
- Removed pre-public legacy aliases so unsupported spellings now use the standard unknown-subcommand path instead of carrying v2.0 compatibility debt.
- Kept `coverage` and `audit` as hidden maintainer diagnostics with neutral maintainer-only notices.
- Removed public npm script exposure for the internal workspace cleanup tool while keeping the development tool in the repository.
### Fixed
- Moved missing-memory disappearance detail formatting into the official `missing` formatter before deleting legacy disappearance formatter code.
## [1.5.3] - 2026-05-02
### Added
- Published the read-only `memory-diag` package binary for package-qualified `npx --package opencode-working-memory memory-diag` usage.
- Added user-facing diagnostics commands for concise status, rejected memory review, missing memory review, and per-memory explanation.
### Changed
- Reworked the diagnostics CLI into focused command, model, formatter, and utility modules while preserving legacy aliases and maintainer diagnostics.
- Updated README and configuration docs to document the supported `memory-diag` CLI workflow and package-qualified `npx` usage.
- Raised the Node.js requirement to `>=22.6.0` for the TypeScript diagnostics CLI runtime.
### Fixed
- Avoided repeated workspace snapshot construction in status diagnostics.
- Replaced per-memory evidence summary lookups with grouped evidence processing to avoid N+1 diagnostics work.
- Added top-level CLI error handling for cleaner user-facing failures.
- Removed a diagnostics model circular dependency by extracting evidence grouping helpers to a neutral module.
## [1.5.1] - 2026-04-30
### Added
+71 -97
View File
@@ -23,73 +23,27 @@ Use it when you want your agent to remember things like:
- Important file paths or references
- Current active files and unresolved errors
## What You Get
## Features
| 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
```
- **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.
- **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.
## Installation
New users: add OpenCode Working Memory to both OpenCode plugin configs.
`.opencode/opencode.json`:
Add OpenCode Working Memory to your OpenCode config:
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-working-memory"]
}
```
`.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.
Then restart OpenCode. It activates automatically.
## How It Works
@@ -131,13 +85,11 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls.
▼
┌──────────────────────────────────────┐
│ ⚡ Prompt Context │
│ system[1]*: frozen workspace memory │
│ system[2+]*: hot session state │
│ system[1]: frozen workspace memory │
│ system[2+]: hot session state │
└──────────────────────────────────────┘
```
\* Conceptually, workspace memory is pushed first when it is non-empty, and hot session state is pushed after workspace memory. If workspace memory is empty, hot state may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
@@ -193,65 +145,69 @@ Memories decay over time. The strongest stay visible in the prompt; weaker ones
## Explicit Memory Triggers
Most memory is extracted automatically during compaction. When something is especially important, tell the agent directly:
You can explicitly ask the agent to remember durable facts.
Examples:
```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.
```
Use explicit triggers for stable preferences, project rules, architecture decisions, or important references. Then inspect active workspace memory with:
Supported trigger languages include:
```text
/memory → Current memories
| 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.
不要記住這個。
覚えないで。
기억하지 마.
```
Trigger phrases include `remember this`, `save to memory`, `from now on`, `my preference`, `記住`, `記得`, `覚えて`, and `기억해`.
Avoid saving:
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.
- Secrets, passwords, tokens, or credentials
- Temporary progress updates
- Raw command output
- Short-lived session details
## Quality Guards
**Good memory is selective memory.**
OpenCode Working Memory tries to keep memory useful and low-noise.
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.
It includes guards for:
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.
- 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
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 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.
```
Protected memories and stale refs are rejected rather than mutated. Use `memory-diag commands` for detailed command outcomes and recovery guidance.
### Memory Diagnostics CLI
For deeper troubleshooting, use the read-only `memory-diag` CLI:
For local development cleanup, use:
```bash
npx --package opencode-working-memory memory-diag status
npx --package opencode-working-memory memory-diag rejected
npx --package opencode-working-memory memory-diag missing
npx --package opencode-working-memory memory-diag explain <memory-id>
npx --package opencode-working-memory memory-diag quality
npm run cleanup:workspaces -- --dry-run
npm run cleanup:workspaces -- --quarantine
```
See [Diagnostics](docs/diagnostics.md) for the full command reference, numbered memory command reports, and dry-run recovery workflow.
The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
## Configuration
@@ -267,17 +223,35 @@ Default behavior:
See [Configuration](docs/configuration.md) for customization options.
## Roadmap
Current focus:
- Add explicit delete tombstones so removed memories do not get re-extracted.
- Enforce explicit `supersedes` chains for safer replacement of obsolete memories.
- Explore tiered hot/warm/cold storage after the retention model has more real-world data.
## Documentation
- [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
npm run cleanup:workspaces -- --dry-run
```
## Requirements
- OpenCode plugin API `>=1.2.0 <2.0.0`
- Node.js >= 22.6.0 (the published `memory-diag` CLI runs compiled JavaScript)
- Node.js >= 18.0.0
## Limitations
-317
View File
@@ -1,322 +1,5 @@
# Release Notes
## 1.6.3 (2026-05-14)
### Diagnostic Quality Review Board
This patch release focuses on safer memory diagnostics. It adds a read-only quality review board and finer reinforcement drill-downs so reviewers can inspect memory-system evidence without treating historical artifacts as current failures or turning heuristic flags into automatic cleanup decisions.
The goal is better observability before policy changes: diagnostics show facts, provenance, answerability, and review questions, while leaving judgment to the operator.
### What Changed
- **Quality review board**: `memory-diag quality` now reports system-mechanism facts for rejection filters, reinforcement rules, eviction/caps, identity/dedup, and active memory content review.
- **Version/provenance context**: diagnostics distinguish current producer-instrumented events from historical or unversioned evidence where possible, and label ambiguous evidence conservatively.
- **Focused reinforcement drill-down**: `memory-diag commands --memory <memory-id>` shows one memory's reinforcement command evidence, current status, recorded block reasons, missing details, and UTC-day evidence.
- **Canonical active-memory JSON surface**: `memory-diag quality --json` now uses `activeMemoryDisplay` as the single active-memory review surface instead of duplicating active memories under `reviewCandidates`.
- **Attribution-safe wording**: quality and reinforcement diagnostics avoid claiming that a block is a bug, policy failure, or cause of memory loss; they present recorded evidence and review prompts instead.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files and evidence logs remain compatible.
- If you consume `memory-diag quality --json` from unreleased builds after v1.6.2, read active-memory review data from `activeMemoryDisplay`; `reviewCandidates` is now reserved for system-mechanism candidates.
### Useful Commands
```bash
npx --package opencode-working-memory@1.6.3 memory-diag quality
npx --package opencode-working-memory@1.6.3 memory-diag quality --json
npx --package opencode-working-memory@1.6.3 memory-diag commands --memory <memory-id>
```
### Validation
- `node --test --experimental-strip-types tests/memory-diag-quality.test.ts` — 53 tests passing
- `node --test --experimental-strip-types tests/memory-diag.test.ts` — 39 tests passing
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 486 tests passing, `TEST_PASS`
- `npm run build` — `BUILD_PASS`
- `npm run test:pack:memory-diag` — packed tarball smoke test passed
---
## 1.6.2 (2026-05-11)
### Published `memory-diag` Bin Fix
This patch release fixes the published npm package path for `memory-diag`. In v1.6.1 the source-tree CLI tests passed, but the installed package could fail under `npx --package opencode-working-memory memory-diag` because Node refuses TypeScript type stripping for files inside `node_modules`.
v1.6.2 compiles the diagnostics CLI during packing and makes the npm bin launch the compiled JavaScript runtime.
### What Changed
- **Compiled diagnostics runtime**: `prepack` now builds `dist/scripts/memory-diag.js` before the package is packed or published.
- **Safer npm bin wrapper**: `memory-diag` no longer runs published `.ts` files through `--experimental-strip-types`; it launches the compiled JS artifact and reports a clear reinstall/build message if the artifact is missing.
- **Packaged-bin smoke test**: release verification now includes a pack/npx smoke test from a temp consumer project outside the repository.
### Upgrade Notes
- No config changes are required.
- Existing OpenCode server and TUI plugin entry points are unchanged.
- If you hit the v1.6.1 bin failure, upgrade and rerun:
```bash
npx --package opencode-working-memory@1.6.2 memory-diag --help
```
### Validation
- `npm run build` — `BUILD_PASS`
- `node ./scripts/memory-diag-bin.cjs --help`
- `npm run test:pack:memory-diag` — packed tarball smoke test passed
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 421 tests passing, `TEST_PASS`
- `npm pack --dry-run` — includes compiled `dist/` diagnostics artifacts
---
## 1.6.1 (2026-05-08)
### Native TUI Memory Menu
This release adds a native OpenCode TUI memory menu so users can inspect local working memory without asking the model and without adding command output to the conversation transcript.
Open `/memory` in the TUI to browse memory status, current workspace memories, and help from native dialogs.
> Memory should stay visible when you need it — and stay out of the transcript when you are only inspecting it.
```text
/memory
│
├─ Status
│ local counts and memory health
│
├─ Current memories
│ searchable grouped [M#] refs
│
└─ Help
local usage notes
```
### What Changed
- **Single TUI entry point**: `/memory` opens a native submenu instead of exposing multiple memory slash commands.
- **Searchable current memory list**: `Current memories` uses OpenCode's native select dialog for bounded scrolling, filtering, and grouping.
- **Transcript-free inspection**: memory status, list, help, empty states, and errors render in native dialogs instead of user-style session messages.
- **Server and TUI plugin exports**: the package exposes `./server` and `./tui` entry points for OpenCode plugin loading.
- **User docs refreshed**: README highlights the `/memory` workflow and moves the full diagnostics CLI reference to `docs/diagnostics.md`.
### Upgrade Notes
- Add `.opencode/tui.json` if you want the native `/memory` TUI menu. Existing server-only configuration continues to work.
- Restart OpenCode after adding the TUI plugin config.
- The TUI menu is read-only and local-only. It does not call the LLM.
- Individual memory row selection is intentionally a no-op in this release; use the list for inspection and search.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 421 tests passing, `TEST_PASS`
- `npm pack --dry-run`
- Real OpenCode TUI smoke test for `/memory` menu, searchable current memories, and transcript-free output.
---
## 1.6.0 (2026-05-08)
### Numbered Memory Refs
This release turns compaction from a one-way memory extractor into a memory maintenance loop. The model now sees numbered references for existing workspace memories and can explicitly reinforce a still-useful memory or propose a protected replacement when compaction reveals that old memory is obsolete.
The goal is not to make memory more aggressive. It is to make memory more accountable: old facts should be strengthened when they keep proving useful, replaced only when the target is safe, and diagnosable when the model tries something risky.
> **Good memory is selective memory.**
> v1.6 lets memory say “this still matters” without copying it again — and lets obsolete compaction memories fade behind a safer replacement trail.
```text
compaction summary
│
▼
Memory candidates:
Memory ref snapshot id: <uuid>
[M1] decision · reinforced=2 · source=explicit
[M2] project · reinforced=0 · source=compaction
│
├─ REINFORCE [M1]
│ ↑ slows decay, no text mutation
│
└─ REPLACE [M2] project Updated durable fact
↑ only allowed for safe compaction targets
```
### What Changed
- **Numbered memory refs**: compaction prompts now render existing workspace memories as `[M1]`, `[M2]`, ... references so the model can target a known memory instead of restating it as a duplicate candidate.
- **REINFORCE commands**: `REINFORCE [M#]` increments the target memory's reinforcement count and updates its retention clock without changing its text.
- **Protected REPLACE commands**: `REPLACE [M#] [type] text` supersedes the old memory and appends a replacement only when the target is safe: active, compaction-sourced, and not already reinforced.
- **Reinforce + append workflow**: when a memory is mostly right but needs more context, compaction can reinforce the old memory and emit a new candidate for the new durable fact instead of mutating history.
- **Compaction prompt restructure**: verbose type definitions and the old “reuse existing wording exactly” instruction were replaced with shorter command rules, categorization guidance, and concrete memory-operation examples.
- **Hot state removed from compaction context**: active files, current errors, and pending session state remain in normal prompt context but are no longer duplicated inside the compaction prompt, saving budget and reducing accidental promotion of transient state.
- **New hard quality gates**: unresolved questions, transient bug/debug state, and deployment snapshots are rejected as durable memory candidates.
- **Soft terse-label diagnostic**: very short label-like candidates are reported for tuning without being hard-rejected in v1.6.
- **Decision cap raised**: rendered decision memories now have a per-type cap of 12 instead of 10, while the global rendered cap remains 28.
- **Overlap guard for compaction refs**: memory ref snapshots are tagged with a compaction ID when available, so overlapping same-session compactions cannot silently apply commands against the wrong numbered snapshot.
- **Safer evidence semantics**: rejected memory command events use a neutral `target` relation role instead of lifecycle roles such as `reinforced` or `superseded`.
### Why This Helps
- Useful memories can become stronger through real reuse instead of duplicate extraction.
- Obsolete compaction-sourced memories can be replaced with an explicit evidence trail rather than left to drift.
- Manual, explicit, and already-reinforced memories are protected from automatic replacement.
- Compaction prompt budget is spent on durable memory maintenance, not on duplicated hot session state.
- Command outcomes are visible enough to tune the feature after release instead of guessing from reinforcement counts alone.
### Diagnostics
Inspect command behavior with:
```bash
npx --package opencode-working-memory memory-diag commands
npx --package opencode-working-memory memory-diag commands --verbose
npx --package opencode-working-memory memory-diag commands --json
```
The command report includes:
- compactions with command evidence
- REINFORCE and REPLACE counts
- reinforced, superseded, rejected, and blocked outcomes
- invalid or malformed command counts
- same-type vs cross-type replacements
- protected REPLACE blocks, split by reinforced target and protected source
- latest command events in verbose mode
If a numbered replacement needs manual recovery, use the dry-run-first revert command:
```bash
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id> --apply
```
You can also target a replacement evidence event directly:
```bash
npx --package opencode-working-memory memory-diag revert --event <event-id>
```
### Safety Model
- REINFORCE never edits memory text.
- REPLACE is rejected for manual or explicit memories.
- REPLACE is rejected for already reinforced targets.
- REPLACE is rejected if the numbered ref no longer matches the current memory ID, status, and exact key.
- If a compaction snapshot ID is present and mismatched, all numbered commands from that summary are rejected with `missing_memory_ref_snapshot`.
- If the model omits the snapshot ID, v1.6 falls back to exact memory ref validation for compatibility and command effectiveness.
### Upgrade Notes
- No configuration changes required.
- Existing workspace memory files remain compatible.
- Existing session state remains compatible; old sessions without compaction ref snapshots fall back safely.
- Existing evidence logs remain compatible; new command events are appended only after v1.6 runs.
- `memory-diag` now exposes two additional public commands: `commands` and `revert`.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 405 tests passing, `TEST_PASS`
---
## 1.5.5 (2026-05-05)
### Hot State Rendering Health
This release replaces blunt prompt truncation with a section-aware greedy renderer that fits whole lines and suppresses empty sections, plus render accounting types for future diagnostics.
> **Good rendering is selective too.** If a section doesn't fit, omit it — don't cut it in half.
### What Changed
- **Whole-line rendering**: the hot session state prompt no longer truncates mid-line with `.slice(0, maxRenderedChars)`. A greedy line accumulator fits complete lines and stops when the next line would exceed the 700-char budget.
- **Header-only sections suppressed**: a section heading is only rendered if at least one entry fits alongside it. No more orphaned `active_files:` headers with no content underneath.
- **Render accounting**: `accountHotSessionStateRender()` now returns `prompt`, `omitted[]`, and `maxRenderedChars` so future v2 diagnostics can surface which items fell out and why.
- **Two-layer omission model**: section caps (`section_cap`) trim per-type overflow first, then the char budget (`char_budget`) trims anything that doesn't fit. Each omitted item carries its reason and section.
- **Backward-compatible wrapper**: `renderHotSessionState()` delegates to the accounting function and returns just the prompt string, preserving existing plugin behavior.
### Docs Fixes
- **Active-file ranking formula corrected**: `docs/configuration.md` now matches the actual implementation — `ACTION_WEIGHT[action] + count * 3` with weights edit 50, write 45, grep 30, read 20, tie-break by `lastSeen` descending. The old docs claimed `count * action_weight * recency_decay` with weights 4/3/2/1.
- **Injection order clarified**: README now explains that workspace memory and hot state system-prompt positions depend on whether workspace memory state is available.
### Not Included Yet
- Evidence events for omitted items are deferred to v2, pending a `memory-diag` consumer to avoid unused storage litter.
### Upgrade Notes
- No configuration changes required.
- Existing workspace memory files remain compatible.
- Hot session state rendering behavior is intentionally different (cleaner output, no mid-line cuts), but the same information is presented when it fits within the budget.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 356 tests passing, `TEST_PASS`
---
## 1.5.4 (2026-05-02)
### Memory Diagnostics Surface Cleanup
This cleanup release keeps the newly published `memory-diag` CLI small before legacy spellings become compatibility debt.
### What Changed
- **Official commands only**: the public CLI surface is `status`, `rejected`, `missing`, and `explain`.
- **Pre-public aliases removed**: old spellings such as `health`, `quality`, `rejections`, `disappearances`, and `trace` are no longer recognized.
- **Maintainer diagnostics clarified**: hidden `coverage` and `audit` commands remain available as maintainer-only diagnostics and stay out of public usage output.
- **Cleaner internals**: current command metadata now has one source of truth, and legacy command/formatter wrappers were removed.
- **Internal cleanup script hidden**: the workspace cleanup helper is no longer exposed as an npm script; the underlying development tool remains in `scripts/dev/`.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 347 tests passing, `TEST_PASS`
---
## 1.5.3 (2026-05-02)
### Published Memory Diagnostics CLI
This release makes the read-only workspace memory diagnostics CLI available as the package binary `memory-diag`, with user-facing commands for checking memory health and understanding why memories are rejected, missing, shown, or hidden.
> Good memory is selective memory — and diagnostics should explain the selection.
### What Changed
- **Published CLI bin**: run diagnostics with `npx --package opencode-working-memory memory-diag` or `memory-diag status`.
- **User-facing commands**: `status`, `rejected`, `missing`, and `explain <memory-id>` are documented as the supported public workflow.
- **Legacy compatibility**: existing maintainer and legacy commands remain accepted with deprecation notices where applicable.
- **Cleaner CLI architecture**: the former monolithic diagnostics script is now split into focused command, model, formatter, and utility modules.
- **Faster diagnostics**: evidence grouping avoids repeated per-memory evidence queries in snapshot diagnostics.
- **Cleaner failures**: top-level CLI error handling now reports usage and unexpected command errors without noisy stack traces.
- **Docs alignment**: README and configuration docs now use package-qualified `npx` commands to avoid resolving an unrelated package named `memory-diag`.
### Requirements
- Node.js `>=22.6.0` is now required because the published diagnostics binary runs TypeScript through Node's `--experimental-strip-types` support.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 358 tests passing, `TEST_PASS`
---
## 1.5.1 (2026-04-30)
### Evidence Loop and Explainability
-24
View File
@@ -55,30 +55,6 @@ Long-term memory that persists across sessions within the same workspace. Perfec
}
```
### Evidence Log Schema
Workspace diagnostics also read the append-only evidence log for the current workspace. New evidence records are additive and keep historical records valid:
```typescript
{
version: 1,
eventId: string,
createdAt: string,
workspaceKey: string,
workspaceRootHash: string,
producerName?: string,
producerVersion?: string,
instrumentationVersion?: number,
type: EvidenceEventType,
phase: EvidencePhase,
outcome: EvidenceOutcome,
reasonCodes: string[],
details?: Record<string, string | number | boolean | null | string[] | number[]>
}
```
Instrumentation version 2 adds optional causal details for future diagnostics without backfilling old JSONL records. Reinforcement-block events may include `details.blockReason` (for example `same_session` or `same_utc_day`). Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior.
### Entry Types
| Type | Purpose | Example |
+9 -34
View File
@@ -87,33 +87,16 @@ const HOT_STATE_LIMITS = {
## Active File Scoring
Files are ranked by action type plus repeated access count:
Files are ranked by action type:
| Action | Weight | Description |
|--------|--------|-------------|
| `edit` | 50 | File modified |
| `write` | 45 | File created/overwritten |
| `grep` | 30 | Grep searched in file |
| `read` | 20 | File read |
| `write` | 4 | File created/overwritten |
| `edit` | 3 | File modified |
| `read` | 2 | File read |
| `grep` | 1 | Grep searched in file |
Score formula: `ACTION_WEIGHT[action] + count * 3`
When scores tie, the most recent `lastSeen` timestamp sorts first. There is no recency decay factor in the score itself.
## Hot Session State Truncation
Hot state rendering applies section caps before the character budget:
| Section | Rendered cap |
|---------|--------------|
| `active_files` | 8 |
| `open_errors` | 3 |
| `recent_decisions` | 8 |
| `pending_memories` | 6 |
After section caps, the renderer applies the 700-character budget with whole-line inclusion only. The prefix line is counted against the budget, and a section heading is emitted only when at least one entry from that section fits; header-only sections are suppressed.
`accountHotSessionStateRender()` returns prompt and omission accounting for future diagnostics. Evidence event integration for hot-state render omissions is planned for v2.
Score formula: `count * action_weight * recency_decay`
## Error Categories
@@ -221,21 +204,13 @@ cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
### Inspect Retention Health
Use the diagnostics CLI to check memory health for the current workspace:
From a source checkout, maintainers can inspect stored vs rendered memory behavior:
```bash
npx --package opencode-working-memory memory-diag status
# or from a source checkout:
npm run diag -- status
bun scripts/memory-diag.ts health
```
For detailed diagnostics, use `--verbose`:
```bash
npx --package opencode-working-memory memory-diag status --verbose
```
The status output includes overall health (OK/WARNING/DEGRADED), key metrics, and suggested next steps when attention is needed.
The health output includes stored active memories, rendered candidates, type caps, global cap overflow, dormancy status, retention monitoring alerts, and strength-ranked top/weakest entries.
### Clear Workspace Memory
-98
View File
@@ -1,98 +0,0 @@
# Memory Diagnostics
Use the read-only diagnostics CLI when you want to understand what OpenCode Working Memory is doing for the current workspace.
The npm package is `opencode-working-memory`; the installed bin is `memory-diag`, so package-qualified `npx` avoids resolving a different package named `memory-diag`.
## Commands
| Question | Command |
|---|---|
| Is memory healthy? | `npx --package opencode-working-memory memory-diag` or `npx --package opencode-working-memory memory-diag status` |
| Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` |
| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` |
| Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain <memory-id>` |
| How are numbered memory commands behaving? | `npx --package opencode-working-memory memory-diag commands` |
| What reinforcement evidence exists for one memory? | `npx --package opencode-working-memory memory-diag commands --memory <memory-id>` |
| How do I review memory quality without automatic cleanup? | `npx --package opencode-working-memory memory-diag quality` |
| Revert a numbered replacement? | `npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>` |
## Global Options
- `--workspace <path>` — inspect another workspace; defaults to the current directory.
- `--verbose` — show detailed diagnostics.
- `--json` — print machine-readable output where supported.
## Diagnostic Answerability Contract
Every diagnostic section must document:
1. **Question:** What does the reviewer want to know?
2. **Decision:** What action could the answer inform?
3. **Competing explanations:** At least two interpretations of the same metric.
4. **Required signals:** What fields/events distinguish those explanations?
5. **Current signals:** What currently exists?
6. **Answerability level:** `supported` | `partial` | `inventory_only` | `not_instrumented`
7. **Output permission:** What the tool may say without overclaiming.
For `memory-diag quality`:
- `reinforcementRules`: `inventory_only` (cannot distinguish spam from legitimate blocks)
- `evictionAndCaps`: `inventory_only` (cannot distinguish healthy turnover from premature eviction)
- Old evidence remains ambiguous. Answerability improves only for events produced after instrumentation version 2. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections.
- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and UTC-day grouping; they still require human content judgment.
- Producer-instrumented capacity removals with rank/strength snapshots can upgrade `evictionAndCaps` to `partial`; fullness alone remains occupancy inventory, not proof of a capacity problem.
## Examples
```bash
npx --package opencode-working-memory memory-diag status
npx --package opencode-working-memory memory-diag rejected --verbose
npx --package opencode-working-memory memory-diag missing --workspace /path/to/project
npx --package opencode-working-memory memory-diag status --json
npx --package opencode-working-memory memory-diag commands --verbose
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
npx --package opencode-working-memory memory-diag quality
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>
```
## Quality Review Board
Use `memory-diag quality` for a read-only, answerability-scoped evidence inventory without automatic cleanup.
- Primarily provides memory-system mechanism observations for human/agent interpretation.
- Secondarily helps review active memory content quality.
- Prints answerability labels and output permissions so inventory facts are not presented as conclusions.
- Separates system-mechanism facts, memory-content facts, heuristic flags, and review questions.
- Includes inferred evidence provenance because historical records do not record producer package version.
- Labels uncertain provenance as `unversioned_ambiguous` so old artifacts are not treated as current mechanism failures.
- Does not decide what to delete or mutate.
- Use `--json` for agent/objective review.
## Numbered Memory Command Reports
Use `memory-diag commands` to inspect `REINFORCE [M#]` and `REPLACE [M#]` outcomes from compaction.
```bash
npx --package opencode-working-memory memory-diag commands
npx --package opencode-working-memory memory-diag commands --verbose
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
```
The report includes successful reinforcements, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode.
Use `commands --memory <memory-id>` when you need a focused, evidence-only reinforcement view for one memory. It reports current memory status separately from recorded reinforcement attempts, block reasons, missing block details, and UTC-day evidence without judging whether the policy is correct.
## Dry-run Recovery
`memory-diag revert` is dry-run by default. Add `--apply` only after reviewing the planned original/replacement status changes.
```bash
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id>
npx --package opencode-working-memory memory-diag revert --memory <replacement-memory-id> --apply
```
You can also target a replacement evidence event directly:
```bash
npx --package opencode-working-memory memory-diag revert --event <event-id>
```
@@ -1,491 +0,0 @@
# 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.
+7 -20
View File
@@ -1,36 +1,23 @@
{
"name": "opencode-working-memory",
"version": "1.6.3",
"version": "1.5.1",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
"exports": {
".": "./index.ts",
"./server": "./index.ts",
"./tui": "./src/tui-plugin.ts"
},
"bin": {
"memory-diag": "./scripts/memory-diag-bin.cjs"
".": "./index.ts"
},
"files": [
"index.ts",
"src/",
"scripts/memory-diag.ts",
"scripts/memory-diag/",
"scripts/memory-diag-bin.cjs",
"dist/",
"README.md",
"LICENSE"
],
"scripts": {
"clean:dist": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"build:memory-diag": "npm run clean:dist && tsc -p tsconfig.memory-diag.json",
"build": "npm run build:memory-diag && node -e \"console.log('BUILD_PASS')\"",
"prepack": "npm run build",
"diag": "npm run --silent build:memory-diag && node ./scripts/memory-diag-bin.cjs",
"test:pack:memory-diag": "node --test --experimental-strip-types tests/smoke/memory-diag-packaging.test.ts",
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
"typecheck": "tsc --noEmit",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts",
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
},
"keywords": [
@@ -60,6 +47,6 @@
"typescript": "^5.9.2"
},
"engines": {
"node": ">=22.6.0"
"node": ">=18.0.0"
}
}
-368
View File
@@ -1,368 +0,0 @@
#!/usr/bin/env node
/**
* Read-only baseline report for workspace-memory exact/identity key collisions.
*
* This script reads workspace-memory.json files from the local workspace memory
* data directory. It never writes to workspace memory stores; the only optional
* write is the explicit --json report path.
*/
import { existsSync } from "node:fs";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../../src/workspace-memory.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType } from "../../src/types.ts";
type CliOptions = {
workspacesDir: string;
jsonPath?: string;
};
type ReinforcementCountStatistics = {
min: number;
max: number;
mean: number;
gtZero: number;
};
type MemorySummary = {
id: string;
type: LongTermType;
source: LongTermSource;
text: string;
reinforcementCount: number;
};
type WorkspaceSummary = {
dirName: string;
dirPath: string;
key: string;
root: string;
};
type CollisionGroup = {
kind: "exact" | "identity";
workspace: WorkspaceSummary;
key: string;
classification: "unclassified";
memories: MemorySummary[];
};
type ValidationReport = {
summary: {
workspaces: number;
activeMemories: number;
exactCollisionGroups: number;
identityCollisionGroups: number;
reinforcementCountStatistics: ReinforcementCountStatistics;
};
groups: CollisionGroup[];
};
type ScannedMemory = {
entry: LongTermMemoryEntry;
exactKey: string;
identityKey: string;
reinforcementCount: number;
};
const DEFAULT_WORKSPACES_DIR = join(homedir(), ".local", "share", "opencode-working-memory", "workspaces");
const WORKSPACES_DIR_ENV_KEYS = [
"OPENCODE_WORKING_MEMORY_WORKSPACES_DIR",
"WORKSPACE_MEMORY_WORKSPACES_DIR",
];
function usage(): string {
return `Usage:
node --experimental-strip-types scripts/dev/validate-identity-keys.ts [workspaces-dir] [--json <path>]
node --experimental-strip-types scripts/dev/validate-identity-keys.ts --data-path <workspaces-dir> [--json <path>]
Defaults:
workspaces-dir defaults to ${DEFAULT_WORKSPACES_DIR}
env override: ${WORKSPACES_DIR_ENV_KEYS.join(" or ")}
`;
}
function die(message: string): never {
console.error(message);
console.error(usage());
process.exit(1);
}
function envWorkspacesDir(): string | undefined {
for (const key of WORKSPACES_DIR_ENV_KEYS) {
const value = process.env[key];
if (value) return value;
}
return undefined;
}
function expandHome(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
return path;
}
function parseArgs(argv: string[]): CliOptions {
let workspacesDir: string | undefined;
let jsonPath: string | undefined;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
console.log(usage());
process.exit(0);
}
if (arg === "--json") {
const value = argv[++i];
if (!value) die("--json requires a path");
jsonPath = expandHome(value);
continue;
}
if (arg === "--data-path" || arg === "--workspaces-dir") {
const value = argv[++i];
if (!value) die(`${arg} requires a path`);
if (workspacesDir) die("Only one workspaces directory may be provided");
workspacesDir = expandHome(value);
continue;
}
if (arg.startsWith("--")) die(`Unknown option: ${arg}`);
if (workspacesDir) die("Only one workspaces directory may be provided");
workspacesDir = expandHome(arg);
}
return {
workspacesDir: resolve(workspacesDir ?? expandHome(envWorkspacesDir() ?? DEFAULT_WORKSPACES_DIR)),
jsonPath: jsonPath ? resolve(jsonPath) : undefined,
};
}
function isLongTermType(value: unknown): value is LongTermType {
return value === "feedback" || value === "project" || value === "decision" || value === "reference";
}
function isLongTermSource(value: unknown): value is LongTermSource {
return value === "explicit" || value === "compaction" || value === "manual";
}
function isMemoryEntry(value: unknown): value is LongTermMemoryEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<LongTermMemoryEntry>;
return typeof entry.id === "string"
&& isLongTermType(entry.type)
&& typeof entry.text === "string"
&& isLongTermSource(entry.source);
}
function reinforcementCount(entry: LongTermMemoryEntry): number {
const count = entry.reinforcementCount ?? 0;
return Number.isFinite(count) && count > 0 ? count : 0;
}
function summarizeMemory(memory: ScannedMemory): MemorySummary {
return {
id: memory.entry.id,
type: memory.entry.type,
source: memory.entry.source,
text: memory.entry.text,
reinforcementCount: memory.reinforcementCount,
};
}
function addToGroupMap(map: Map<string, ScannedMemory[]>, key: string, memory: ScannedMemory): void {
const group = map.get(key);
if (group) {
group.push(memory);
} else {
map.set(key, [memory]);
}
}
function computeReinforcementStatistics(counts: number[]): ReinforcementCountStatistics {
if (counts.length === 0) {
return { min: 0, max: 0, mean: 0, gtZero: 0 };
}
const sum = counts.reduce((total, count) => total + count, 0);
return {
min: Math.min(...counts),
max: Math.max(...counts),
mean: sum / counts.length,
gtZero: counts.filter(count => count > 0).length,
};
}
async function readWorkspaceMemory(workspaceDir: string): Promise<unknown | undefined> {
const path = join(workspaceDir, "workspace-memory.json");
if (!existsSync(path)) return undefined;
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw);
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
if (code === "ENOENT" || code === "EISDIR") return undefined;
throw error;
}
}
async function scanWorkspaces(workspacesDir: string): Promise<ValidationReport> {
const dirents = await readdir(workspacesDir, { withFileTypes: true });
const workspaceDirs = dirents
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
.sort((a, b) => a.localeCompare(b));
const collisionGroups: CollisionGroup[] = [];
const reinforcementCounts: number[] = [];
let workspaces = 0;
let activeMemories = 0;
for (const dirName of workspaceDirs) {
const dirPath = join(workspacesDir, dirName);
const store = await readWorkspaceMemory(dirPath);
if (!store || typeof store !== "object") continue;
const storeObject = store as {
workspace?: { key?: unknown; root?: unknown };
entries?: unknown;
};
const rawEntries = Array.isArray(storeObject.entries) ? storeObject.entries : [];
const workspace: WorkspaceSummary = {
dirName,
dirPath,
key: typeof storeObject.workspace?.key === "string" ? storeObject.workspace.key : dirName,
root: typeof storeObject.workspace?.root === "string" ? storeObject.workspace.root : "<unknown>",
};
workspaces += 1;
const exactGroups = new Map<string, ScannedMemory[]>();
const identityGroups = new Map<string, ScannedMemory[]>();
for (const rawEntry of rawEntries) {
if (!isMemoryEntry(rawEntry)) continue;
if (rawEntry.status === "superseded") continue;
const scanned: ScannedMemory = {
entry: rawEntry,
exactKey: workspaceMemoryExactKey(rawEntry),
identityKey: workspaceMemoryIdentityKey(rawEntry),
reinforcementCount: reinforcementCount(rawEntry),
};
activeMemories += 1;
reinforcementCounts.push(scanned.reinforcementCount);
addToGroupMap(exactGroups, scanned.exactKey, scanned);
addToGroupMap(identityGroups, scanned.identityKey, scanned);
}
for (const [key, memories] of exactGroups.entries()) {
if (memories.length < 2) continue;
collisionGroups.push({
kind: "exact",
workspace,
key,
classification: "unclassified",
memories: memories.map(summarizeMemory),
});
}
for (const [key, memories] of identityGroups.entries()) {
if (memories.length < 2) continue;
collisionGroups.push({
kind: "identity",
workspace,
key,
classification: "unclassified",
memories: memories.map(summarizeMemory),
});
}
}
const exactCollisionGroups = collisionGroups.filter(group => group.kind === "exact").length;
const identityCollisionGroups = collisionGroups.filter(group => group.kind === "identity").length;
return {
summary: {
workspaces,
activeMemories,
exactCollisionGroups,
identityCollisionGroups,
reinforcementCountStatistics: computeReinforcementStatistics(reinforcementCounts),
},
groups: collisionGroups,
};
}
function oneLine(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function renderHumanReport(report: ValidationReport, workspacesDir: string): string {
const stats = report.summary.reinforcementCountStatistics;
const lines: string[] = [];
lines.push("Workspace memory identity key validation baseline");
lines.push(`Data path: ${workspacesDir}`);
lines.push("");
lines.push("Summary:");
lines.push(`- Workspaces scanned: ${report.summary.workspaces}`);
lines.push(`- Active memories: ${report.summary.activeMemories}`);
lines.push(`- Exact collision groups: ${report.summary.exactCollisionGroups}`);
lines.push(`- Identity collision groups: ${report.summary.identityCollisionGroups}`);
lines.push(`- Reinforcement counts: min=${stats.min} max=${stats.max} mean=${stats.mean.toFixed(2)} gtZero=${stats.gtZero}`);
if (report.groups.length === 0) {
lines.push("");
lines.push("Collision breakdown: none");
return lines.join("\n");
}
const groupsByWorkspace = new Map<string, CollisionGroup[]>();
for (const group of report.groups) {
const key = group.workspace.key;
const existing = groupsByWorkspace.get(key);
if (existing) existing.push(group);
else groupsByWorkspace.set(key, [group]);
}
lines.push("");
lines.push("Collision breakdown:");
for (const [workspaceKey, groups] of [...groupsByWorkspace.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
const workspace = groups[0].workspace;
const exactCount = groups.filter(group => group.kind === "exact").length;
const identityCount = groups.filter(group => group.kind === "identity").length;
lines.push(`- Workspace ${workspaceKey}`);
lines.push(` root: ${workspace.root}`);
lines.push(` dir: ${workspace.dirPath}`);
lines.push(` exactCollisionGroups=${exactCount} identityCollisionGroups=${identityCount}`);
for (const group of groups.sort((a, b) => a.kind.localeCompare(b.kind) || a.key.localeCompare(b.key))) {
lines.push(` - ${group.kind} key: ${group.key}`);
lines.push(` classification: ${group.classification}`);
for (const memory of group.memories) {
lines.push(` - id=${memory.id} type=${memory.type} source=${memory.source} reinforcementCount=${memory.reinforcementCount}`);
lines.push(` text: ${oneLine(memory.text)}`);
}
}
}
return lines.join("\n");
}
async function writeJsonReport(path: string, report: ValidationReport): Promise<void> {
if (basename(path) === "workspace-memory.json") {
die("Refusing to write JSON output to a workspace-memory.json file");
}
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, "utf8");
}
const options = parseArgs(process.argv.slice(2));
const report = await scanWorkspaces(options.workspacesDir);
console.log(renderHumanReport(report, options.workspacesDir));
if (options.jsonPath) {
await writeJsonReport(options.jsonPath, report);
console.log(`\nJSON report written to: ${options.jsonPath}`);
}
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env node
const { execFileSync } = require("child_process");
const { existsSync } = require("fs");
const path = require("path");
function isSupportedNodeVersion(version) {
const [major = 0, minor = 0, patch = 0] = version.split(".").map(part => Number(part));
void patch;
return major > 22 || (major === 22 && minor >= 6);
}
if (!isSupportedNodeVersion(process.versions.node)) {
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 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);
} catch (e) {
process.exit(e.status || 1);
}
+1060 -35
View File
File diff suppressed because it is too large Load Diff
-138
View File
@@ -1,138 +0,0 @@
import { HIDDEN_COMMAND_NOTICES, isCommand } from "./command-metadata.ts";
import type { CliOptions, Command, ParsedArgs } from "./types.ts";
export type { ParsedArgs } from "./types.ts";
export function usage(): string {
return `Usage:
memory-diag [status] [--workspace <path>] [--verbose] [--json]
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 <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:
--workspace <path> Workspace path (default: current directory)
--verbose Show detailed diagnostics
--json Print machine-readable JSON where supported
--no-emoji Disable emoji in human output
`;
}
function error(message: string): ParsedArgs {
return { ok: false, message, usage: usage(), exitCode: 1 };
}
function isValidSince(rawSince: string): boolean {
if (/^(\d+)([dhm])$/i.test(rawSince)) return true;
return !Number.isNaN(new Date(rawSince).getTime());
}
export function parseArgs(argv: string[]): ParsedArgs {
const [first, ...tail] = argv;
if (first === "--help" || first === "-h") {
return { ok: true, help: true, usage: usage() };
}
let command: Command = "status";
let deprecationNotice: string | undefined;
let rest = argv;
if (first && !first.startsWith("--")) {
rest = tail;
if (isCommand(first)) {
command = first;
deprecationNotice = HIDDEN_COMMAND_NOTICES[first];
} else {
return error(`Unknown subcommand: ${first}`);
}
}
const options: CliOptions = { raw: false, positional: [] };
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (arg === "--raw") options.raw = true;
else if (arg === "--json") options.json = true;
else if (arg === "--verbose") options.verbose = true;
else if (arg === "--no-emoji") options.noEmoji = true;
else if (arg === "--all") options.all = true;
else if (arg === "--soft-only") options.softOnly = true;
else if (arg === "--trigger-only") options.triggerOnly = true;
else if (arg === "--include-historical") options.includeHistorical = true;
else if (arg === "--explain") options.explain = true;
else if (arg === "--apply") options.apply = true;
else if (arg === "--workspace") {
const value = rest[++i];
if (!value) return error("--workspace requires a path");
options.workspace = value;
} else if (arg === "--since") {
const value = rest[++i];
if (!value) return error("--since requires a duration or ISO timestamp");
options.since = value;
} else if (arg === "--reason") {
const value = rest[++i];
if (!value) return error("--reason requires a reason code");
options.reason = value;
} else if (arg === "--migration") {
const value = rest[++i];
if (!value) return error("--migration requires an id");
options.migration = value;
} else if (arg === "--memory") {
const value = rest[++i];
if (!value) return error("--memory requires an id");
options.memory = value;
} else if (arg === "--event") {
const value = rest[++i];
if (!value) return error("--event requires an id");
options.event = value;
} else if (!arg.startsWith("--") && command === "explain") {
options.positional?.push(arg);
} else {
return error(`Unknown option: ${arg}`);
}
}
if (command === "explain") {
const positional = options.positional ?? [];
if (positional.length > 1) return error("explain accepts at most one memory id");
if (positional.length === 1 && options.memory) return error("Use either explain <memory-id> or --memory, not both");
if (positional.length === 1) options.memory = positional[0];
} else if ((options.positional ?? []).length > 0) {
return error(`Unknown option: ${options.positional?.[0]}`);
}
if (command === "status") {
if (options.all) return error(`${command} does not accept --all`);
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain" || command === "commands" || command === "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" && command !== "quality") {
return error(`${command} does not accept --json`);
}
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
return error(`${command} does not accept rejection filters`);
}
if (command !== "coverage" && options.includeHistorical) return error(`${command} does not accept --include-historical`);
if (command !== "rejected" && options.reason) return error(`${command} does not accept rejection filters`);
if (command !== "missing" && options.explain) return error(`${command} does not accept --explain`);
if (command !== "audit" && options.migration) {
return error(`${command} does not accept --migration`);
}
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`);
if (command !== "revert" && options.apply) return error(`${command} does not accept --apply`);
if (command === "revert") {
if (!options.memory && !options.event) return error("revert requires --memory or --event");
if (options.memory && options.event) return error("Use either --memory or --event, not both");
}
if (command === "rejected" && options.since && !isValidSince(options.since)) {
return error(`Invalid --since value: ${options.since}`);
}
return { ok: true, command, options, deprecationNotice };
}
-16
View File
@@ -1,16 +0,0 @@
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];
export type HiddenCommand = typeof HIDDEN_COMMANDS[number];
export type Command = VisibleCommand | HiddenCommand;
export const HIDDEN_COMMAND_NOTICES: Partial<Record<Command, string>> = {
coverage: "Note: 'coverage' is a maintainer-only diagnostic and is not part of the public CLI surface.",
audit: "Note: 'audit' is a maintainer-only diagnostic and is not part of the public CLI surface.",
};
export function isCommand(value: string | undefined): value is Command {
return value !== undefined
&& ([...VISIBLE_COMMANDS, ...HIDDEN_COMMANDS] as readonly string[]).includes(value);
}
-24
View File
@@ -1,24 +0,0 @@
import { runAudit } from "./commands/audit.ts";
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";
import type { CliOptions, Command, CommandResult } from "./types.ts";
export async function dispatch(command: Command, options: CliOptions): Promise<CommandResult> {
switch (command) {
case "status": return runStatus(options);
case "rejected": return runRejected(options);
case "missing": return runMissing(options);
case "coverage": return runCoverage(options);
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);
}
}
-24
View File
@@ -1,24 +0,0 @@
import { HARD_QUALITY_REASONS } from "../../../src/memory-quality.ts";
import { formatMigrationAudit, type MigrationAuditReport } from "../formatters/audit.ts";
import { readJSONLFile } from "../io.ts";
import { migrationIdFromPath, migrationLogPaths, riskySupersedeReasons } from "../migrations-model.ts";
import type { CliOptions, CommandResult, MigrationLogRecord } from "../types.ts";
export async function runAudit(options: CliOptions): Promise<CommandResult> {
const paths = await migrationLogPaths(options);
const reports: MigrationAuditReport[] = [];
for (const path of paths) {
const migrationId = options.migration ?? migrationIdFromPath(path);
const { records, invalidLines } = await readJSONLFile<MigrationLogRecord>(path);
const superseded = records.filter(record => !record.afterStatus || record.afterStatus === "superseded");
const hardReasons = superseded.flatMap(record => {
if (Array.isArray(record.hardReasons)) return record.hardReasons;
return Array.isArray(record.reasons) ? record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)) : [];
});
const risky = superseded
.map(record => ({ record, reasons: riskySupersedeReasons(record) }))
.filter(item => item.reasons.length > 0);
reports.push({ migrationId, path, invalidLines, superseded, hardReasons, risky });
}
return { stdout: formatMigrationAudit(reports, { raw: options.raw }) };
}
-424
View File
@@ -1,424 +0,0 @@
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";
type MemoryCommandSummary = {
version: 1;
generatedAt: string;
compactionsWithCommandEvidence: number;
commands: Record<CommandKind, number>;
outcomes: Record<"reinforced" | "superseded" | "rejected" | "blocked", number>;
invalidMalformedCommands: number;
replacements: {
sameType: number;
crossType: number;
};
protectedReplacements: {
total: number;
protectedReinforcedTarget: number;
protectedMemorySource: number;
};
rejectionReasons: Record<string, number>;
latestEvents: Array<{
eventId: string;
createdAt: string;
type: string;
outcome: EvidenceOutcome;
ref?: string;
memoryId?: string;
reasonCodes: string[];
textPreview?: string;
}>;
};
type MemoryCommandDetail = {
version: 1;
generatedAt: string;
memoryId: string;
current: {
present: boolean;
status?: string;
renderStatus?: "rendered" | "not_rendered" | "unknown";
type?: string;
source?: string;
};
summary: {
attempts: number;
reinforced: number;
rejectedOrBlocked: number;
windowBlocked: number;
blocksByReason: Record<string, number>;
blockDetailsMissing: number;
refs: string[];
sameSessionCrossUtcDayBlocks: number;
};
events: Array<{
eventId: string;
createdAt: string;
outcome: EvidenceOutcome;
ref?: string;
blockReason?: string;
reasonCodes: string[];
attemptedAtIso?: string;
lastReinforcedAtIso?: string;
crossUtcDay?: boolean | "unknown";
producerVersion?: string;
instrumentationVersion?: number;
}>;
};
const INVALID_COMMAND_REASONS = new Set([
"invalid_memory_command",
"invalid_memory_ref",
"invalid_memory_type",
"empty_replacement_text",
]);
function hasReason(event: EvidenceEventV1, reason: string): boolean {
return event.reasonCodes.includes(reason);
}
function isInvalidMalformedCommandEvent(event: EvidenceEventV1): boolean {
return event.type === "extraction_candidate_rejected"
&& event.reasonCodes.some(reason => INVALID_COMMAND_REASONS.has(reason));
}
function isParsedCommandEvent(event: EvidenceEventV1): boolean {
return event.type === "memory_reinforced" || event.type === "memory_replaced_numbered_ref";
}
function isManualRevertEvent(event: EvidenceEventV1): boolean {
return event.type === "memory_reverted_numbered_ref";
}
function isCommandEvidenceEvent(event: EvidenceEventV1): boolean {
return isParsedCommandEvent(event) || isInvalidMalformedCommandEvent(event) || isManualRevertEvent(event);
}
function refFromEvent(event: EvidenceEventV1): string | undefined {
const ref = event.details?.ref;
return typeof ref === "string" ? ref : undefined;
}
function isReinforcementEvent(event: EvidenceEventV1): boolean {
return event.type === "memory_reinforced";
}
function stringDetail(event: EvidenceEventV1, key: string): string | undefined {
const value = event.details?.[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function isRejectedOrBlocked(event: EvidenceEventV1): boolean {
return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked");
}
function blockReasonFor(event: EvidenceEventV1): string | undefined {
if (!isRejectedOrBlocked(event)) return undefined;
return stringDetail(event, "blockReason") ?? "unknown";
}
function isCrossUtcDay(attemptedAtIso: string | undefined, lastReinforcedAtIso: string | undefined): boolean | "unknown" {
if (!attemptedAtIso || !lastReinforcedAtIso) return "unknown";
const attempted = new Date(attemptedAtIso);
const lastReinforced = new Date(lastReinforcedAtIso);
if (Number.isNaN(attempted.getTime()) || Number.isNaN(lastReinforced.getTime())) return "unknown";
return attempted.toISOString().slice(0, 10) !== lastReinforced.toISOString().slice(0, 10);
}
async function currentMemoryStatus(root: string, memoryId: string): Promise<MemoryCommandDetail["current"]> {
const rawStore = await readJSONFile<WorkspaceMemoryStore>(await workspaceMemoryPath(root));
const storeRoot = rawStore?.workspace?.root ?? root;
const storeKey = rawStore?.workspace?.key ?? await workspaceKey(root);
const store = normalizedStore(rawStore, storeRoot, storeKey);
const activeEntry = store.entries.find(entry => entry.id === memoryId && entry.status !== "superseded");
const renderAccounting = accountWorkspaceMemoryRender(store);
const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id));
const omittedIds = new Set(renderAccounting.omitted.map(item => item.memory.id));
const renderStatus = renderedIds.has(memoryId)
? "rendered"
: omittedIds.has(memoryId)
? "not_rendered"
: "unknown";
if (!activeEntry) {
return { present: false, renderStatus };
}
return {
present: true,
status: activeEntry.status,
renderStatus,
type: activeEntry.type,
source: activeEntry.source,
};
}
function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][number] {
const attemptedAtIso = stringDetail(event, "attemptedAtIso");
const lastReinforcedAtIso = stringDetail(event, "lastReinforcedAtIso");
const blocked = isRejectedOrBlocked(event);
const blockReason = blockReasonFor(event);
return {
eventId: event.eventId,
createdAt: event.createdAt,
outcome: event.outcome,
ref: refFromEvent(event),
blockReason,
reasonCodes: event.reasonCodes,
attemptedAtIso,
lastReinforcedAtIso,
crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined,
producerVersion: event.producerVersion,
instrumentationVersion: event.instrumentationVersion,
};
}
export async function buildMemoryCommandDetail(
root: string,
memoryId: string,
events: EvidenceEventV1[],
generatedAt = new Date().toISOString(),
): Promise<MemoryCommandDetail> {
const reinforcementEvents = events.filter(isReinforcementEvent);
const blockReasonCounts = new Map<string, number>();
const refs = new Set<string>();
let blockDetailsMissing = 0;
let sameSessionCrossUtcDayBlocks = 0;
for (const event of reinforcementEvents) {
const ref = refFromEvent(event);
if (ref) refs.add(ref);
if (!isRejectedOrBlocked(event)) continue;
const blockReason = blockReasonFor(event) ?? "unknown";
blockReasonCounts.set(blockReason, (blockReasonCounts.get(blockReason) ?? 0) + 1);
if (!stringDetail(event, "blockReason")) blockDetailsMissing += 1;
if (blockReason === "same_session" && isCrossUtcDay(stringDetail(event, "attemptedAtIso"), stringDetail(event, "lastReinforcedAtIso")) === true) {
sameSessionCrossUtcDayBlocks += 1;
}
}
return {
version: 1,
generatedAt,
memoryId,
current: await currentMemoryStatus(root, memoryId),
summary: {
attempts: reinforcementEvents.length,
reinforced: reinforcementEvents.filter(event => event.outcome === "reinforced").length,
rejectedOrBlocked: reinforcementEvents.filter(isRejectedOrBlocked).length,
windowBlocked: reinforcementEvents.filter(event => hasReason(event, "reinforcement_window_blocked")).length,
blocksByReason: objectFromCounts(blockReasonCounts),
blockDetailsMissing,
refs: [...refs].sort(),
sameSessionCrossUtcDayBlocks,
},
events: reinforcementEvents.map(detailEventJSON),
};
}
function latestEventJSON(event: EvidenceEventV1): MemoryCommandSummary["latestEvents"][number] {
return {
eventId: event.eventId,
createdAt: event.createdAt,
type: event.type,
outcome: event.outcome,
ref: refFromEvent(event),
memoryId: event.memory?.memoryId,
reasonCodes: event.reasonCodes,
textPreview: event.textPreview,
};
}
export function buildMemoryCommandSummary(events: EvidenceEventV1[], generatedAt = new Date().toISOString()): MemoryCommandSummary {
const commandEvents = events.filter(isCommandEvidenceEvent);
const compactionCommandEvents = commandEvents.filter(event => !isManualRevertEvent(event));
const parsedEvents = compactionCommandEvents.filter(isParsedCommandEvent);
const invalidEvents = compactionCommandEvents.filter(isInvalidMalformedCommandEvent);
const sessions = new Set(compactionCommandEvents.map(event => event.sessionHash).filter((value): value is string => typeof value === "string" && value.length > 0));
const replacementSuccesses = parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && event.outcome === "superseded");
const rejectedCommandEvents = commandEvents.filter(event => event.outcome === "rejected");
const rejectionReasonCounts = new Map<string, number>();
for (const event of rejectedCommandEvents) {
for (const reason of event.reasonCodes) {
rejectionReasonCounts.set(reason, (rejectionReasonCounts.get(reason) ?? 0) + 1);
}
}
const protectedReinforcedTarget = parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && hasReason(event, "protected_reinforced_target")).length;
const protectedMemorySource = parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && hasReason(event, "protected_memory_source")).length;
const parsedRejected = parsedEvents.filter(event => event.outcome === "rejected").length;
return {
version: 1,
generatedAt,
compactionsWithCommandEvidence: sessions.size > 0 ? sessions.size : compactionCommandEvents.length > 0 ? 1 : 0,
commands: {
reinforce: parsedEvents.filter(event => event.type === "memory_reinforced").length,
replace: parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref").length,
},
outcomes: {
reinforced: parsedEvents.filter(event => event.outcome === "reinforced").length,
superseded: parsedEvents.filter(event => event.outcome === "superseded").length,
rejected: parsedRejected,
blocked: parsedRejected,
},
invalidMalformedCommands: invalidEvents.length,
replacements: {
sameType: replacementSuccesses.filter(event => hasReason(event, "same_type_replace")).length,
crossType: replacementSuccesses.filter(event => hasReason(event, "cross_type_replace")).length,
},
protectedReplacements: {
total: parsedEvents.filter(event => event.type === "memory_replaced_numbered_ref" && (hasReason(event, "protected_reinforced_target") || hasReason(event, "protected_memory_source"))).length,
protectedReinforcedTarget,
protectedMemorySource,
},
rejectionReasons: objectFromCounts(rejectionReasonCounts),
latestEvents: commandEvents.slice(-10).reverse().map(latestEventJSON),
};
}
function formatReasonCounts(rejectionReasons: Record<string, number>): string[] {
const counts = new Map(Object.entries(rejectionReasons));
const rows = sortedCounts(counts);
if (rows.length === 0) return [" (none)"];
return rows.map(([reason, count]) => ` - ${reason}: ${count}`);
}
function formatLatestEvents(events: MemoryCommandSummary["latestEvents"]): string[] {
if (events.length === 0) return [" (none)"];
return events.map(event => {
const ref = event.ref ? ` ref=${event.ref}` : "";
const memoryId = event.memoryId ? ` memory=${event.memoryId}` : "";
const textPreview = event.textPreview ? ` text=${JSON.stringify(event.textPreview)}` : "";
return ` - ${event.createdAt} ${event.type} ${event.outcome}${ref}${memoryId} reasons=${event.reasonCodes.join(",") || "none"}${textPreview}`;
});
}
function formatInlineCounts(counts: Record<string, number>): string {
const rows = sortedCounts(new Map(Object.entries(counts)));
return rows.length > 0 ? rows.map(([reason, count]) => `${reason}=${count}`).join(", ") : "(none)";
}
function formatCrossUtcDay(value: boolean | "unknown" | undefined): string {
if (value === true) return "yes";
if (value === false) return "no";
return "unknown";
}
function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] {
if (events.length === 0) return [" (none)"];
return events.map(event => {
const ref = event.ref ? ` ref=${event.ref}` : "";
const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : "";
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : "";
const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : "";
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${attemptedAt}${lastReinforcedAt}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`;
});
}
export function formatMemoryCommandDetail(detail: MemoryCommandDetail, options: Pick<CliOptions, "verbose"> = {}): string {
const current = detail.current;
const lines = [
`Memory command diagnostics for ${detail.memoryId}`,
"",
"Current memory:",
` - present: ${current.present ? "yes" : "no"}`,
` - status: ${current.status ?? "unknown"}`,
` - render: ${current.renderStatus ?? "unknown"}`,
];
if (current.type) lines.push(` - type: ${current.type}`);
if (current.source) lines.push(` - source: ${current.source}`);
lines.push("");
if (detail.summary.attempts === 0) {
lines.push(`No reinforcement command evidence found for ${detail.memoryId}.`);
return lines.join("\n");
}
lines.push(
"Reinforcement summary:",
` - attempts: ${detail.summary.attempts}`,
` - reinforced: ${detail.summary.reinforced}`,
` - rejected/blocked: ${detail.summary.rejectedOrBlocked}`,
` - window blocked: ${detail.summary.windowBlocked}`,
` - block reasons: ${formatInlineCounts(detail.summary.blocksByReason)}`,
` - block details missing: ${detail.summary.blockDetailsMissing}`,
` - same-session cross UTC day blocks: ${detail.summary.sameSessionCrossUtcDayBlocks}`,
` - refs: ${detail.summary.refs.length > 0 ? detail.summary.refs.join(", ") : "(none)"}`,
"",
);
const eventRows = options.verbose ? detail.events : detail.events.slice(-10).reverse();
if (!options.verbose && detail.events.length > eventRows.length) {
lines.push(`Latest reinforcement events (showing ${eventRows.length} of ${detail.events.length}):`);
} else {
lines.push("Latest reinforcement events:");
}
lines.push(...formatMemoryCommandDetailEvents(eventRows));
return lines.join("\n");
}
export function formatMemoryCommandSummary(summary: MemoryCommandSummary, options: Pick<CliOptions, "verbose" | "noEmoji"> = {}): string {
const warning = options.noEmoji ? "!" : "⚠";
const lines = [
"Memory command diagnostics",
"",
"Key metrics:",
` - compactions with command evidence: ${summary.compactionsWithCommandEvidence}`,
` - reinforce: ${summary.commands.reinforce}`,
` - replace: ${summary.commands.replace}`,
` - reinforced: ${summary.outcomes.reinforced}`,
` - superseded: ${summary.outcomes.superseded}`,
` - rejected: ${summary.outcomes.rejected}`,
` - blocked: ${summary.outcomes.blocked}`,
` - invalid/malformed commands: ${summary.invalidMalformedCommands}`,
` - same-type replacements: ${summary.replacements.sameType}`,
` - cross-type replacements: ${summary.replacements.crossType}`,
` - ${warning} Protected REPLACE blocked: ${summary.protectedReplacements.total} (reinforced: ${summary.protectedReplacements.protectedReinforcedTarget}, source: ${summary.protectedReplacements.protectedMemorySource})`,
"",
"Rejection reasons:",
...formatReasonCounts(summary.rejectionReasons),
];
if (options.verbose) {
lines.push("", "Latest command events:", ...formatLatestEvents(summary.latestEvents));
}
return lines.join("\n");
}
export async function runCommands(options: CliOptions): Promise<CommandResult> {
const root = options.workspace ?? process.cwd();
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);
if (options.json) {
return { stdout: JSON.stringify(summary, null, 2) };
}
return { stdout: formatMemoryCommandSummary(summary, options) };
}
-14
View File
@@ -1,14 +0,0 @@
import { buildInspectionReadModel, coverageRows } from "../inspection-model.ts";
import { buildCoverageJSON, formatCoverage } from "../formatters/coverage.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runCoverage(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const rows = coverageRows(model, options.includeHistorical === true);
if (options.json) {
return { stdout: JSON.stringify(buildCoverageJSON(rows), null, 2) };
}
return { stdout: formatCoverage(rows) };
}
-19
View File
@@ -1,19 +0,0 @@
import { traceMemoryLifecycle } from "../../../src/evidence-log.ts";
import { formatExplain } from "../formatters/explain.ts";
import { formatTrace } from "../formatters/trace.ts";
import { snapshotForOptions } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runExplain(options: CliOptions): Promise<CommandResult> {
if (options.memory) {
const root = options.workspace ?? process.cwd();
const [snapshot, trace] = await Promise.all([
snapshotForOptions(options),
traceMemoryLifecycle(root, { memoryId: options.memory }),
]);
return { stdout: formatTrace(options.memory, snapshot, trace) };
}
const snapshot = await snapshotForOptions(options);
return { stdout: formatExplain(snapshot) };
}
-14
View File
@@ -1,14 +0,0 @@
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
import { buildMissingJSON, formatMissing } from "../formatters/missing.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runMissing(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const rows = disappearanceRows(model);
if (options.json) {
return { stdout: JSON.stringify(buildMissingJSON(rows, { explain: options.explain }), null, 2) };
}
return { stdout: formatMissing(rows, { verbose: options.verbose, explain: options.explain }) };
}
-19
View File
@@ -1,19 +0,0 @@
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 }) };
}
-25
View File
@@ -1,25 +0,0 @@
import { buildRejectedJSON, formatRejected } from "../formatters/rejected.ts";
import { loadRejectionRecords, rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runRejected(options: CliOptions): Promise<CommandResult> {
const { path, invalidLines, records } = await loadRejectionRecords(options);
const summary = rejectionQualitySummary(records);
const falsePositiveRisk = rejectionFalsePositiveRisk(summary);
if (options.json) {
return { stdout: JSON.stringify(buildRejectedJSON({ summary, falsePositiveRisk }), null, 2) };
}
return {
stdout: formatRejected({
path,
invalidLines,
records,
summary,
falsePositiveRisk,
raw: options.raw,
verbose: options.verbose,
}),
};
}
-193
View File
@@ -1,193 +0,0 @@
import { appendEvidenceEvents, queryEvidenceEvents, type EvidenceEventInput, type EvidenceEventV1, type MemoryEvidenceRef } from "../../../src/evidence-log.ts";
import { workspaceMemoryPath } from "../../../src/paths.ts";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../../../src/types.ts";
import { updateWorkspaceMemoryWithAccounting } from "../../../src/workspace-memory.ts";
import { readJSONFile } from "../io.ts";
import { cleanText, truncate } from "../text.ts";
import { CliInputError, type CliOptions, type CommandResult } from "../types.ts";
type ReplacementLink = {
event: EvidenceEventV1;
originalId: string;
replacementId: string;
};
type RevertPlan = ReplacementLink & {
original: LongTermMemoryEntry;
replacement: LongTermMemoryEntry;
};
function reject(message: string): never {
throw new CliInputError(`revert rejected: ${message}`);
}
function memoryRef(memory: LongTermMemoryEntry, status: LongTermMemoryEntry["status"] = memory.status): MemoryEvidenceRef {
return {
memoryId: memory.id,
type: memory.type,
source: memory.source,
status,
};
}
function replacementIdFromEvent(event: EvidenceEventV1): string | undefined {
return event.relations?.find(relation => relation.role === "superseded_by")?.memory?.memoryId;
}
function originalIdFromEvent(event: EvidenceEventV1): string | undefined {
return event.memory?.memoryId
?? event.relations?.find(relation => relation.role === "superseded")?.memory?.memoryId;
}
function replacementLinkFromEvent(event: EvidenceEventV1): ReplacementLink {
if (event.type !== "memory_replaced_numbered_ref") {
reject(`event ${event.eventId} is not a memory_replaced_numbered_ref event`);
}
if (event.outcome !== "superseded") {
reject(`event ${event.eventId} is not a successful numbered replacement`);
}
if (!event.reasonCodes.includes("numbered_ref_replace")) {
reject(`event ${event.eventId} is not a numbered replacement`);
}
const originalId = originalIdFromEvent(event);
const replacementId = replacementIdFromEvent(event);
if (!originalId || !replacementId) {
reject(`event ${event.eventId} does not identify original and replacement memories`);
}
return { event, originalId, replacementId };
}
function selectReplacementLink(events: EvidenceEventV1[], options: CliOptions): ReplacementLink {
if (options.event) {
const event = events.find(item => item.eventId === options.event);
if (!event) reject(`event ${options.event} was not found`);
return replacementLinkFromEvent(event);
}
const memoryId = options.memory;
if (!memoryId) reject("missing --memory or --event selector");
const matches = events
.filter(event => event.type === "memory_replaced_numbered_ref")
.filter(event => replacementIdFromEvent(event) === memoryId);
if (matches.length === 0) {
reject(`replacement memory ${memoryId} was not created by memory_replaced_numbered_ref`);
}
if (matches.length > 1) {
reject(`replacement memory ${memoryId} has ${matches.length} replacement events; use --event`);
}
return replacementLinkFromEvent(matches[0]);
}
function validatePlan(link: ReplacementLink, store: WorkspaceMemoryStore): RevertPlan {
const byId = new Map(store.entries.map(entry => [entry.id, entry]));
const original = byId.get(link.originalId);
const replacement = byId.get(link.replacementId);
if (!original) reject(`original memory ${link.originalId} is missing`);
if (!replacement) reject(`replacement memory ${link.replacementId} is missing`);
if (original.status !== "superseded") reject(`original memory ${original.id} is not superseded`);
if (replacement.status !== "active") reject(`replacement memory ${replacement.id} is not active`);
const laterSuperseder = store.entries.find(entry =>
entry.status === "active"
&& entry.id !== original.id
&& entry.id !== replacement.id
&& (entry.supersedes ?? []).includes(replacement.id)
);
if (laterSuperseder) {
reject(`replacement memory ${replacement.id} is superseded by active memory ${laterSuperseder.id}`);
}
return { ...link, original, replacement };
}
async function dryRunPlan(root: string, link: ReplacementLink): Promise<RevertPlan> {
const rawStore = await readJSONFile<WorkspaceMemoryStore>(await workspaceMemoryPath(root));
const store: WorkspaceMemoryStore = rawStore ?? {
version: 1,
workspace: { root, key: "" },
limits: { maxRenderedChars: 0, maxEntries: 0 },
entries: [],
migrations: [],
updatedAt: new Date(0).toISOString(),
};
return validatePlan(link, store);
}
function revertEvidence(plan: RevertPlan): EvidenceEventInput {
const replacement = { ...plan.replacement, status: "superseded" as const };
const original = { ...plan.original, status: "active" as const };
return {
type: "memory_reverted_numbered_ref",
phase: "storage",
outcome: "recovered",
memory: memoryRef(replacement, "superseded"),
relations: [
{ role: "superseded", memory: memoryRef(replacement, "superseded") },
{ role: "recovered", memory: memoryRef(original, "active") },
],
reasonCodes: ["manual_revert_numbered_ref"],
details: {
replacementEventId: plan.event.eventId,
replacementMemoryId: plan.replacementId,
restoredMemoryId: plan.originalId,
},
textPreview: original.text,
};
}
async function applyPlan(root: string, link: ReplacementLink): Promise<RevertPlan> {
let applied: RevertPlan | undefined;
const updateResult = await updateWorkspaceMemoryWithAccounting(root, store => {
const plan = validatePlan(link, store);
const nowIso = new Date().toISOString();
applied = {
...plan,
original: { ...plan.original, status: "active", updatedAt: nowIso },
replacement: { ...plan.replacement, status: "superseded", updatedAt: nowIso },
};
return {
...store,
entries: store.entries.map(entry => {
if (entry.id === plan.originalId) return applied!.original;
if (entry.id === plan.replacementId) return applied!.replacement;
return entry;
}),
updatedAt: nowIso,
lastActivityAt: nowIso,
};
});
if (!applied) reject("unable to apply revert");
await appendEvidenceEvents(root, [...updateResult.evidence, revertEvidence(applied)]);
return applied;
}
function formatPlan(plan: RevertPlan, applied: boolean): string {
const heading = applied ? "Memory revert applied" : "Memory revert dry run";
const nextStep = applied ? "Changes applied." : "No changes applied. Re-run with --apply to mutate workspace memory.";
return [
heading,
"",
"Planned changes:",
` - replacement: ${plan.replacementId} active -> superseded`,
` - original: ${plan.originalId} superseded -> active`,
` - replacement event: ${plan.event.eventId}`,
` - restored text: ${truncate(cleanText(plan.original.text, false), 100)}`,
"",
nextStep,
].join("\n");
}
export async function runRevert(options: CliOptions): Promise<CommandResult> {
const root = options.workspace ?? process.cwd();
const events = await queryEvidenceEvents(root);
const link = selectReplacementLink(events, options);
const plan = options.apply ? await applyPlan(root, link) : await dryRunPlan(root, link);
return { stdout: formatPlan(plan, options.apply === true) };
}
-112
View File
@@ -1,112 +0,0 @@
import { RETENTION_TYPE_MAX, WORKSPACE_DORMANT_AFTER_DAYS } from "../../../src/retention.ts";
import { TYPES } from "../constants.ts";
import { formatStatus, type StatusReadout } from "../formatters/status.ts";
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
import { retentionCandidatesForDiag, retentionClockSummary, daysSinceIso } from "../retention-model.ts";
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import { isWithinDays, memoryDiagJSONFromSnapshot } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult, MemoryInspectionReadModel } from "../types.ts";
export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.now()): StatusReadout {
const active = model.store.entries.filter(entry => entry.status !== "superseded");
const retention = retentionCandidatesForDiag(model.store, now);
const clocks = retentionClockSummary(active);
const disappearances = disappearanceRows(model);
const evidenceCovered = active.filter(entry => (model.evidenceByMemoryId.get(entry.id) ?? []).length > 0).length;
const rejectionSummary = rejectionQualitySummary(model.rejectionRecords);
const falsePositiveRisk = rejectionFalsePositiveRisk(rejectionSummary);
const typeCounts = Object.fromEntries(TYPES.map(type => [type, active.filter(entry => entry.type === type).length]));
const capsFull = active.length >= model.store.limits.maxEntries || TYPES.some(type => (typeCounts[type] ?? 0) >= RETENTION_TYPE_MAX[type]);
const unknownDisappearances = disappearances.filter(row => row.classification === "historical_absent_unknown_reason").length;
const status = unknownDisappearances > 0 || clocks.invalid > 0
? "degraded"
: capsFull || rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high"
? "warning"
: "ok";
const rejectedLast7Days = model.evidenceEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7, now)).length;
const evidenceCoveragePercent = active.length === 0 ? 100 : Math.round((evidenceCovered / active.length) * 100);
const needsAttention: string[] = [];
if (clocks.invalid > 0) needsAttention.push(`${clocks.invalid} invalid retention clocks`);
if (unknownDisappearances > 0) needsAttention.push(`${unknownDisappearances} memories disappeared without terminal evidence`);
if (capsFull) needsAttention.push("memory store is at or over retention caps");
if (rejectionSummary.legacyUnscopedCount > 0) needsAttention.push(`${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records`);
if (falsePositiveRisk === "high") needsAttention.push("possible rejection false-positive risk is high");
const suggestedNextSteps: string[] = [];
if (clocks.invalid > 0 || unknownDisappearances > 0) suggestedNextSteps.push("Run memory-diag missing --verbose to inspect missing-memory evidence.");
if (capsFull) suggestedNextSteps.push("Run memory-diag explain to see which memories are hidden by caps.");
if (rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high") suggestedNextSteps.push("Run memory-diag rejected --verbose to inspect rejection quality.");
const wallDaysSinceActivity = daysSinceIso(model.store.lastActivityAt, now);
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS;
const dormantDaysPastGrace = wallDaysSinceActivity === null ? 0 : Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
return {
status,
summaryText: `Summary: Workspace memory quality is ${status}: ${active.length} active memories, ${evidenceCovered}/${active.length} with evidence, ${disappearances.length} evidence-only disappearances (${unknownDisappearances} unknown), ${clocks.invalid} invalid retention clocks, and ${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records.`,
active: active.length,
rendered: retention.rendered.length,
pending: model.pending.entries.length,
rejectedLast7Days,
evidenceCoveragePercent,
needsAttention,
suggestedNextSteps,
caps: {
active: active.length,
maxEntries: model.store.limits.maxEntries,
rendered: retention.rendered.length,
typeCapped: retention.typeCapped.length,
globalCapped: retention.globalCapped.length,
typeCounts,
capsFull,
},
retention: clocks,
evidence: {
currentWithEvidence: evidenceCovered,
currentWithoutEvidence: active.length - evidenceCovered,
evidenceMemoryIds: model.evidenceByMemoryId.size,
disappearances: disappearances.length,
unknownDisappearances,
withTerminalReason: disappearances.length - unknownDisappearances,
},
rejections: { ...rejectionSummary, falsePositiveRisk },
dormant: {
lastActivityAt: model.store.lastActivityAt,
wallDaysSinceActivity,
dormantDiscountActive,
dormantDaysPastGrace,
},
topRendered: retention.rendered.slice(0, 5),
weakestActive: retention.sorted.slice(-5).reverse(),
};
}
export async function runStatus(options: CliOptions): Promise<CommandResult> {
const now = Date.now();
const model = await buildInspectionReadModel(options);
const readout = buildStatusReadout(model, now);
if (options.json) {
const root = options.workspace ?? process.cwd();
const diag = memoryDiagJSONFromSnapshot(root, model.snapshot);
return {
stdout: JSON.stringify({
...diag,
summary: {
...diag.summary,
status: readout.status,
evidenceCoveragePercent: readout.evidenceCoveragePercent,
needsAttention: readout.needsAttention,
suggestedNextSteps: readout.suggestedNextSteps,
},
}, null, 2),
};
}
return {
stdout: formatStatus(readout, model, {
verbose: options.verbose,
noEmoji: options.noEmoji,
isTty: process.stdout.isTTY === true,
raw: options.raw,
}),
};
}
-21
View File
@@ -1,21 +0,0 @@
import type { LongTermType } from "../../src/types.ts";
import type { Origin } from "./types.ts";
export const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
export const SUSPICIOUS_REASONS = [
"progress_snapshot",
"active_file_snapshot",
"commit_or_ci_snapshot",
"temporary_status",
"raw_error",
"code_or_api_signature",
] as const;
export const ALLOWED_ORIGINS = new Set<Origin>([
"explicit_trigger",
"compaction_candidate",
"manual",
"migration_check",
"unknown",
]);
-21
View File
@@ -1,21 +0,0 @@
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import { uniqueStrings } from "./text.ts";
export function eventMemoryIds(event: EvidenceEventV1): string[] {
return uniqueStrings([
event.memory?.memoryId ?? "",
...(event.relations ?? []).map(relation => relation.memory?.memoryId ?? ""),
]);
}
export function groupEvidenceByMemoryId(events: EvidenceEventV1[]): Map<string, EvidenceEventV1[]> {
const grouped = new Map<string, EvidenceEventV1[]>();
for (const event of events) {
for (const id of eventMemoryIds(event)) {
const bucket = grouped.get(id) ?? [];
bucket.push(event);
grouped.set(id, bucket);
}
}
return grouped;
}
-48
View File
@@ -1,48 +0,0 @@
import { cleanPath, cleanText, countBy, formatWorkspaceIdentity, sortedCounts, truncate } from "../text.ts";
import type { MigrationLogRecord } from "../types.ts";
export type MigrationAuditReport = {
migrationId: string;
path: string;
invalidLines: number;
superseded: MigrationLogRecord[];
hardReasons: string[];
risky: Array<{ record: MigrationLogRecord; reasons: string[] }>;
};
export function formatMigrationAudit(reports: MigrationAuditReport[], options: { raw: boolean }): string {
const lines: string[] = [];
lines.push("Migration audit report");
lines.push("");
if (reports.length === 0) {
lines.push("No migration logs found.");
return lines.join("\n");
}
for (let i = 0; i < reports.length; i += 1) {
const report = reports[i];
if (i > 0) lines.push("");
lines.push(`Migration: ${report.migrationId}`);
lines.push(`logPath=${cleanPath(report.path, options.raw)}`);
if (report.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${report.invalidLines}`);
lines.push(`Superseded entries: ${report.superseded.length}`);
lines.push("");
lines.push("By hard reason:");
const byHardReason = sortedCounts(countBy(report.hardReasons));
if (byHardReason.length === 0) lines.push(" (none)");
else for (const [reason, count] of byHardReason) lines.push(` ${reason.padEnd(24)} ${count}`);
lines.push("");
lines.push("Potentially risky supersedes:");
lines.push(` ${report.risky.length}`);
for (const item of report.risky.slice(0, 10)) {
const record = item.record;
const hard = Array.isArray(record.hardReasons) ? record.hardReasons : [];
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
lines.push(` - [${record.type ?? "unknown"}] hardReasons=${JSON.stringify(hard)} risk=${item.reasons.join(",")} ${JSON.stringify(truncate(cleanText(record.text ?? "", options.raw)))}`);
if (identity) lines.push(` ${identity}`);
}
}
return lines.join("\n");
}
@@ -1,34 +0,0 @@
import type { coverageRows } from "../inspection-model.ts";
import { countBy, objectFromCounts } from "../text.ts";
import type { CoverageClass } from "../types.ts";
export type CoverageRows = ReturnType<typeof coverageRows>;
export function buildCoverageJSON(rows: CoverageRows, generatedAt = new Date().toISOString()): Record<string, unknown> {
const classCounts = objectFromCounts(countBy(rows.map(row => row.class)));
return {
version: 1,
generatedAt,
classCounts,
memories: rows,
};
}
export function formatCoverage(rows: CoverageRows): string {
const classCounts = objectFromCounts(countBy(rows.map(row => row.class)));
const lines: string[] = [];
lines.push("Memory evidence coverage");
lines.push("");
lines.push("Class counts:");
for (const cls of ["full_lifecycle", "render_only", "no_evidence", "historical_absent_with_reason", "historical_absent_unknown_reason"] as CoverageClass[]) {
lines.push(` ${cls}: ${classCounts[cls] ?? 0}`);
}
lines.push("");
lines.push("Per-memory rows:");
if (rows.length === 0) lines.push(" (none)");
for (const row of rows) {
const phases = row.eventCounts.byPhase;
lines.push(` ${row.id} ${row.class} current=${row.current ? "yes" : "no"} total=${row.eventCounts.total} extraction=${phases.extraction ?? 0} promotion=${phases.promotion ?? 0} render=${phases.render ?? 0} storage=${phases.storage ?? 0}`);
}
return lines.join("\n");
}
-42
View File
@@ -1,42 +0,0 @@
import type { EvidenceEventV1 } from "../../../src/evidence-log.ts";
import { formatStrength } from "../retention-model.ts";
import type { WorkspaceDiagSnapshot } from "../types.ts";
export function formatEvidenceRefs(eventIds: string[], allEvents: EvidenceEventV1[]): string {
if (eventIds.length === 0) return "(none)";
const byId = new Map(allEvents.map(event => [event.eventId, event]));
return eventIds
.map(id => {
const event = byId.get(id);
return event ? `${event.eventId} ${event.type}` : id;
})
.join(", ");
}
export function formatExplain(snapshot: WorkspaceDiagSnapshot): string {
const lines: string[] = [];
lines.push("Workspace memory explainability");
lines.push("");
if (snapshot.memories.length === 0) {
lines.push("No memories found.");
}
for (const memory of snapshot.memories) {
lines.push(`Memory ${memory.id}: ${memory.status}`);
const strength = typeof memory.strength === "number" ? formatStrength(memory.strength) : "n/a";
lines.push(`- strength=${strength}, type=${memory.type}, source=${memory.source}`);
lines.push(`- reasons: ${memory.reasonCodes.length > 0 ? memory.reasonCodes.join(", ") : "(none)"}`);
lines.push(`- evidence: ${formatEvidenceRefs(memory.evidenceEventIds, snapshot.allEvents)}`);
lines.push("");
}
const quarantines = snapshot.recentEvents.filter(event => event.type === "storage_corrupt_json_quarantined");
if (quarantines.length > 0) {
lines.push("Quarantined stores:");
for (const event of quarantines) {
lines.push(`- quarantined_corrupt_store: ${event.eventId} ${event.type}; reasons=${event.reasonCodes.join(",")}`);
}
}
return lines.join("\n");
}
-102
View File
@@ -1,102 +0,0 @@
import type { disappearanceRows } from "../inspection-model.ts";
import { eventCounts } from "../inspection-model.ts";
import { formatDetails } from "../text.ts";
export type DisappearanceRows = ReturnType<typeof disappearanceRows>;
export type MissingSummary = {
total: number;
explained: number;
needsReview: number;
};
export function missingSummary(rows: DisappearanceRows): MissingSummary {
return {
total: rows.length,
explained: rows.filter(row => row.classification === "historical_absent_with_reason").length,
needsReview: rows.filter(row => row.classification === "historical_absent_unknown_reason").length,
};
}
export function buildMissingJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
return {
...buildDisappearancesJSON(rows, options),
summary: missingSummary(rows),
};
}
function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
return {
version: 1,
generatedAt: options.generatedAt ?? new Date().toISOString(),
disappearances: rows.map(row => ({
id: row.id,
classification: row.classification,
terminalType: row.terminalType,
reasonCodes: row.reasonCodes,
eventCounts: eventCounts(row.events),
details: options.explain ? row.event?.details : undefined,
})),
};
}
function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string {
const lines: string[] = [];
lines.push("Memory disappearances");
lines.push("");
if (rows.length === 0) {
lines.push("No evidence-only memories found.");
return lines.join("\n");
}
for (const row of rows) {
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`);
if (options.explain) {
lines.push(` events: ${row.events.map(event => event.type).join(", ")}`);
if (row.event?.type === "memory_removed_capacity") {
lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`);
}
const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"));
if (renderTypeCap) {
lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`);
}
}
}
return lines.join("\n");
}
export function formatMissing(rows: DisappearanceRows, options: { verbose?: boolean; explain?: boolean } = {}): string {
const summary = missingSummary(rows);
const lines: string[] = [];
lines.push("Missing memory summary");
lines.push("");
if (rows.length === 0) {
lines.push("No missing memories found.");
return lines.join("\n");
}
lines.push(`Total missing: ${summary.total}`);
lines.push(`Explained: ${summary.explained}`);
lines.push(`Needs review: ${summary.needsReview}`);
lines.push("");
const unknownRows = rows
.filter(row => row.classification === "historical_absent_unknown_reason")
.slice(0, 5);
lines.push("Unknown disappearance samples:");
if (unknownRows.length === 0) {
lines.push(" (none)");
} else {
for (const row of unknownRows) {
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
lines.push(` - ${row.id} terminal=${row.terminalType} reasons=${reasons}`);
}
}
if (options.verbose || options.explain) {
lines.push("");
lines.push(formatDisappearances(rows, { explain: true }));
}
return lines.join("\n");
}
-467
View File
@@ -1,467 +0,0 @@
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)]));
}
@@ -1,93 +0,0 @@
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import { cleanPath, cleanText, countBy, sortedCounts, truncate } from "../text.ts";
import type { NormalizedRejection } from "../types.ts";
export type RejectedSummary = ReturnType<typeof rejectionQualitySummary>;
export type RejectedFalsePositiveRisk = ReturnType<typeof rejectionFalsePositiveRisk>;
export function buildRejectedJSON(input: {
summary: RejectedSummary;
falsePositiveRisk: RejectedFalsePositiveRisk;
generatedAt?: string;
}): Record<string, unknown> {
return {
version: 1,
generatedAt: input.generatedAt ?? new Date().toISOString(),
...input.summary,
falsePositiveRisk: input.falsePositiveRisk,
};
}
export function formatRejected(input: {
path: string;
invalidLines: number;
records: NormalizedRejection[];
summary: RejectedSummary;
falsePositiveRisk: RejectedFalsePositiveRisk;
raw: boolean;
verbose?: boolean;
}): string {
const lines: string[] = [];
lines.push("Rejected memory summary");
lines.push("");
lines.push(`Total rejected: ${input.summary.totalRecords}`);
lines.push(`Unique texts: ${input.summary.uniqueTexts}`);
lines.push("");
lines.push("Top reasons:");
const byReason = sortedCounts(countBy(input.records.flatMap(record => record.reasons))).slice(0, 5);
if (byReason.length === 0) lines.push(" (none)");
else for (const [reason, count] of byReason) lines.push(` ${reason.padEnd(36)} ${count}`);
lines.push("");
lines.push(`False-positive risk: ${input.falsePositiveRisk}`);
if (input.falsePositiveRisk === "high") {
lines.push("⚠️ Possible false positives detected. Use --verbose to inspect samples.");
}
lines.push("");
lines.push("Recent samples:");
const samples = [...input.records]
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
.slice(0, 5);
if (samples.length === 0) {
lines.push(" (none)");
} else {
for (const record of samples) {
lines.push(` - [${record.type}] ${truncate(cleanText(record.text, input.raw))}`);
lines.push(` reasons: ${record.reasons.length > 0 ? record.reasons.join(",") : "none"}`);
}
}
if (!input.verbose) return lines.join("\n");
lines.push("");
lines.push("Possible false-positive grouping is heuristic, not deterministic truth.");
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
lines.push("");
lines.push("Reason distribution (raw records):");
for (const [reason, count] of Object.entries(input.summary.reasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
if (Object.keys(input.summary.reasonDistribution).length === 0) lines.push(" (none)");
lines.push("");
lines.push("Reason distribution (unique text):");
for (const [reason, count] of Object.entries(input.summary.uniqueReasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
if (Object.keys(input.summary.uniqueReasonDistribution).length === 0) lines.push(" (none)");
lines.push("");
lines.push("By origin:");
const byOrigin = sortedCounts(countBy(input.records.map(record => record.origin)));
if (byOrigin.length === 0) lines.push(" (none)");
else for (const [origin, count] of byOrigin) lines.push(` ${origin.padEnd(36)} ${count}`);
lines.push("");
lines.push("Possible false-positive groups (heuristic, not deterministic):");
for (const [group, data] of Object.entries(input.summary.possibleFalsePositiveGroups)) {
lines.push(` ${group}: ${data.count}`);
for (const sample of data.samples) lines.push(` - ${JSON.stringify(cleanText(sample, input.raw))}`);
}
return lines.join("\n");
}
-155
View File
@@ -1,155 +0,0 @@
import {
DORMANT_DECAY_MULTIPLIER,
RETENTION_TYPE_MAX,
} from "../../../src/retention.ts";
import { TYPES } from "../constants.ts";
import { daysSinceIso, formatStrength } from "../retention-model.ts";
import { cleanText, truncate } from "../text.ts";
import type { MemoryInspectionReadModel, RetentionDiagItem } from "../types.ts";
export type MemoryStatusLevel = "ok" | "warning" | "degraded";
export type StatusReadout = {
status: MemoryStatusLevel;
summaryText: string;
active: number;
rendered: number;
pending: number;
rejectedLast7Days: number;
evidenceCoveragePercent: number;
needsAttention: string[];
suggestedNextSteps: string[];
caps: {
active: number;
maxEntries: number;
rendered: number;
typeCapped: number;
globalCapped: number;
typeCounts: Record<string, number>;
capsFull: boolean;
};
retention: { present: number; missing: number; invalid: number };
evidence: {
currentWithEvidence: number;
currentWithoutEvidence: number;
evidenceMemoryIds: number;
disappearances: number;
unknownDisappearances: number;
withTerminalReason: number;
};
rejections: {
totalRecords: number;
workspaceScopedCount: number;
legacyUnscopedCount: number;
falsePositiveRisk: string;
};
dormant: {
lastActivityAt?: string;
wallDaysSinceActivity: number | null;
dormantDiscountActive: boolean;
dormantDaysPastGrace: number;
};
topRendered: RetentionDiagItem[];
weakestActive: RetentionDiagItem[];
};
export function formatStatus(readout: StatusReadout, model: MemoryInspectionReadModel, options: { verbose?: boolean; noEmoji?: boolean; isTty?: boolean; raw?: boolean } = {}): string {
if (options.verbose) return formatStatusVerbose(readout, model, options);
const lines: string[] = [];
const label = statusLabel(readout.status, options);
lines.push(`${label} Memory status`);
lines.push("");
lines.push("Key metrics:");
lines.push(` active memories: ${readout.active}`);
lines.push(` rendered: ${readout.rendered}`);
lines.push(` pending: ${readout.pending}`);
lines.push(` rejected 7d: ${readout.rejectedLast7Days}`);
lines.push(` evidence coverage: ${readout.evidenceCoveragePercent}%`);
if (readout.needsAttention.length > 0) {
lines.push("");
lines.push("Needs attention:");
for (const item of readout.needsAttention) lines.push(` - ${item}`);
}
if (readout.status !== "ok" && readout.suggestedNextSteps.length > 0) {
lines.push("");
lines.push("Suggested next steps:");
for (const item of readout.suggestedNextSteps) lines.push(` - ${item}`);
}
return lines.join("\n");
}
function statusLabel(status: MemoryStatusLevel, options: { noEmoji?: boolean; isTty?: boolean }): string {
const text = status === "ok" ? "OK" : status === "warning" ? "WARNING" : "DEGRADED";
if (options.noEmoji || !options.isTty) return text;
const emoji = status === "ok" ? "🧠" : status === "warning" ? "⚠️" : "✖️";
return `${emoji} ${text}`;
}
function formatStatusVerbose(readout: StatusReadout, model: MemoryInspectionReadModel, options: { raw?: boolean } = {}): string {
const lines: string[] = [];
lines.push("Memory status inspection");
lines.push("");
lines.push(readout.summaryText);
lines.push("");
lines.push("Caps:");
lines.push(` active: ${readout.caps.active} / ${readout.caps.maxEntries}`);
for (const type of TYPES) {
const count = readout.caps.typeCounts[type] ?? 0;
const limit = RETENTION_TYPE_MAX[type];
const marker = count >= limit ? " FULL" : "";
lines.push(` ${type}: ${count} / ${limit}${marker}`);
}
lines.push(` rendered: ${readout.caps.rendered}`);
lines.push(` type-capped entries: ${readout.caps.typeCapped}`);
lines.push(` global-cap overflow: ${readout.caps.globalCapped}`);
lines.push(` caps full: ${readout.caps.capsFull ? "yes" : "no"}`);
lines.push("");
lines.push("Retention clocks:");
lines.push(` present: ${readout.retention.present}`);
lines.push(` missing: ${readout.retention.missing}`);
lines.push(` invalid: ${readout.retention.invalid}`);
lines.push("");
lines.push("Evidence:");
lines.push(` current with evidence: ${readout.evidence.currentWithEvidence}`);
lines.push(` current without evidence: ${readout.evidence.currentWithoutEvidence}`);
lines.push(` evidence memory ids: ${readout.evidence.evidenceMemoryIds}`);
lines.push(` disappearances: ${readout.evidence.disappearances}`);
lines.push(` unknown disappearances: ${readout.evidence.unknownDisappearances}`);
lines.push("");
lines.push("Rejection scoping:");
lines.push(` total records: ${readout.rejections.totalRecords}`);
lines.push(` workspace scoped: ${readout.rejections.workspaceScopedCount}`);
lines.push(` legacy unscoped: ${readout.rejections.legacyUnscopedCount}`);
lines.push(` false-positive risk: ${readout.rejections.falsePositiveRisk}`);
lines.push("");
lines.push("Dormancy:");
lines.push(` lastActivityAt: ${readout.dormant.lastActivityAt ?? "(missing)"}`);
lines.push(` wall days since activity: ${readout.dormant.wallDaysSinceActivity === null ? "unknown" : readout.dormant.wallDaysSinceActivity.toFixed(1)}`);
lines.push(` dormant discount active: ${readout.dormant.dormantDiscountActive ? "yes" : "no"}`);
lines.push(` dormant days past grace: ${readout.dormant.dormantDaysPastGrace.toFixed(1)}`);
lines.push(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`);
lines.push("");
lines.push("Top rendered candidates:");
pushMemoryItems(lines, readout.topRendered, options.raw === true);
lines.push("");
lines.push("Weakest active memories:");
pushMemoryItems(lines, readout.weakestActive, options.raw === true);
lines.push("");
lines.push("Store:");
lines.push(` superseded: ${model.store.entries.length - readout.active}`);
return lines.join("\n");
}
function pushMemoryItems(lines: string[], items: RetentionDiagItem[], raw: boolean): void {
if (items.length === 0) {
lines.push(" (none)");
return;
}
for (const item of items) {
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, raw))}`);
}
}
-36
View File
@@ -1,36 +0,0 @@
import type { EvidenceEventV1 } from "../../../src/evidence-log.ts";
import { formatTraceEvent, relationMemoryIds, statusFromTraceEvent } from "../trace-model.ts";
import type { WorkspaceDiagSnapshot } from "../types.ts";
export function formatTrace(memoryId: string, snapshot: WorkspaceDiagSnapshot, trace: { events: EvidenceEventV1[] }): string {
const lines: string[] = [];
const memoryRow = snapshot.memories.find(memory => memory.id === memoryId);
const status = memoryRow?.status ?? statusFromTraceEvent(trace.events.at(-1));
lines.push(`Memory ${memoryId}: ${status}`);
lines.push("");
lines.push("Lifecycle:");
if (trace.events.length === 0) {
lines.push("(none)");
return lines.join("\n");
}
for (const event of trace.events) {
lines.push(formatTraceEvent(event));
}
const supersededBy = relationMemoryIds(trace.events, "superseded_by");
if (supersededBy.length > 0) {
lines.push("");
lines.push("Superseded by:");
for (const id of supersededBy) lines.push(`- ${id}`);
}
const reinforcedBy = relationMemoryIds(trace.events, "reinforced_by");
if (reinforcedBy.length > 0) {
lines.push("");
lines.push("Reinforced by:");
for (const id of reinforcedBy) lines.push(`- ${id}`);
}
return lines.join("\n");
}
-98
View File
@@ -1,98 +0,0 @@
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import type { LongTermType } from "../../src/types.ts";
import { countBy, objectFromCounts, uniqueStrings } from "./text.ts";
import { groupEvidenceByMemoryId } from "./evidence-model.ts";
import { loadRejectionRecords } from "./rejections-model.ts";
import { snapshotForOptions } from "./workspace-snapshot.ts";
import type { CliOptions, CoverageClass, DisappearanceReason, MemoryInspectionReadModel } from "./types.ts";
export async function buildInspectionReadModel(options: CliOptions): Promise<MemoryInspectionReadModel> {
const snapshot = await snapshotForOptions(options);
const rejections = await loadRejectionRecords(options);
return {
snapshot,
store: snapshot.store,
pending: snapshot.journal,
evidenceEvents: snapshot.allEvents,
rejectionRecords: rejections.records,
currentById: new Map(snapshot.store.entries.map(entry => [entry.id, entry])),
evidenceByMemoryId: groupEvidenceByMemoryId(snapshot.allEvents),
};
}
export function terminalDisappearanceReason(events: EvidenceEventV1[]): DisappearanceReason {
const terminal = [...events].reverse().find(event =>
event.type === "memory_removed_capacity"
|| event.type === "promotion_absorbed_exact"
|| event.type === "promotion_absorbed_identity"
|| event.type === "promotion_superseded"
);
const renderOmission = [...events].reverse().find(event => event.type === "render_omitted");
const event = terminal ?? renderOmission;
if (!event) {
return { classification: "historical_absent_unknown_reason", terminalType: "unknown", reasonCodes: [] };
}
return {
classification: "historical_absent_with_reason",
terminalType: event.type,
reasonCodes: event.reasonCodes,
event,
};
}
export function coverageClassForMemory(id: string, model: MemoryInspectionReadModel): CoverageClass {
const events = model.evidenceByMemoryId.get(id) ?? [];
if (!model.currentById.has(id)) return terminalDisappearanceReason(events).classification;
if (events.length === 0) return "no_evidence";
if (events.every(event => event.phase === "render")) return "render_only";
return "full_lifecycle";
}
export function eventCounts(events: EvidenceEventV1[]): { total: number; byType: Record<string, number>; byPhase: Record<string, number> } {
return {
total: events.length,
byType: objectFromCounts(countBy(events.map(event => event.type))),
byPhase: objectFromCounts(countBy(events.map(event => event.phase))),
};
}
export function coverageRows(model: MemoryInspectionReadModel, includeHistorical: boolean): Array<{
id: string;
class: CoverageClass;
current: boolean;
type?: LongTermType;
eventCounts: ReturnType<typeof eventCounts>;
}> {
const ids = new Set<string>(model.currentById.keys());
if (includeHistorical) {
for (const id of model.evidenceByMemoryId.keys()) ids.add(id);
}
return [...ids].sort().map(id => {
const entry = model.currentById.get(id);
const events = model.evidenceByMemoryId.get(id) ?? [];
return {
id,
class: coverageClassForMemory(id, model),
current: Boolean(entry),
type: entry?.type ?? events.find(event => event.memory?.memoryId === id)?.memory?.type,
eventCounts: eventCounts(events),
};
});
}
export function disappearanceRows(model: MemoryInspectionReadModel): Array<{
id: string;
classification: DisappearanceReason["classification"];
terminalType: DisappearanceReason["terminalType"];
reasonCodes: string[];
event?: EvidenceEventV1;
events: EvidenceEventV1[];
}> {
const rows = [...model.evidenceByMemoryId.entries()]
.filter(([id]) => !model.currentById.has(id))
.map(([id, events]) => {
const reason = terminalDisappearanceReason(events);
return { id, ...reason, events };
});
return rows.sort((a, b) => a.classification.localeCompare(b.classification) || a.id.localeCompare(b.id));
}
-36
View File
@@ -1,36 +0,0 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
export async function readJSONFile<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch {
return null;
}
}
export async function readJSONLFile<T>(path: string): Promise<{ records: T[]; invalidLines: number }> {
let content = "";
try {
content = await readFile(path, "utf8");
} catch {
return { records: [], invalidLines: 0 };
}
const records: T[] = [];
let invalidLines = 0;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
records.push(JSON.parse(trimmed) as T);
} catch {
invalidLines += 1;
}
}
return { records, invalidLines };
}
export function pathExists(path: string): boolean {
return existsSync(path);
}
-42
View File
@@ -1,42 +0,0 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { dataHome, migrationLogPath } from "../../src/paths.ts";
import type { CliOptions, MigrationLogRecord } from "./types.ts";
export function migrationLogsRoot(): string {
return join(dataHome(), "opencode-working-memory", "migration-logs");
}
export async function migrationLogPaths(options: CliOptions): Promise<string[]> {
if (options.migration) return [migrationLogPath(options.migration)];
const root = migrationLogsRoot();
let entries: string[] = [];
try {
entries = await readdir(root);
} catch {
return [];
}
return entries.filter(entry => entry.endsWith(".jsonl")).sort().map(entry => join(root, entry));
}
export function migrationIdFromPath(path: string): string {
return path.split("/").pop()?.replace(/\.jsonl$/, "") ?? "unknown";
}
export function riskySupersedeReasons(record: MigrationLogRecord): string[] {
const reasons: string[] = [];
const hardReasonsMissing = !Array.isArray(record.hardReasons);
const hardReasons = Array.isArray(record.hardReasons) ? record.hardReasons : [];
const qualityReasons = Array.isArray(record.reasons) ? record.reasons : [];
const text = record.text ?? "";
if (hardReasonsMissing || hardReasons.length === 0) reasons.push("missing_or_empty_hardReasons");
if (qualityReasons.length > 0 && hardReasons.length === 0) reasons.push("soft_reasons_without_hardReasons");
if (/\b(?:User|user|prefers|requires|wants|insists)\b|用戶|使用者|偏好|要求|不要|不刪除/u.test(text)) reasons.push("user_preference_marker");
if (/\b(?:must|should|do not|never|is|are|follows)\b|必須|應該|採用|維持|需支援/iu.test(text)) reasons.push("durable_rule_marker");
if ((record.type === "feedback" || record.type === "decision") && hardReasons.length === 1 && hardReasons[0] === "path_heavy") {
reasons.push("feedback_or_decision_path_heavy_only");
}
return reasons;
}
File diff suppressed because it is too large Load Diff
-141
View File
@@ -1,141 +0,0 @@
import { extractionRejectionLogPath, workspaceKey } from "../../src/paths.ts";
import { HARD_QUALITY_REASONS, isArchitectureLikeDecision } from "../../src/memory-quality.ts";
import { ALLOWED_ORIGINS } from "./constants.ts";
import { readJSONLFile } from "./io.ts";
import { canonicalMemoryText, cleanText, countBy, objectFromCounts, truncate } from "./text.ts";
import { CliInputError, type CliOptions, type NormalizedRejection, type Origin, type RejectionLogRecord } from "./types.ts";
export { CliInputError } from "./types.ts";
export function inferOrigin(record: RejectionLogRecord): Origin {
if (record.origin && ALLOWED_ORIGINS.has(record.origin as Origin)) return record.origin as Origin;
if (record.source === "compaction") return "compaction_candidate";
if (record.source === "explicit") return "explicit_trigger";
if (record.source === "manual") return "manual";
return "unknown";
}
export function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | null {
if (!record.text || !Array.isArray(record.reasons)) return null;
const origin = inferOrigin(record);
return {
timestamp: record.timestamp ?? "",
workspaceKey: record.workspaceKey,
workspaceRoot: record.workspaceRoot,
workspaceRootHash: record.workspaceRootHash,
type: record.type ?? "project",
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,
};
}
export function sinceCutoff(rawSince: string | undefined, now = Date.now()): number | null {
if (!rawSince) return null;
const relative = rawSince.match(/^(\d+)([dhm])$/i);
if (relative) {
const amount = Number(relative[1]);
const unit = relative[2].toLowerCase();
const multiplier = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : 60_000;
return now - amount * multiplier;
}
const timestamp = new Date(rawSince).getTime();
if (Number.isNaN(timestamp)) throw new CliInputError(`Invalid --since value: ${rawSince}`);
return timestamp;
}
export function hasSoftReason(record: NormalizedRejection): boolean {
return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason));
}
export function hasWorkspaceScope(record: NormalizedRejection): boolean {
return Boolean(record.workspaceKey || record.workspaceRoot || record.workspaceRootHash);
}
export async function workspaceKeyForOption(options: CliOptions): Promise<string | undefined> {
return options.workspace ? workspaceKey(options.workspace) : undefined;
}
export async function loadRejectionRecords(options: CliOptions): Promise<{ path: string; invalidLines: number; records: NormalizedRejection[] }> {
const path = extractionRejectionLogPath();
const { records, invalidLines } = await readJSONLFile<RejectionLogRecord>(path);
const cutoff = sinceCutoff(options.since);
const requestedWorkspaceKey = await workspaceKeyForOption(options);
let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null);
if (requestedWorkspaceKey) {
normalized = normalized.filter(record => !record.workspaceKey || record.workspaceKey === requestedWorkspaceKey);
}
if (cutoff !== null) {
normalized = normalized.filter(record => {
const timestamp = new Date(record.timestamp).getTime();
return !Number.isNaN(timestamp) && timestamp >= cutoff;
});
}
if (options.softOnly) normalized = normalized.filter(hasSoftReason);
if (options.triggerOnly) normalized = normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger");
if (options.reason) normalized = normalized.filter(record => record.reasons.includes(options.reason ?? ""));
return { path, invalidLines, records: normalized };
}
export function uniqueByCanonicalText(records: NormalizedRejection[]): NormalizedRejection[] {
const byText = new Map<string, NormalizedRejection>();
for (const record of records) {
const key = `${record.type}:${canonicalMemoryText(record.text)}`;
if (!byText.has(key)) byText.set(key, record);
}
return [...byText.values()];
}
export function rejectionQualitySummary(records: NormalizedRejection[]): {
totalRecords: number;
uniqueTexts: number;
workspaceScopedCount: number;
legacyUnscopedCount: number;
reasonDistribution: Record<string, number>;
uniqueReasonDistribution: Record<string, number>;
possibleFalsePositiveGroups: Record<string, { count: number; samples: string[] }>;
} {
const uniqueRecords = uniqueByCanonicalText(records);
const badDecisionUnique = uniqueRecords.filter(record => record.reasons.includes("bad_decision"));
const groups: Record<string, { count: number; samples: string[] }> = {
architecture_like_possible_false_positive: { count: 0, samples: [] },
clearly_garbage: { count: 0, samples: [] },
ambiguous: { count: 0, samples: [] },
};
for (const record of badDecisionUnique) {
const hardReasons = record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason));
const statusLike = /\b(?:implemented|added|updated|fixed|completed|reviewed|tests?|CI|commit|wave|phase|task|session)\b/i.test(record.text);
const group = isArchitectureLikeDecision(record.text) && hardReasons.length === 0 && !statusLike
? "architecture_like_possible_false_positive"
: hardReasons.length > 0 || statusLike
? "clearly_garbage"
: "ambiguous";
groups[group].count += 1;
if (groups[group].samples.length < 5) groups[group].samples.push(truncate(cleanText(record.text, false), 120));
}
return {
totalRecords: records.length,
uniqueTexts: uniqueRecords.length,
workspaceScopedCount: records.filter(hasWorkspaceScope).length,
legacyUnscopedCount: records.filter(record => !hasWorkspaceScope(record)).length,
reasonDistribution: objectFromCounts(countBy(records.flatMap(record => record.reasons))),
uniqueReasonDistribution: objectFromCounts(countBy(uniqueRecords.flatMap(record => record.reasons))),
possibleFalsePositiveGroups: groups,
};
}
export function rejectionFalsePositiveRisk(summary: ReturnType<typeof rejectionQualitySummary>): "low" | "high" {
const possible = summary.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count;
return possible >= 3 || (summary.uniqueTexts > 0 && possible / summary.uniqueTexts >= 0.5) ? "high" : "low";
}
-84
View File
@@ -1,84 +0,0 @@
import {
RETENTION_TYPE_MAX,
calculateRetentionStrength,
} from "../../src/retention.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, WorkspaceMemoryStore } from "../../src/types.ts";
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../../src/types.ts";
import type { RetentionDiagItem } from "./types.ts";
export function ageDays(entry: LongTermMemoryEntry, now = Date.now()): number | null {
const time = new Date(entry.createdAt).getTime();
if (Number.isNaN(time)) return null;
return Math.floor((now - time) / 86_400_000);
}
export function formatStrength(value: number): string {
return Number.isFinite(value) ? value.toFixed(3) : "0.000";
}
export function daysSinceIso(value: string | undefined, now = Date.now()): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
if (!Number.isFinite(ms)) return null;
return Math.max(0, (now - ms) / 86_400_000);
}
export function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean {
return entry.safetyCritical === true;
}
export function retentionCandidatesForDiag(store: WorkspaceMemoryStore, now = Date.now()): {
sorted: RetentionDiagItem[];
rendered: RetentionDiagItem[];
typeCapped: RetentionDiagItem[];
globalCapped: RetentionDiagItem[];
} {
const active = store.entries.filter(entry => entry.status !== "superseded");
const sorted = active
.map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) }))
.sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id));
const rendered: RetentionDiagItem[] = [];
const typeCapped: RetentionDiagItem[] = [];
const globalCapped: RetentionDiagItem[] = [];
const typeCounts: Partial<Record<LongTermType, number>> = {};
for (const item of sorted) {
const count = typeCounts[item.entry.type] ?? 0;
const max = RETENTION_TYPE_MAX[item.entry.type] ?? Infinity;
if (count >= max) {
typeCapped.push(item);
continue;
}
typeCounts[item.entry.type] = count + 1;
if (rendered.length < LONG_TERM_LIMITS.maxEntries) {
rendered.push(item);
} else {
globalCapped.push(item);
}
}
return { sorted, rendered, typeCapped, globalCapped };
}
export function promotionLimit(source: LongTermSource): number {
if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts;
return PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
}
export function retentionClockSummary(entries: LongTermMemoryEntry[]): { present: number; missing: number; invalid: number } {
let present = 0;
let missing = 0;
let invalid = 0;
for (const entry of entries) {
if (entry.retentionClock === undefined) {
missing += 1;
} else if (!Number.isFinite(entry.retentionClock) || entry.retentionClock <= 0) {
invalid += 1;
} else {
present += 1;
}
}
return { present, missing, invalid };
}
-69
View File
@@ -1,69 +0,0 @@
import { createHash } from "node:crypto";
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import { redactCredentials } from "../../src/redaction.ts";
export function countBy<T extends string>(items: T[]): Map<T, number> {
const counts = new Map<T, number>();
for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1);
return counts;
}
export function sortedCounts<T extends string>(counts: Map<T, number>): Array<[T, number]> {
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
export function workspaceRootHash(root: string): string {
return createHash("sha256").update(root).digest("hex").slice(0, 16);
}
export function redactAbsolutePaths(text: string): string {
return text.replace(/(?:^|[\s"'`(=:\[])(\/(?:Users|home|private|tmp|var|opt|Volumes|[^\s"'`)\],;:]+)\/[^\s"'`)\],;:]*)/g, (match, path) => match.replace(path, "<path>"));
}
export function cleanText(text: string, raw: boolean): string {
if (raw) return text;
return redactAbsolutePaths(redactCredentials(text));
}
export function cleanPath(path: string, raw: boolean): string {
return raw ? path : "<path>";
}
export function formatWorkspaceIdentity(workspaceKeyValue: string | undefined, workspaceRoot: string | undefined, raw: boolean): string {
const parts: string[] = [];
if (workspaceKeyValue) parts.push(`workspaceKey=${workspaceKeyValue}`);
if (workspaceRoot) {
parts.push(raw ? `workspaceRoot=${workspaceRoot}` : `workspaceRootHash=${workspaceRootHash(workspaceRoot)}`);
}
return parts.join(" ");
}
export function truncate(text: string, max = 120): string {
const collapsed = text.replace(/\s+/g, " ").trim();
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
}
export function canonicalMemoryText(text: string): string {
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[\s\p{P}]+/gu, " ")
.trim();
}
export function formatPercent(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
export function uniqueStrings(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
export function objectFromCounts<T extends string>(counts: Map<T, number>): Record<string, number> {
return Object.fromEntries(sortedCounts(counts));
}
export function formatDetails(details: EvidenceEventV1["details"]): string {
if (!details || Object.keys(details).length === 0) return "none";
return Object.entries(details).map(([key, value]) => `${key}=${Array.isArray(value) ? value.join("|") : String(value)}`).join(" ");
}
-30
View File
@@ -1,30 +0,0 @@
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import { uniqueStrings } from "./text.ts";
import { statusFromOmissionReason } from "./workspace-snapshot.ts";
export function statusFromTraceEvent(event: EvidenceEventV1 | undefined): string {
if (!event) return "unknown";
if (event.type === "render_selected") return "rendered";
if (event.type === "render_omitted") return statusFromOmissionReason(event.reasonCodes[0]);
if (event.type === "promotion_absorbed_exact" || event.type === "promotion_absorbed_identity") return "omitted_absorbed_duplicate";
if (event.type === "promotion_retry_scheduled") return "pending_retry";
if (event.type === "promotion_rejected_capacity" || event.type === "promotion_retry_exhausted") return "pending_rejected_capacity";
if (event.type === "storage_corrupt_json_quarantined") return "quarantined_corrupt_store";
if (event.outcome === "superseded") return "omitted_superseded";
return event.outcome;
}
export function formatTraceEvent(event: EvidenceEventV1): string {
const reasons = event.reasonCodes.length > 0 ? event.reasonCodes.join(",") : "none";
const relations = (event.relations ?? [])
.map(relation => relation.memory?.memoryId ? `${relation.role}=${relation.memory.memoryId}` : undefined)
.filter((value): value is string => Boolean(value));
const relationText = relations.length > 0 ? `; ${relations.join(", ")}` : "";
return `- ${event.eventId} ${event.type}: ${event.outcome}; reasons=${reasons}${relationText}`;
}
export function relationMemoryIds(events: EvidenceEventV1[], role: string): string[] {
return uniqueStrings(events.flatMap(event => (event.relations ?? [])
.filter(relation => relation.role === role)
.map(relation => relation.memory?.memoryId ?? "")));
}
-169
View File
@@ -1,169 +0,0 @@
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome } from "../../src/evidence-log.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts";
import type { Command } from "./command-metadata.ts";
export type { Command, HiddenCommand, VisibleCommand } from "./command-metadata.ts";
export type MemoryRenderStatus =
| "rendered"
| "omitted_superseded"
| "omitted_type_cap"
| "omitted_global_cap"
| "omitted_char_budget"
| "omitted_absorbed_duplicate"
| "pending_retry"
| "pending_rejected_capacity"
| "quarantined_corrupt_store";
export type MemoryDiagJSON = {
version: 1;
workspace: { rootHash: string; key: string };
generatedAt: string;
summary: {
storedActive: number;
rendered: number;
pending: number;
rejectedLast7Days: number;
corruptStoresQuarantinedLast30Days: number;
status?: "ok" | "warning" | "degraded";
evidenceCoveragePercent?: number;
needsAttention?: string[];
suggestedNextSteps?: string[];
};
memories: Array<{
id: string;
type: "feedback" | "project" | "decision" | "reference";
source: "explicit" | "compaction" | "manual";
status: MemoryRenderStatus;
strength?: number;
reasonCodes: string[];
textPreview?: string;
evidenceEventIds: string[];
}>;
recentEvents: Array<{
eventId: string;
type: EvidenceEventType;
outcome: EvidenceOutcome;
createdAt: string;
memoryId?: string;
reasonCodes: string[];
}>;
};
export type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
export type CliOptions = {
raw: boolean;
json?: boolean;
verbose?: boolean;
noEmoji?: boolean;
workspace?: string;
all?: boolean;
softOnly?: boolean;
triggerOnly?: boolean;
includeHistorical?: boolean;
reason?: string;
explain?: boolean;
since?: string;
migration?: string;
memory?: string;
event?: string;
apply?: boolean;
positional?: string[];
auditMode?: "coverage" | "migrations";
};
export type ParsedArgs =
| { ok: true; command: Command; options: CliOptions; deprecationNotice?: string }
| { ok: true; help: true; usage: string }
| { ok: false; message: string; usage: string; exitCode: number };
export type CommandResult = { stdout: string; stderr?: string; exitCode?: number };
export class CliInputError extends Error {}
export type RejectionLogRecord = {
timestamp?: string;
workspaceKey?: string;
workspaceRoot?: string;
workspaceRootHash?: string;
type?: LongTermType;
source?: LongTermSource | string;
origin?: string;
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">> & {
workspaceKey?: string;
workspaceRoot?: string;
workspaceRootHash?: string;
source?: string;
origin: Origin;
fromTrigger: boolean;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
decisionLogicName?: string;
decisionLogicVersion?: number;
};
export type MigrationLogRecord = {
migrationId?: string;
timestamp?: string;
workspaceKey?: string;
workspaceRoot?: string;
entryId?: string;
type?: LongTermType;
source?: LongTermSource | string;
text?: string;
reasons?: string[];
hardReasons?: string[];
beforeStatus?: string;
afterStatus?: string;
};
export type RetentionDiagItem = {
entry: LongTermMemoryEntry;
strength: number;
};
export type WorkspaceDiagSnapshot = {
store: WorkspaceMemoryStore;
journal: PendingMemoryJournalStore;
retention: {
sorted: RetentionDiagItem[];
rendered: RetentionDiagItem[];
typeCapped: RetentionDiagItem[];
globalCapped: RetentionDiagItem[];
};
memories: MemoryDiagJSON["memories"];
recentEvents: MemoryDiagJSON["recentEvents"];
allEvents: EvidenceEventV1[];
summary: MemoryDiagJSON["summary"];
};
export type MemoryInspectionReadModel = {
snapshot: WorkspaceDiagSnapshot;
store: WorkspaceMemoryStore;
pending: PendingMemoryJournalStore;
evidenceEvents: EvidenceEventV1[];
rejectionRecords: NormalizedRejection[];
currentById: Map<string, LongTermMemoryEntry>;
evidenceByMemoryId: Map<string, EvidenceEventV1[]>;
};
export type CoverageClass = "full_lifecycle" | "render_only" | "no_evidence" | "historical_absent_with_reason" | "historical_absent_unknown_reason";
export type DisappearanceReason = {
classification: "historical_absent_with_reason" | "historical_absent_unknown_reason";
terminalType: EvidenceEventType | "unknown";
reasonCodes: string[];
event?: EvidenceEventV1;
};
-236
View File
@@ -1,236 +0,0 @@
import { calculateRetentionStrength } from "../../src/retention.ts";
import {
queryEvidenceEvents,
type EvidenceEventV1,
} from "../../src/evidence-log.ts";
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../../src/paths.ts";
import { accountWorkspaceMemoryRender } from "../../src/workspace-memory.ts";
import type { LongTermMemoryEntry, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts";
import { LONG_TERM_LIMITS } from "../../src/types.ts";
import { readJSONFile } from "./io.ts";
import { groupEvidenceByMemoryId } from "./evidence-model.ts";
import { promotionLimit, retentionCandidatesForDiag } from "./retention-model.ts";
import { cleanText, truncate, uniqueStrings, workspaceRootHash } from "./text.ts";
import type { CliOptions, MemoryDiagJSON, MemoryRenderStatus, WorkspaceDiagSnapshot } from "./types.ts";
export function emptyStore(root: string, key: string): WorkspaceMemoryStore {
return {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [],
migrations: [],
updatedAt: new Date(0).toISOString(),
};
}
export function normalizedStore(store: WorkspaceMemoryStore | null, root: string, key: string): WorkspaceMemoryStore {
const fallback = emptyStore(root, key);
return {
...fallback,
...(store ?? {}),
workspace: store?.workspace ?? fallback.workspace,
limits: {
maxRenderedChars: store?.limits?.maxRenderedChars ?? fallback.limits.maxRenderedChars,
maxEntries: store?.limits?.maxEntries ?? fallback.limits.maxEntries,
},
entries: Array.isArray(store?.entries) ? store.entries : [],
migrations: Array.isArray(store?.migrations) ? store.migrations : [],
};
}
export function normalizedJournal(journal: PendingMemoryJournalStore | null): PendingMemoryJournalStore {
return {
version: 1,
workspace: journal?.workspace ?? { root: "", key: "" },
entries: Array.isArray(journal?.entries) ? journal.entries : [],
updatedAt: journal?.updatedAt ?? new Date(0).toISOString(),
};
}
export function eventMemoryId(event: EvidenceEventV1): string | undefined {
return event.memory?.memoryId
?? event.relations?.map(relation => relation.memory?.memoryId).find((id): id is string => Boolean(id));
}
export function isWithinDays(iso: string, days: number, now = Date.now()): boolean {
const ms = new Date(iso).getTime();
return Number.isFinite(ms) && ms >= now - days * 86_400_000;
}
export function renderStatusReason(status: MemoryRenderStatus, fallback?: string): string[] {
switch (status) {
case "rendered": return ["within_caps", "within_char_budget"];
case "omitted_superseded": return ["superseded"];
case "omitted_type_cap": return ["type_cap"];
case "omitted_global_cap": return ["global_cap"];
case "omitted_char_budget": return [fallback === "empty_render_budget" ? "empty_render_budget" : "char_budget"];
case "omitted_absorbed_duplicate": return ["absorbed_duplicate"];
case "pending_retry": return ["retryable_capacity_rejection"];
case "pending_rejected_capacity": return ["capacity_rejected", "max_attempts_reached"];
case "quarantined_corrupt_store": return ["invalid_json"];
}
}
export function statusFromOmissionReason(reason: string | undefined): MemoryRenderStatus {
if (reason === "superseded") return "omitted_superseded";
if (reason === "type_cap") return "omitted_type_cap";
if (reason === "global_cap") return "omitted_global_cap";
return "omitted_char_budget";
}
export function pendingStatus(entry: LongTermMemoryEntry): MemoryRenderStatus {
const attempts = entry.promotionAttempts ?? 0;
return attempts >= promotionLimit(entry.source) ? "pending_rejected_capacity" : "pending_retry";
}
export function safeTextPreview(text: string): string {
return truncate(cleanText(text, false), 120);
}
function evidenceSummaryForMemory(grouped: Map<string, EvidenceEventV1[]>, memoryId: string): { eventIds: string[]; reasonCodes: string[] } {
const events = grouped.get(memoryId) ?? [];
const reasonCodes = new Set<string>();
for (const event of events) {
for (const reason of event.reasonCodes) reasonCodes.add(reason);
}
return {
eventIds: events.map(event => event.eventId),
reasonCodes: [...reasonCodes],
};
}
export async function buildWorkspaceDiagSnapshot(input: {
root: string;
key: string;
memoryPath: string;
pendingPath: string;
}, now = Date.now()): Promise<WorkspaceDiagSnapshot> {
const rawStore = await readJSONFile<WorkspaceMemoryStore>(input.memoryPath);
const storeRoot = rawStore?.workspace?.root ?? input.root;
const storeKey = rawStore?.workspace?.key ?? input.key;
const store = normalizedStore(rawStore, storeRoot, storeKey);
const journal = normalizedJournal(await readJSONFile<PendingMemoryJournalStore>(input.pendingPath));
const retention = retentionCandidatesForDiag(store, now);
const renderAccounting = accountWorkspaceMemoryRender(store);
const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id));
const omittedById = new Map(renderAccounting.omitted.map(item => [item.memory.id, item.reason]));
const allEvents = await queryEvidenceEvents(input.root);
const evidenceByMemoryId = groupEvidenceByMemoryId(allEvents);
const recentEvidence = await queryEvidenceEvents(input.root, { newestFirst: true, limit: 50 });
const memoryRows: MemoryDiagJSON["memories"] = [];
const seenIds = new Set<string>();
for (const entry of store.entries) {
const omissionReason = omittedById.get(entry.id);
const status: MemoryRenderStatus = renderedIds.has(entry.id)
? "rendered"
: statusFromOmissionReason(omissionReason ?? (entry.status === "superseded" ? "superseded" : undefined));
const summary = evidenceSummaryForMemory(evidenceByMemoryId, entry.id);
memoryRows.push({
id: entry.id,
type: entry.type,
source: entry.source,
status,
strength: calculateRetentionStrength(entry, now, store.lastActivityAt),
reasonCodes: uniqueStrings([...renderStatusReason(status, omissionReason), ...summary.reasonCodes]),
textPreview: safeTextPreview(entry.text),
evidenceEventIds: summary.eventIds,
});
seenIds.add(entry.id);
}
for (const entry of journal.entries) {
const status = pendingStatus(entry);
const summary = evidenceSummaryForMemory(evidenceByMemoryId, entry.id);
memoryRows.push({
id: entry.id,
type: entry.type,
source: entry.source,
status,
strength: calculateRetentionStrength(entry, now, store.lastActivityAt),
reasonCodes: uniqueStrings([...renderStatusReason(status), entry.lastPromotionFailureReason ?? "", ...summary.reasonCodes]),
textPreview: safeTextPreview(entry.text),
evidenceEventIds: summary.eventIds,
});
seenIds.add(entry.id);
}
for (const event of allEvents) {
if (event.outcome !== "absorbed") continue;
const memory = event.memory;
if (!memory?.memoryId || !memory.type || !memory.source || seenIds.has(memory.memoryId)) continue;
const summary = evidenceSummaryForMemory(evidenceByMemoryId, memory.memoryId);
memoryRows.push({
id: memory.memoryId,
type: memory.type,
source: memory.source,
status: "omitted_absorbed_duplicate",
reasonCodes: uniqueStrings([...renderStatusReason("omitted_absorbed_duplicate"), ...summary.reasonCodes]),
evidenceEventIds: summary.eventIds.length > 0 ? summary.eventIds : [event.eventId],
});
seenIds.add(memory.memoryId);
}
const recentEvents = recentEvidence.map(event => ({
eventId: event.eventId,
type: event.type,
outcome: event.outcome,
createdAt: event.createdAt,
memoryId: eventMemoryId(event),
reasonCodes: uniqueStrings([
...event.reasonCodes,
...(event.type === "storage_corrupt_json_quarantined" ? ["quarantined_corrupt_store"] : []),
]),
}));
return {
store,
journal,
retention,
memories: memoryRows,
recentEvents,
allEvents,
summary: {
storedActive: store.entries.filter(entry => entry.status !== "superseded").length,
rendered: retention.rendered.length,
pending: journal.entries.length,
rejectedLast7Days: allEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7, now)).length,
corruptStoresQuarantinedLast30Days: allEvents.filter(event => event.type === "storage_corrupt_json_quarantined" && isWithinDays(event.createdAt, 30, now)).length,
},
};
}
export async function buildMemoryDiagJSON(root: string): Promise<MemoryDiagJSON> {
const key = await workspaceKey(root);
const snapshot = await buildWorkspaceDiagSnapshot({
root,
key,
memoryPath: await workspaceMemoryPath(root),
pendingPath: await workspacePendingJournalPath(root),
});
return memoryDiagJSONFromSnapshot(root, snapshot);
}
export function memoryDiagJSONFromSnapshot(root: string, snapshot: WorkspaceDiagSnapshot, generatedAt = new Date().toISOString()): MemoryDiagJSON {
return {
version: 1,
workspace: { rootHash: workspaceRootHash(snapshot.store.workspace.root || root), key: snapshot.store.workspace.key },
generatedAt,
summary: snapshot.summary,
memories: snapshot.memories,
recentEvents: snapshot.recentEvents,
};
}
export async function snapshotForOptions(options: CliOptions): Promise<WorkspaceDiagSnapshot> {
const root = options.workspace ?? process.cwd();
const key = await workspaceKey(root);
return buildWorkspaceDiagSnapshot({
root,
key,
memoryPath: await workspaceMemoryPath(root),
pendingPath: await workspacePendingJournalPath(root),
});
}
+2 -17
View File
@@ -4,7 +4,6 @@ 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"
@@ -21,12 +20,8 @@ export type EvidenceEventType =
| "promotion_retry_scheduled"
| "promotion_retry_exhausted"
| "memory_reinforced"
| "memory_replaced_numbered_ref"
| "memory_reverted_numbered_ref"
| "memory_migration_superseded"
| "render_selected"
| "render_omitted"
| "memory_removed_capacity"
| "storage_corrupt_json_quarantined"
| "storage_stale_lock_recovered"
| "storage_lock_timeout"
@@ -50,7 +45,6 @@ export type EvidenceOutcome =
| "superseded"
| "rendered"
| "omitted"
| "removed"
| "retried"
| "exhausted"
| "reinforced"
@@ -74,15 +68,12 @@ export type EvidenceRelation = {
| "promoted"
| "retained"
| "absorbed"
| "target"
| "superseded"
| "superseded_by"
| "reinforced"
| "reinforced_by"
| "recovered"
| "rendered"
| "omitted"
| "removed";
| "omitted";
memory?: MemoryEvidenceRef;
};
@@ -96,9 +87,6 @@ export type EvidenceEventV1 = {
workspaceRootHash: string;
sessionHash?: string;
messageHash?: string;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
type: EvidenceEventType;
phase: EvidencePhase;
outcome: EvidenceOutcome;
@@ -277,10 +265,7 @@ function buildEvidenceEvent(
if (details) event.details = details;
if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax);
return {
...event,
...producerFields(),
};
return event;
}
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
+16 -137
View File
@@ -7,7 +7,6 @@ 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)}`;
@@ -53,14 +52,9 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
export type WorkspaceMemoryParseResult = {
entries: LongTermMemoryEntry[];
commands: WorkspaceMemoryCommand[];
evidence: EvidenceEventInput[];
};
export type WorkspaceMemoryCommand =
| { kind: "REINFORCE"; ref: string }
| { kind: "REPLACE"; ref: string; type: LongTermType; text: string };
function evidenceTextPreview(text: string, maxChars = 120): string {
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
}
@@ -197,7 +191,7 @@ export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemo
}
}
return { entries, commands: [], evidence };
return { entries, evidence };
}
function classifyExplicitMemory(text: string): LongTermType {
@@ -328,18 +322,6 @@ type ExtractionRejectionLogEntry = {
text: string;
reasons: string[];
source: "compaction";
workspaceKey?: string;
workspaceRootHash?: string;
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
decisionLogicName?: string;
decisionLogicVersion?: number;
};
type WorkspaceMemoryCandidateParseOptions = {
workspaceKey?: string;
workspaceRootHash?: string;
};
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
@@ -359,7 +341,7 @@ function evaluateWorkspaceMemoryCandidate(
},
options: {
fromMemoryTrigger?: boolean;
} & WorkspaceMemoryCandidateParseOptions = {},
} = {},
): { accepted: boolean; reasons: string[] } {
const text = entry.text.trim();
const minLength = options.fromMemoryTrigger ? 6 : 20;
@@ -385,11 +367,6 @@ function evaluateWorkspaceMemoryCandidate(
text: redactCredentials(text),
reasons: quality.reasons,
source: "compaction",
workspaceKey: options.workspaceKey,
workspaceRootHash: options.workspaceRootHash,
...producerFields(),
decisionLogicName: "assessMemoryQuality",
decisionLogicVersion: 1,
});
return { accepted: false, reasons: quality.reasons };
}
@@ -404,81 +381,11 @@ function shouldAcceptWorkspaceMemoryCandidate(
},
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);
if (reinforceMatch) {
return /^\[M[1-9]\d*\]$/i.test(reinforceMatch[1]?.trim() ?? "")
? "invalid_memory_command"
: "invalid_memory_ref";
}
const replaceMatch = normalized.match(/^REPLACE\s+(.*)$/i);
if (!replaceMatch) return "invalid_memory_command";
const rest = replaceMatch[1]?.trim() ?? "";
const refMatch = rest.match(/^(\[[^\]]+\]|\S+)(?:\s+(.*))?$/);
const ref = refMatch?.[1] ?? "";
if (!/^\[M[1-9]\d*\]$/i.test(ref)) return "invalid_memory_ref";
const afterRef = refMatch?.[2]?.trim() ?? "";
const typeMatch = afterRef.match(/^(\[[^\]]+\]|\S+)(?:\s+(.*))?$/);
const typeToken = typeMatch?.[1] ?? "";
if (!/^\[(feedback|project|decision|reference)\]$/i.test(typeToken)) {
return "invalid_memory_type";
}
const replacementText = typeMatch?.[2]?.trim() ?? "";
return replacementText ? "invalid_memory_command" : "empty_replacement_text";
}
function isCommandAttempt(line: string): boolean {
const normalized = line.replace(/^\s*-\s*/, "").trim();
return /^(REINFORCE|REPLACE)\b/i.test(normalized)
|| /\b(REINFORCE|REPLACE)\b.*\[?\w+\]?/i.test(normalized);
}
function parseWorkspaceMemoryCommand(line: string): WorkspaceMemoryCommand | null {
const normalized = line.replace(/^\s*-\s*/, "").trim();
const reinforce = normalized.match(/^REINFORCE\s+\[(M[1-9]\d*)\]\s*$/i);
if (reinforce) {
return { kind: "REINFORCE", ref: reinforce[1].toUpperCase() };
}
const replace = normalized.match(/^REPLACE\s+\[(M[1-9]\d*)\]\s+\[(feedback|project|decision|reference)\]\s+(.+)$/i);
if (replace) {
const text = replace[3].trim();
if (!text) return null;
return {
kind: "REPLACE",
ref: replace[1].toUpperCase(),
type: replace[2].toLowerCase() as LongTermType,
text,
};
}
return null;
}
function parseCandidateLine(line: string): { type: LongTermType; body: string } | null {
const bracketed = line.trim().match(/^\s*-?\s*\[(feedback|project|decision|reference)\]\s+(.+)$/i);
if (bracketed) {
return { type: bracketed[1].toLowerCase() as LongTermType, body: bracketed[2] };
}
const bracketless = line.trim().match(/^-\s*(feedback|project|decision|reference)\b\s+(.+)$/i);
if (bracketless) {
return { type: bracketless[1].toLowerCase() as LongTermType, body: bracketless[2] };
}
return null;
}
/**
* Extract candidate block from summary using multiple formats.
* Supports: Plain text label, Markdown section, legacy XML.
@@ -503,51 +410,27 @@ function extractCandidateBlock(summary: string): string | null {
return null;
}
export function parseWorkspaceMemoryCandidates(
summary: string,
options?: WorkspaceMemoryCandidateParseOptions,
): LongTermMemoryEntry[] {
return parseWorkspaceMemoryCandidatesWithEvidence(summary, options).entries;
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
return parseWorkspaceMemoryCandidatesWithEvidence(summary).entries;
}
export function parseWorkspaceMemoryCandidatesWithEvidence(
summary: string,
options: WorkspaceMemoryCandidateParseOptions = {},
): WorkspaceMemoryParseResult {
export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): WorkspaceMemoryParseResult {
const block = extractCandidateBlock(summary);
if (!block) return { entries: [], commands: [], evidence: [] };
if (!block) return { entries: [], evidence: [] };
const nowMs = Date.now();
const now = new Date(nowMs).toISOString();
const entries: LongTermMemoryEntry[] = [];
const commands: WorkspaceMemoryCommand[] = [];
const evidence: EvidenceEventInput[] = [];
for (const line of block.split("\n")) {
if (!line.trim() || /^\s*\(?none\)?\s*$/i.test(line)) continue;
const command = parseWorkspaceMemoryCommand(line);
if (command) {
commands.push(command);
continue;
}
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
const item = parseCandidateLine(line);
if (!item) {
if (isCommandAttempt(line)) {
evidence.push(extractionEvidence({
type: "extraction_candidate_rejected",
phase: "extraction",
outcome: "rejected",
reasonCodes: [commandAttemptReason(line)],
textPreview: evidenceTextPreview(line, 80),
}));
}
continue;
}
const type = item.type;
const normalizedBody = normalizeCandidateBody(item.body);
const item = line.trim().match(
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
);
if (!item) continue;
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
const normalizedBody = normalizeCandidateBody(item[3]);
if (!normalizedBody) {
evidence.push(extractionEvidence({
type: "extraction_candidate_rejected",
@@ -555,7 +438,7 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
outcome: "rejected",
reasonCodes: ["negated_request"],
memory: { type, source: "compaction" },
textPreview: evidenceTextPreview(item.body, 80),
textPreview: evidenceTextPreview(item[3], 80),
}));
continue;
}
@@ -576,11 +459,7 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
// Apply quality gate
const quality = evaluateWorkspaceMemoryCandidate(
{ type, text: normalizedBody.text },
{
fromMemoryTrigger: normalizedBody.hadTrigger,
workspaceKey: options.workspaceKey,
workspaceRootHash: options.workspaceRootHash,
},
{ fromMemoryTrigger: normalizedBody.hadTrigger },
);
if (!quality.accepted) {
evidence.push(extractionEvidence({
@@ -617,5 +496,5 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
}));
}
return { entries, commands, evidence };
return { entries, evidence };
}
-41
View File
@@ -1,41 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
let cachedVersion: string | undefined;
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
const MEMORY_INSTRUMENTATION_VERSION = 2;
function producerVersion(): string {
if (cachedVersion) return cachedVersion;
try {
const candidates = [
join(__dirname, "..", "package.json"),
join(__dirname, "..", "..", "package.json"),
// resolve from compiled dist/src/ -> repo root
];
for (const path of candidates) {
try {
const pkg = JSON.parse(readFileSync(path, "utf8"));
cachedVersion = pkg.version as string;
break;
} catch {
// try next
}
}
if (!cachedVersion) cachedVersion = "unknown";
} catch {
cachedVersion = "unknown";
}
return cachedVersion;
}
export function producerFields(): { producerName: string; producerVersion: string; instrumentationVersion: number } {
return {
producerName: MEMORY_PRODUCER_NAME,
producerVersion: producerVersion(),
instrumentationVersion: MEMORY_INSTRUMENTATION_VERSION,
};
}
+4 -96
View File
@@ -7,7 +7,6 @@ export type MemoryQualityInput = Pick<LongTermMemoryEntry, "type" | "text"> & {
export type MemoryQualityResult = {
accepted: boolean;
reasons: string[];
diagnostics?: string[];
};
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
@@ -19,9 +18,6 @@ export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
"active_file_snapshot",
"code_or_api_signature",
"path_heavy",
"unresolved_question",
"transient_bug_state",
"deployment_snapshot",
]);
export function isHardQualityReason(reason: string): boolean {
@@ -40,18 +36,10 @@ export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityRes
if (isTemporaryStatusViolation(text)) reasons.push("temporary_status");
if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot");
if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature");
if (isUnresolvedQuestionViolation(text)) reasons.push("unresolved_question");
if (isTransientBugStateViolation(text)) reasons.push("transient_bug_state");
if (isDeploymentSnapshotViolation(text)) reasons.push("deployment_snapshot");
if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback");
if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision");
const diagnostics = isTerseLabelDiagnostic(text) ? ["terse_label"] : [];
return {
accepted: reasons.length === 0,
reasons,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
return { accepted: reasons.length === 0, reasons };
}
export function isProgressSnapshotViolation(text: string): boolean {
@@ -90,67 +78,15 @@ export function isFeedbackQualityViolation(text: string): boolean {
return true;
}
export function hasFutureRule(text: string): boolean {
return /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
export function isDecisionQualityViolation(text: string): boolean {
const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
}
function textWithoutUrls(text: string): string {
return text.replace(/https?:\/\/[^\s`"'<>]+/gi, "");
}
function hasDurableRuleMarker(text: string): boolean {
return /\b(?:must|always|never|use|do\s+not|don't)\b/i.test(text)
|| /\bshould\b(?!\s+we\b)/i.test(text)
|| /(?:必須|必须|應該|应该|不要|使用|保持)/u.test(text);
}
function isUnresolvedQuestionViolation(text: string): boolean {
if (hasDurableRuleMarker(text)) return false;
const withoutUrls = textWithoutUrls(text).trim();
const startsUnresolved = /^(?:question:|open question\b|unresolved\b|pending question\b|todo:\s*decide\b|TBD\b|TODO\b|待確認|未決|待決定)/iu.test(withoutUrls);
if (startsUnresolved) return true;
if (/\b(?:need to decide|needs decision|not decided|whether to|should we|do we need)\b/i.test(withoutUrls)) return true;
if (/(?:尚未決定|需要決定|是否要|要不要)/u.test(withoutUrls)) return true;
if (/[??]\s*$/.test(withoutUrls)) return true;
const hasQuestion = /[??]/.test(withoutUrls);
const hasPlanningPhrase = /\b(?:we need|need to|next|later|follow up)\b/i.test(withoutUrls)
|| /(?:確認|决定|決定)/u.test(withoutUrls);
return hasQuestion && hasPlanningPhrase;
}
export function isArchitectureLikeDecision(text: string): boolean {
if (/\b(?:[A-Z][A-Z0-9]*_[A-Z0-9_]*|[A-Z][A-Z0-9]{3,})\b/.test(text)) return true;
if (/\b(?:schema|model|scoring|retention|cap|evidence|normalization|root cause|architecture(?!\s+keywords?)|boundary|rule|memory system)\b/i.test(text)) return true;
if (/(?:模型|架構|架构|证据|證據|規則|规则|邊界|边界|記憶系統|记忆系统|原因|採用|采用)/u.test(text)) return true;
return false;
}
export function isImplementationStatusDecision(text: string): boolean {
if (!futureRule) return true;
if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true;
if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true;
return false;
}
export function isDecisionQualityViolation(text: string): boolean {
if (hasFutureRule(text)) {
if (isImplementationStatusDecision(text)) return true;
return false;
}
if (isArchitectureLikeDecision(text)) {
if (isImplementationStatusDecision(text)) return true;
if (/\b(?:session|wave|task|test|CI|compatibility|commit)\b/i.test(text)) return true;
return false;
}
return true;
}
function isRawErrorViolation(text: string): boolean {
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
@@ -177,34 +113,6 @@ function isTemporaryStatusViolation(text: string): boolean {
return false;
}
function isTransientBugStateViolation(text: string): boolean {
return /\b(?:currently debugging|still failing|unresolved bug|temporary workaround|next step is to fix|tests are failing)\b/i.test(text)
|| /(?:待修|暫時|暂时|目前正在)/u.test(text);
}
function isDeploymentSnapshotViolation(text: string): boolean {
const hasDeploymentContext = /\b(?:deployed|current|latest|active|revision|build|release)\b/i.test(text)
|| /(?:部署|版本|修訂|修订)/u.test(text);
if (!hasDeploymentContext) return false;
const highEntropyId = /\b(?:rev|build|release|revision)[-_]?[A-Za-z0-9]{10,}\b/i.test(text)
|| /\b[A-Za-z0-9]*[A-Z][A-Za-z0-9]*\d[A-Za-z0-9]*[A-Za-z0-9_-]{8,}\b/.test(text)
|| /\b[A-Za-z0-9]*\d[A-Za-z0-9]*[A-Z][A-Za-z0-9]*[A-Za-z0-9_-]{8,}\b/.test(text);
return highEntropyId;
}
function isTerseLabelDiagnostic(text: string): boolean {
if (/[::]/u.test(text)) return false;
const codePoints = [...text].length;
const tokens = text.split(/\s+/u).filter(Boolean);
if (codePoints >= 18 && tokens.length >= 4) return false;
const hasMarker = /\b(?:is|are|was|were|has|have|uses?|keeps?|requires?|prefers?|wants?|supports?|must|should|always|never|do\s+not|don't|because|for|with|when|after|before)\b/i.test(text)
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|偏好|要求|支援|支持|因為|因为)/u.test(text);
return !hasMarker;
}
function isActiveFileSnapshotViolation(text: string): boolean {
return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text);
}
-301
View File
@@ -1,301 +0,0 @@
// No OpenCode SDK or TUI imports. Uses only local file-system reads from workspace memory, session state, and pending journal.
import { readFile } from "node:fs/promises";
import { sessionStatePath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "./paths.ts";
import { redactCredentials } from "./redaction.ts";
import type { LongTermMemoryEntry, PendingMemoryJournalStore, SessionState, WorkspaceMemoryStore } from "./types.ts";
import { LONG_TERM_LIMITS } from "./types.ts";
import { accountWorkspaceMemoryCompactionRefs, accountWorkspaceMemoryRender } from "./workspace-memory.ts";
export type MemoryVisibilityCommand = "status" | "list" | "help";
type MemoryListItem = {
ref: string;
text: string;
};
export type MemoryStatusModel = {
activeMemories: number;
supersededMemories: number;
renderedInPrompt: number;
omittedActiveMemories: number;
pendingInSession: number;
pendingJournalMemories: number;
openErrors: number;
recentDecisions: number;
};
export type MemoryListModel = {
activeMemories: number;
renderedMemories: number;
omittedActiveMemories: number;
groups: Record<LongTermMemoryEntry["type"], MemoryListItem[]>;
};
const MAX_PREVIEW_CHARS = 120;
const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermMemoryEntry["type"][];
function safePreview(text: string | undefined, maxChars = MAX_PREVIEW_CHARS): string {
const clean = redactCredentials(text ?? "").replace(/\s+/g, " ").trim();
if (clean.length <= maxChars) return clean;
return `${clean.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
}
async function readJSONSnapshot(path: string): Promise<unknown | undefined> {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch {
return undefined;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isLongTermType(value: unknown): value is LongTermMemoryEntry["type"] {
return value === "feedback" || value === "project" || value === "decision" || value === "reference";
}
function isLongTermSource(value: unknown): value is LongTermMemoryEntry["source"] {
return value === "explicit" || value === "compaction" || value === "manual";
}
function isLongTermMemoryEntry(value: unknown): value is LongTermMemoryEntry {
if (!isRecord(value)) return false;
if (typeof value.id !== "string") return false;
if (!isLongTermType(value.type)) return false;
if (typeof value.text !== "string") return false;
if (!isLongTermSource(value.source)) return false;
if (typeof value.confidence !== "number") return false;
if (value.status !== "active" && value.status !== "superseded") return false;
if (typeof value.createdAt !== "string") return false;
return typeof value.updatedAt === "string";
}
function memoryEntries(value: unknown): LongTermMemoryEntry[] {
return Array.isArray(value) ? value.filter(isLongTermMemoryEntry) : [];
}
async function emptyWorkspaceMemorySnapshot(root: string): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: {
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: LONG_TERM_LIMITS.maxEntries,
},
entries: [],
migrations: [],
updatedAt: nowIso,
lastActivityAt: nowIso,
};
}
async function readWorkspaceMemorySnapshot(root: string): Promise<WorkspaceMemoryStore> {
const fallback = await emptyWorkspaceMemorySnapshot(root);
const loaded = await readJSONSnapshot(await workspaceMemoryPath(root));
if (!isRecord(loaded)) return fallback;
const limits = isRecord(loaded.limits) ? loaded.limits : {};
return {
version: 1,
workspace: fallback.workspace,
limits: {
maxRenderedChars: typeof limits.maxRenderedChars === "number" ? limits.maxRenderedChars : LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: typeof limits.maxEntries === "number" ? limits.maxEntries : LONG_TERM_LIMITS.maxEntries,
},
entries: memoryEntries(loaded.entries),
migrations: Array.isArray(loaded.migrations) ? loaded.migrations.filter(item => typeof item === "string") : [],
updatedAt: typeof loaded.updatedAt === "string" ? loaded.updatedAt : fallback.updatedAt,
lastActivityAt: typeof loaded.lastActivityAt === "string" ? loaded.lastActivityAt : undefined,
};
}
async function emptyPendingJournalSnapshot(root: string): Promise<PendingMemoryJournalStore> {
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
entries: [],
updatedAt: new Date().toISOString(),
};
}
async function readPendingJournalSnapshot(root: string): Promise<PendingMemoryJournalStore> {
const fallback = await emptyPendingJournalSnapshot(root);
const loaded = await readJSONSnapshot(await workspacePendingJournalPath(root));
if (!isRecord(loaded)) return fallback;
return {
version: 1,
workspace: fallback.workspace,
entries: memoryEntries(loaded.entries),
updatedAt: typeof loaded.updatedAt === "string" ? loaded.updatedAt : fallback.updatedAt,
};
}
function emptySessionStateSnapshot(sessionID: string): SessionState {
return {
version: 1,
sessionID,
turn: 0,
updatedAt: new Date().toISOString(),
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [],
compactionMemoryRefs: [],
};
}
async function readSessionStateSnapshot(root: string, sessionID: string): Promise<SessionState> {
const fallback = emptySessionStateSnapshot(sessionID);
const loaded = await readJSONSnapshot(await sessionStatePath(root, sessionID));
if (!isRecord(loaded)) return fallback;
return {
...fallback,
turn: typeof loaded.turn === "number" ? loaded.turn : fallback.turn,
updatedAt: typeof loaded.updatedAt === "string" ? loaded.updatedAt : fallback.updatedAt,
activeFiles: Array.isArray(loaded.activeFiles) ? loaded.activeFiles as SessionState["activeFiles"] : [],
openErrors: Array.isArray(loaded.openErrors) ? loaded.openErrors as SessionState["openErrors"] : [],
recentDecisions: Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions as SessionState["recentDecisions"] : [],
pendingMemories: memoryEntries(loaded.pendingMemories),
compactionMemoryRefs: Array.isArray(loaded.compactionMemoryRefs) ? loaded.compactionMemoryRefs as SessionState["compactionMemoryRefs"] : [],
};
}
export async function getMemoryStatus(root: string, sessionID: string): Promise<MemoryStatusModel> {
const [store, sessionState, pendingJournal] = await Promise.all([
readWorkspaceMemorySnapshot(root),
readSessionStateSnapshot(root, sessionID),
readPendingJournalSnapshot(root),
]);
const renderAccounting = accountWorkspaceMemoryRender(store);
const activeEntries = store.entries.filter(entry => entry.status !== "superseded");
const supersededEntries = store.entries.filter(entry => entry.status === "superseded");
return {
activeMemories: activeEntries.length,
supersededMemories: supersededEntries.length,
renderedInPrompt: renderAccounting.rendered.length,
omittedActiveMemories: renderAccounting.omitted.filter(item => item.memory.status !== "superseded").length,
pendingInSession: sessionState.pendingMemories.length,
pendingJournalMemories: pendingJournal.entries.length,
openErrors: sessionState.openErrors.filter(error => error.status === "open").length,
recentDecisions: sessionState.recentDecisions.length,
};
}
export function formatMemoryStatus(model: MemoryStatusModel): string {
return [
"## Memory status",
"",
"Workspace:",
`- Active memories: ${model.activeMemories}`,
`- Rendered in prompt: ${model.renderedInPrompt}`,
`- Omitted active memories: ${model.omittedActiveMemories}`,
`- Superseded memories: ${model.supersededMemories}`,
"",
"Pending:",
`- Pending in this session: ${model.pendingInSession}`,
`- Pending journal memories: ${model.pendingJournalMemories}`,
"",
"Session:",
`- Open errors: ${model.openErrors}`,
`- Recent decisions: ${model.recentDecisions}`,
"",
`Use /memory → Current memories to browse current [M1]-[M${LONG_TERM_LIMITS.maxEntries}] memory refs.`,
"",
"Local only: no LLM request was made.",
].join("\n");
}
function emptyMemoryListGroups(): MemoryListModel["groups"] {
return { feedback: [], project: [], decision: [], reference: [] };
}
export async function getMemoryList(root: string): Promise<MemoryListModel> {
const store = await readWorkspaceMemorySnapshot(root);
const accounting = accountWorkspaceMemoryCompactionRefs(store);
const groups = emptyMemoryListGroups();
const renderedMemoryIds = new Set(accounting.rendered.map(memory => memory.id));
for (const ref of accounting.refs) {
if (!renderedMemoryIds.has(ref.memoryId)) continue;
groups[ref.type].push({
ref: ref.ref,
text: safePreview(ref.textPreview),
});
}
const renderedMemories = MEMORY_TYPE_ORDER.reduce((total, type) => total + groups[type].length, 0);
return {
activeMemories: store.entries.filter(entry => entry.status !== "superseded").length,
renderedMemories,
omittedActiveMemories: accounting.omitted.filter(item => item.memory.status !== "superseded").length,
groups,
};
}
export function formatMemoryList(model: MemoryListModel): string {
const lines = [
"## Current workspace memories",
"",
];
if (model.renderedMemories === 0) {
lines.push("No active workspace memories are stored yet.", "", "Local only: no LLM request was made.");
return lines.join("\n");
}
lines.push("Display refs are local to this output and may change after memory updates.", "");
for (const type of MEMORY_TYPE_ORDER) {
const group = model.groups[type];
if (group.length === 0) continue;
lines.push(`${type}:`);
for (const item of group) {
lines.push(`- [${item.ref}] ${item.text}`);
}
lines.push("");
}
lines.push(
`Shown: ${model.renderedMemories} of ${model.activeMemories} active memories.`,
`Omitted active memories: ${model.omittedActiveMemories}.`,
"",
"Local only: no LLM request was made.",
);
return lines.join("\n");
}
export function formatMemoryHelp(): string {
return [
"## Memory help",
"",
"Command:",
"- /memory — open the local memory menu.",
"",
"Menu entries:",
"- Status — show local memory statistics.",
`- Current memories — browse active workspace memories as display-local [M1]-[M${LONG_TERM_LIMITS.maxEntries}] refs.`,
"- Help — show this help.",
"",
"These commands are read-only, local-only, and do not call the LLM.",
].join("\n");
}
export async function renderMemoryCommand(root: string, sessionID: string, command: MemoryVisibilityCommand): Promise<string> {
switch (command) {
case "status":
return formatMemoryStatus(await getMemoryStatus(root, sessionID));
case "list":
return formatMemoryList(await getMemoryList(root));
case "help":
return formatMemoryHelp();
default:
return formatMemoryHelp();
}
}
+45 -347
View File
@@ -19,32 +19,26 @@
* - Processes explicit memory from latest user text once per message id
* - Injects frozen workspace memory and dynamic hot session state into system prompt
* - Updates session state after tool execution
* - Augments compaction context with numbered memory refs, todos, and instruction
* - Augments compaction context with memory, hot state, todos, and instruction
* - Parses compaction summaries for memory candidates and merges them
*/
import { createHash, randomUUID } from "node:crypto";
import { realpath, rm } from "fs/promises";
import { rm } from "fs/promises";
import type { Plugin } from "@opencode-ai/plugin";
import {
extractExplicitMemoriesWithEvidence,
extractActiveFiles,
extractErrorsFromBash,
parseWorkspaceMemoryCandidatesWithEvidence,
staleAfterDaysFor,
type WorkspaceMemoryCommand,
} from "./extractors.ts";
import { assessMemoryQuality } from "./memory-quality.ts";
import {
loadWorkspaceMemory,
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
accountWorkspaceMemoryRender,
accountWorkspaceMemoryCompactionRefs,
workspaceMemoryExactKey,
workspaceMemoryIdentityKey,
} from "./workspace-memory.ts";
import { tryReinforceMemory } from "./retention.ts";
import { reinforceMemory } from "./retention.ts";
import {
appendPendingMemories,
clearPendingMemories,
@@ -63,7 +57,7 @@ import {
addRecentDecision,
renderHotSessionState,
} from "./session-state.ts";
import { sessionStatePath, workspaceKey } from "./paths.ts";
import { sessionStatePath } from "./paths.ts";
import {
latestUserText,
latestCompactionSummary,
@@ -71,7 +65,7 @@ import {
} from "./opencode.ts";
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts";
import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts";
import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
/**
* Build the complete compaction prompt.
@@ -81,14 +75,11 @@ import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, W
* Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter,
* horizontal rules, and delimiter lines.
*
* @param privateContext - Background context (numbered workspace memory refs,
* @param privateContext - Background context (workspace memory, hot session state,
* pending todos) from our plugin and any other plugins. Shown to the model to
* inform the summary but not copied verbatim.
*/
function buildCompactionPrompt(privateContext: string, compactionId?: string): string {
const snapshotInstruction = compactionId
? `- If you emit any REINFORCE or REPLACE command, include \`Memory ref snapshot id: ${compactionId}\` as the first line under \"Memory candidates:\" so numbered refs match the correct compaction snapshot.`
: "";
function buildCompactionPrompt(privateContext: string): string {
return [
"Provide a detailed summary for continuing our conversation above.",
"Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.",
@@ -103,7 +94,6 @@ function buildCompactionPrompt(privateContext: string, compactionId?: string): s
"- Do not output horizontal rules.",
"- Do not wrap the summary in delimiter lines such as ---.",
"- Do not use code fences around the summary.",
...(snapshotInstruction ? [snapshotInstruction] : []),
"",
"Use this structure:",
"",
@@ -125,19 +115,16 @@ function buildCompactionPrompt(privateContext: string, compactionId?: string): s
"",
"CRITICAL MEMORY RULES:",
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
"- Existing memories are numbered [M#]. If an existing memory is still accurate, emit at most 3 lines like `REINFORCE [M#]`; do not rephrase it.",
"- Use `REPLACE [M#] [type] text` only for eligible unreinforced compaction-sourced memories where the old text itself needs correction; this is rarely the right choice.",
"- To supplement or correct a memory, REINFORCE the existing [M#] if it is still accurate, and also emit a new complete [type] candidate with the addition or correction. Do not use REPLACE for additions; do not reinforce a memory that is now inaccurate.",
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
"- decision = future rule/architecture choice; reference = stable lookup fact; project = stable project fact; feedback = stable user preference.",
"- Do not use decision for service names, IDs, URLs, file paths, or one-off session status; use reference/project or skip.",
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
"- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.",
"- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.",
"- If unsure, skip it.",
"",
"Good memory examples:",
"- REINFORCE [M1]",
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
"- [decision] Keep memory dedupe exact-only for decisions.",
"- [decision] Do not add semantic merge to memory dedupe.",
"- [project] This repository is an OpenCode plugin using local JSON stores.",
"- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.",
"",
@@ -148,13 +135,11 @@ function buildCompactionPrompt(privateContext: string, compactionId?: string): s
"- The assistant reviewed code reviewer feedback and updated the plan.",
"- Commit a762e86 contains the owner scope fix.",
"",
"Format when there ARE REINFORCE/REPLACE commands or durable new candidates:",
"Format when there ARE durable memories:",
"Memory candidates:",
"REINFORCE [M#]",
"REPLACE [M#] [feedback|decision|project|reference] corrected durable fact",
"- [feedback|decision|project|reference] new future-facing durable fact",
"- [feedback|decision|project|reference] future-facing durable fact",
"",
"Format when there are NO REINFORCE/REPLACE commands or durable candidates:",
"Format when there are NO durable memories:",
"Memory candidates:",
"(none)",
"",
@@ -184,20 +169,6 @@ function safeErrorMessage(error: unknown): string {
return message.replace(/\s+/g, " ").slice(0, 240);
}
type CompactionRefResolution =
| {
ok: true;
refSnapshot: CompactionMemoryRef;
target: LongTermMemoryEntry;
targetIndex: number;
}
| {
ok: false;
reason: "missing_memory_ref_snapshot" | "invalid_memory_ref" | "memory_ref_target_unavailable" | "memory_ref_target_changed";
refSnapshot?: CompactionMemoryRef;
target?: LongTermMemoryEntry;
};
async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise<void> {
const message = safeErrorMessage(error);
console.error(`[memory] ${scope} failed: ${message}`);
@@ -212,23 +183,6 @@ async function warnMemoryHook(scope: string, error: unknown, root?: string): Pro
}
}
async function workspaceRootHash(root: string): Promise<string> {
const resolved = await realpath(root).catch(() => root);
return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
}
async function workspaceIdentity(root: string): Promise<{ workspaceKey: string; workspaceRootHash: string }> {
const [workspaceKeyValue, workspaceRootHashValue] = await Promise.all([
workspaceKey(root),
workspaceRootHash(root),
]);
return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue };
}
function compactionIdFromSummary(summary: string): string | undefined {
return summary.match(/Memory ref snapshot id:\s*([a-zA-Z0-9_-]+)/i)?.[1];
}
export const MemoryV2Plugin: Plugin = async (input) => {
const { directory, client } = input;
@@ -288,241 +242,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
};
}
function memoryReinforcedEvidence(
memory: LongTermMemoryEntry | undefined,
ref: string,
outcome: "reinforced" | "rejected",
reasonCodes: string[],
details: EvidenceEventInput["details"] = {},
): EvidenceEventInput {
const relationRole = outcome === "rejected" ? "target" : "reinforced";
return {
type: "memory_reinforced",
phase: "reinforcement",
outcome,
memory: memory ? memoryEvidenceRef(memory) : undefined,
relations: memory ? [{ role: relationRole, memory: memoryEvidenceRef(memory) }] : undefined,
reasonCodes,
details: {
ref,
...details,
},
textPreview: memory?.text,
};
}
function replacementMemoryId(): string {
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function memoryReplacedEvidence(
oldMemory: LongTermMemoryEntry | undefined,
newMemory: LongTermMemoryEntry | undefined,
ref: string,
outcome: "superseded" | "rejected",
reasonCodes: string[],
details: EvidenceEventInput["details"] = {},
): EvidenceEventInput {
const relations = outcome === "rejected"
? [
...(oldMemory ? [{ role: "target" as const, memory: memoryEvidenceRef(oldMemory) }] : []),
]
: [
...(oldMemory ? [{ role: "superseded" as const, memory: memoryEvidenceRef(oldMemory) }] : []),
...(newMemory ? [{ role: "superseded_by" as const, memory: memoryEvidenceRef(newMemory) }] : []),
];
return {
type: "memory_replaced_numbered_ref",
phase: "storage",
outcome,
memory: oldMemory ? memoryEvidenceRef(oldMemory) : undefined,
relations: relations.length > 0 ? relations : undefined,
reasonCodes,
details: {
ref,
...details,
},
textPreview: newMemory?.text ?? oldMemory?.text,
};
}
function compactionRefByLabel(refs: CompactionMemoryRef[]): Map<string, CompactionMemoryRef> {
return new Map(refs.map(ref => [ref.ref.toUpperCase(), ref]));
}
function compactionSnapshotStatus(
refs: CompactionMemoryRef[],
expectedCompactionId: string | undefined,
): { ok: true } | { ok: false; storedCompactionId: string } {
if (refs.length === 0) return { ok: false, storedCompactionId: "none" };
const ids = new Set(refs.map(ref => ref.compactionId).filter((id): id is string => typeof id === "string" && id.length > 0));
if (!expectedCompactionId) return { ok: true };
if (ids.size === 1 && ids.has(expectedCompactionId)) return { ok: true };
if (ids.size === 0) return { ok: false, storedCompactionId: "none" };
if (ids.size === 1) return { ok: false, storedCompactionId: [...ids][0] };
return { ok: false, storedCompactionId: "mixed" };
}
function resolveCompactionMemoryRef(
refs: CompactionMemoryRef[],
refsByLabel: Map<string, CompactionMemoryRef>,
entries: LongTermMemoryEntry[],
ref: string,
): CompactionRefResolution {
if (refs.length === 0) {
return { ok: false, reason: "missing_memory_ref_snapshot" };
}
const refSnapshot = refsByLabel.get(ref.toUpperCase());
if (!refSnapshot) {
return { ok: false, reason: "invalid_memory_ref" };
}
const targetIndex = entries.findIndex(entry => entry.id === refSnapshot.memoryId);
const target = targetIndex >= 0 ? entries[targetIndex] : undefined;
if (!target || target.status !== "active") {
return { ok: false, reason: "memory_ref_target_unavailable", refSnapshot, target };
}
if (workspaceMemoryExactKey(target) !== refSnapshot.exactKey) {
return { ok: false, reason: "memory_ref_target_changed", refSnapshot, target };
}
return { ok: true, refSnapshot, target, targetIndex };
}
async function applyCompactionMemoryCommands(
sessionID: string,
commands: WorkspaceMemoryCommand[],
compactionId: string | undefined,
): Promise<void> {
if (commands.length === 0) return;
const sessionState = await loadSessionState(directory, sessionID);
const snapshotStatus = compactionSnapshotStatus(sessionState.compactionMemoryRefs, compactionId);
const refs = snapshotStatus.ok ? sessionState.compactionMemoryRefs : [];
const refsByLabel = compactionRefByLabel(refs);
const evidence: EvidenceEventInput[] = [];
const now = Date.now();
let snapshotMismatchDetails: EvidenceEventInput["details"] = {};
if ("storedCompactionId" in snapshotStatus) {
snapshotMismatchDetails = {
...(compactionId ? { compactionId } : {}),
storedCompactionId: snapshotStatus.storedCompactionId,
};
}
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
for (const command of commands) {
const resolution = resolveCompactionMemoryRef(refs, refsByLabel, workspaceMemory.entries, command.ref);
if (resolution.ok === false) {
const memoryId = resolution.refSnapshot?.memoryId;
const details = memoryId ? { ...snapshotMismatchDetails, memoryId } : snapshotMismatchDetails;
if (command.kind === "REINFORCE") {
evidence.push(memoryReinforcedEvidence(resolution.target, command.ref, "rejected", [resolution.reason], details));
} else {
evidence.push(memoryReplacedEvidence(resolution.target, undefined, command.ref, "rejected", [resolution.reason], details));
}
continue;
}
const { refSnapshot, target, targetIndex } = resolution;
if (command.kind === "REINFORCE") {
const decision = tryReinforceMemory(target, sessionID, now);
if (decision.outcome === "blocked") {
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], {
memoryId: refSnapshot.memoryId,
blockReason: decision.blockReason,
attemptedAtMs: now,
attemptedAtIso: new Date(now).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
}));
continue;
}
const reinforced = decision.memory;
workspaceMemory.entries[targetIndex] = reinforced;
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
memoryId: refSnapshot.memoryId,
reinforcementOutcome: "reinforced",
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
}));
continue;
}
if (target.source !== "compaction") {
evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_memory_source"], {
memoryId: refSnapshot.memoryId,
source: target.source,
}));
continue;
}
if ((target.reinforcementCount ?? 0) > 0) {
evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_reinforced_target"], {
memoryId: refSnapshot.memoryId,
reinforcementCount: target.reinforcementCount ?? 0,
}));
continue;
}
const quality = assessMemoryQuality({ type: command.type, text: command.text, source: "compaction" });
if (!quality.accepted) {
evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", quality.reasons, {
memoryId: refSnapshot.memoryId,
}));
continue;
}
const supersededTarget: LongTermMemoryEntry = {
...target,
status: "superseded",
updatedAt: new Date(now).toISOString(),
};
const replacement: LongTermMemoryEntry = {
id: replacementMemoryId(),
type: command.type,
text: command.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: new Date(now).toISOString(),
updatedAt: new Date(now).toISOString(),
retentionClock: now,
staleAfterDays: staleAfterDaysFor(command.type),
supersedes: [target.id],
};
workspaceMemory.entries[targetIndex] = supersededTarget;
workspaceMemory.entries.push(replacement);
evidence.push(memoryReplacedEvidence(supersededTarget, replacement, command.ref, "superseded", [
"numbered_ref_replace",
command.type === target.type ? "same_type_replace" : "cross_type_replace",
], {
oldMemoryId: target.id,
newMemoryId: replacement.id,
oldType: target.type,
newType: command.type,
}));
}
return workspaceMemory;
});
await appendEvidenceEvents(directory, [...updateResult.evidence, ...evidence].map(event => ({
...event,
sessionHash: sessionID,
})));
}
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
@@ -676,12 +395,11 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const key = memoryKey(memory);
const existing = existingByKey.get(key);
if (existing) {
const decision = tryReinforceMemory(
const reinforced = reinforceMemory(
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 });
@@ -829,13 +547,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
return props?.sessionID ?? props?.info?.id;
}
async function clearCompactionMemoryRefs(sessionID: string): Promise<void> {
await updateSessionState(directory, sessionID, state => {
state.compactionMemoryRefs = [];
return state;
});
}
return {
// Inject workspace memory and hot session state into system prompt
"experimental.chat.system.transform": async (hookInput, output) => {
@@ -964,27 +675,23 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// so we must explicitly carry forward any existing output.context.
const otherContext = output.context.filter(Boolean).join("\n\n");
// Build our private context (numbered workspace memory refs, todos)
// Build our private context (workspace memory, hot state, todos)
const contextParts: string[] = [];
// 1. Compaction-only numbered workspace memory snapshot
const compactionId = randomUUID();
const workspaceStore = await loadWorkspaceMemory(directory);
const compactionRefs = accountWorkspaceMemoryCompactionRefs(workspaceStore);
const refsWithCompactionId = compactionRefs.refs.map(ref => ({ ...ref, compactionId }));
await updateSessionState(directory, sessionID, state => {
state.compactionMemoryRefs = refsWithCompactionId;
return state;
});
await appendEvidenceEvents(directory, compactionRefs.evidence.map(event => ({
...event,
sessionHash: sessionID,
})));
if (compactionRefs.prompt) {
contextParts.push(compactionRefs.prompt);
// 1. Frozen workspace memory snapshot
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
if (workspaceSnapshot.renderedPrompt) {
contextParts.push(workspaceSnapshot.renderedPrompt);
}
// 2. Pending todos from OpenCode
// 2. Hot session state
const sessionState = await loadSessionState(directory, sessionID);
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
contextParts.push(hotPrompt);
}
// 3. Pending todos from OpenCode
const todos = await pendingTodos(client, sessionID);
const todosPrompt = renderTodosForCompaction(todos);
if (todosPrompt) {
@@ -997,7 +704,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
.join("\n\n");
// Replace the default prompt entirely with our ---free template
output.prompt = buildCompactionPrompt(privateContext, compactionId);
output.prompt = buildCompactionPrompt(privateContext);
// Clear context array since we consumed it into output.prompt.
// Subsequent plugins that set output.prompt will also need to check
@@ -1011,40 +718,31 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Handle session events
event: async ({ event }) => {
if (event.type === "session.compacted") {
let sessionID: string | undefined;
try {
sessionID = sessionIDFromEventProperties(event.properties);
const sessionID = sessionIDFromEventProperties(event.properties);
if (!sessionID) return;
// Sub-agents don't need post-compaction processing
if (await isSubAgent(sessionID)) return;
try {
// Parse latest compaction summary for memory candidates, stage them into
// durable pending journal, then promote pending memories.
const summary = await latestCompactionSummary(client, sessionID);
const compactionId = summary ? compactionIdFromSummary(summary) : undefined;
const parseResult = summary
? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory))
: { entries: [], commands: [], evidence: [] };
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
...event,
// Parse latest compaction summary for memory candidates, stage them into
// durable pending journal, then promote pending memories.
const summary = await latestCompactionSummary(client, sessionID);
const parseResult = summary ? parseWorkspaceMemoryCandidatesWithEvidence(summary) : { entries: [], evidence: [] };
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
...event,
sessionHash: sessionID,
})));
const candidates = parseResult.entries;
if (candidates.length > 0) {
await appendPendingMemories(directory, candidates);
await appendEvidenceEvents(directory, candidates.map(memory => ({
...pendingAppendedEvidence(memory),
sessionHash: sessionID,
})));
await applyCompactionMemoryCommands(sessionID, parseResult.commands, compactionId);
const candidates = parseResult.entries;
if (candidates.length > 0) {
await appendPendingMemories(directory, candidates);
await appendEvidenceEvents(directory, candidates.map(memory => ({
...pendingAppendedEvidence(memory),
sessionHash: sessionID,
})));
}
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
} finally {
await clearCompactionMemoryRefs(sessionID);
}
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
} catch (error) {
// Keep pending memories in session/journal for retry on next event/session.
await warnMemoryHook("event.session.compacted", error, directory);
+14 -46
View File
@@ -1,11 +1,5 @@
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
export type ReinforcementBlockReason = "same_session" | "same_utc_day" | "min_interval" | "max_count";
export type ReinforcementDecision =
| { outcome: "reinforced"; memory: LongTermMemoryEntry; previousReinforcementCount: number; newReinforcementCount: number }
| { outcome: "blocked"; memory: LongTermMemoryEntry; blockReason: ReinforcementBlockReason; lastReinforcedAt?: number; reinforcementCount: number; maxReinforcementCount: number; minIntervalMs: number };
// Retention decay model constants (v1.5)
export const BASE_HALF_LIFE_DAYS = 45;
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
@@ -36,7 +30,7 @@ export const USER_IMPORTANCE_FACTOR = {
export const RETENTION_TYPE_MAX = {
feedback: 10,
decision: 12,
decision: 10,
project: 8,
reference: 6,
} as const;
@@ -122,59 +116,33 @@ function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
&& d1.getUTCDate() === d2.getUTCDate();
}
export function tryReinforceMemory(
export function reinforceMemory(
memory: LongTermMemoryEntry,
sessionId: string,
now: number,
): ReinforcementDecision {
const count = memory.reinforcementCount ?? 0;
const lastAt = memory.lastReinforcedAt ?? 0;
const lastSession = memory.lastReinforcedSessionID;
if (lastSession === sessionId) {
return blockedDecision(memory, "same_session", count, lastAt);
): LongTermMemoryEntry {
if (memory.lastReinforcedSessionID === sessionId) {
return memory;
}
if (count >= REINFORCEMENT_MAX_COUNT) {
return blockedDecision(memory, "max_count", count, lastAt);
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
return memory;
}
if (lastAt > 0 && now < lastAt + REINFORCEMENT_MIN_INTERVAL_MS) {
return blockedDecision(memory, "min_interval", count, lastAt);
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
return memory;
}
if (lastAt > 0 && isSameUTCCalendarDay(lastAt, now)) {
return blockedDecision(memory, "same_utc_day", count, lastAt);
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
return memory;
}
const reinforced: LongTermMemoryEntry = {
return {
...memory,
reinforcementCount: count + 1,
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
retentionClock: now,
};
return {
outcome: "reinforced",
memory: reinforced,
previousReinforcementCount: count,
newReinforcementCount: count + 1,
};
}
function blockedDecision(
memory: LongTermMemoryEntry,
blockReason: ReinforcementBlockReason,
reinforcementCount: number,
lastReinforcedAt: number,
): ReinforcementDecision {
return {
outcome: "blocked",
memory,
blockReason,
...(lastReinforcedAt > 0 ? { lastReinforcedAt } : {}),
reinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
minIntervalMs: REINFORCEMENT_MIN_INTERVAL_MS,
};
}
+39 -181
View File
@@ -1,14 +1,10 @@
import { relative } from "path";
import { sessionStatePath } from "./paths.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts";
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "./types.ts";
import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts";
import { HOT_STATE_LIMITS } from "./types.ts";
import { memoryKey } from "./pending-journal.ts";
type SessionStateInput = Omit<SessionState, "compactionMemoryRefs"> & {
compactionMemoryRefs?: unknown;
};
const ACTION_WEIGHT: Record<ActiveFile["action"], number> = {
edit: 50,
write: 45,
@@ -26,7 +22,6 @@ export function createEmptySessionState(sessionID: string): SessionState {
openErrors: [],
recentDecisions: [],
pendingMemories: [],
compactionMemoryRefs: [],
};
}
@@ -38,11 +33,10 @@ export async function loadSessionState(root: string, sessionID: string): Promise
loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : [];
loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : [];
loaded.pendingMemories = Array.isArray(loaded.pendingMemories) ? loaded.pendingMemories : [];
loaded.compactionMemoryRefs = normalizeCompactionMemoryRefs((loaded as SessionStateInput).compactionMemoryRefs);
return loaded;
}
export async function saveSessionState(root: string, state: SessionState | SessionStateInput): Promise<void> {
export async function saveSessionState(root: string, state: SessionState): Promise<void> {
await atomicWriteJSON(await sessionStatePath(root, state.sessionID), normalizeSessionState(state));
}
@@ -58,53 +52,18 @@ export async function updateSessionState(
current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : [];
current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : [];
current.pendingMemories = Array.isArray(current.pendingMemories) ? current.pendingMemories : [];
current.compactionMemoryRefs = normalizeCompactionMemoryRefs((current as SessionStateInput).compactionMemoryRefs);
return normalizeSessionState(await updater(current));
});
}
function normalizeSessionState(state: SessionState | SessionStateInput): SessionState {
function normalizeSessionState(state: SessionState): SessionState {
state.updatedAt = new Date().toISOString();
state.activeFiles = state.activeFiles.slice(0, HOT_STATE_LIMITS.maxActiveFilesStored);
state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored);
state.recentDecisions = state.recentDecisions.slice(0, HOT_STATE_LIMITS.maxRecentDecisionsStored);
state.pendingMemories = dedupePendingMemories(Array.isArray(state.pendingMemories) ? state.pendingMemories : [])
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesStored);
return {
...state,
compactionMemoryRefs: normalizeCompactionMemoryRefs(state.compactionMemoryRefs),
};
}
function normalizeCompactionMemoryRefs(value: unknown): CompactionMemoryRef[] {
if (!Array.isArray(value)) return [];
if (value.some(item => !isCompactionMemoryRef(item))) return [];
return value.slice(0, LONG_TERM_LIMITS.maxEntries);
}
function isCompactionMemoryRef(value: unknown): value is CompactionMemoryRef {
if (!isRecord(value)) return false;
if (typeof value.ref !== "string" || !/^M[1-9]\d*$/.test(value.ref)) return false;
if (typeof value.memoryId !== "string" || value.memoryId.trim() === "") return false;
if (value.compactionId !== undefined && typeof value.compactionId !== "string") return false;
if (!isLongTermType(value.type)) return false;
if (!isLongTermSource(value.source)) return false;
if (typeof value.exactKey !== "string" || value.exactKey.trim() === "") return false;
if (typeof value.identityKey !== "string" || value.identityKey.trim() === "") return false;
if (typeof value.textPreview !== "string") return false;
return typeof value.capturedAt === "number" && Number.isFinite(value.capturedAt);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isLongTermType(value: unknown): value is CompactionMemoryRef["type"] {
return value === "feedback" || value === "project" || value === "decision" || value === "reference";
}
function isLongTermSource(value: unknown): value is CompactionMemoryRef["source"] {
return value === "explicit" || value === "compaction" || value === "manual";
return state;
}
function dedupePendingMemories(memories: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
@@ -230,150 +189,49 @@ export function clearErrorsForSuccessfulCommand(state: SessionState, command: st
state.updatedAt = new Date().toISOString();
}
export type HotStateSection = "active_files" | "open_errors" | "recent_decisions" | "pending_memories";
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
const activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesRendered);
const openErrors = [...state.openErrors]
.sort((a, b) => b.lastSeen - a.lastSeen)
.slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered);
const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
const pendingMemories = dedupePendingMemories(state.pendingMemories)
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesRendered);
export type HotStateOmissionReason = "section_cap" | "char_budget";
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0 && pendingMemories.length === 0) return "";
export type HotStateOmittedItem = {
section: HotStateSection;
reason: HotStateOmissionReason;
text: string;
memoryId?: string;
};
const lines: string[] = ["Hot session state (current session):"];
export type HotSessionStateRenderAccounting = {
prompt: string;
omitted: HotStateOmittedItem[];
maxRenderedChars: number;
};
type HotStateRenderItem = {
section: HotStateSection;
line: string;
memoryId?: string;
};
type HotStateRenderSection = {
heading: `${HotStateSection}:`;
items: HotStateRenderItem[];
};
const HOT_STATE_PREFIX = "Hot session state (current session):";
export function accountHotSessionStateRender(state: SessionState, workspaceRoot: string): HotSessionStateRenderAccounting {
const maxRenderedChars = HOT_STATE_LIMITS.maxRenderedChars;
const omitted: HotStateOmittedItem[] = [];
const sections = buildHotStateRenderSections(state, workspaceRoot, omitted);
if (sections.every(section => section.items.length === 0)) {
return { prompt: "", omitted, maxRenderedChars };
}
const lines: string[] = [HOT_STATE_PREFIX];
let renderedEntryCount = 0;
for (const section of sections) {
let headingRendered = false;
for (const item of section.items) {
if (headingRendered) {
if (wouldFit(lines, item.line, maxRenderedChars)) {
lines.push(item.line);
renderedEntryCount += 1;
} else {
omitted.push(omitHotStateItem(item, "char_budget"));
}
continue;
}
if (wouldFit(lines, section.heading, maxRenderedChars)
&& wouldFit([...lines, section.heading], item.line, maxRenderedChars)) {
lines.push(section.heading, item.line);
headingRendered = true;
renderedEntryCount += 1;
} else {
omitted.push(omitHotStateItem(item, "char_budget"));
}
if (activeFiles.length > 0) {
lines.push("active_files:");
for (const item of activeFiles) {
const viewPath = displayPath(workspaceRoot, item.path);
lines.push(`- ${viewPath} (${item.action}, ${item.count}x)`);
}
}
if (renderedEntryCount === 0) return { prompt: "", omitted, maxRenderedChars };
if (openErrors.length > 0) {
lines.push("open_errors:");
for (const err of openErrors) {
lines.push(`- [${err.category}] ${err.summary}`);
}
}
return { prompt: lines.join("\n"), omitted, maxRenderedChars };
}
if (decisions.length > 0) {
lines.push("recent_decisions:");
for (const decision of decisions) {
lines.push(`- ${decision.text}`);
}
}
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
return accountHotSessionStateRender(state, workspaceRoot).prompt;
}
if (pendingMemories.length > 0) {
lines.push("pending_memories:");
for (const memory of pendingMemories) {
lines.push(`- [${memory.type}] ${memory.text}`);
}
}
function buildHotStateRenderSections(
state: SessionState,
workspaceRoot: string,
omitted: HotStateOmittedItem[],
): HotStateRenderSection[] {
const activeFiles = rankActiveFiles(state.activeFiles).map(item => ({
section: "active_files" as const,
line: `- ${displayPath(workspaceRoot, item.path)} (${item.action}, ${item.count}x)`,
}));
const openErrors = [...state.openErrors]
.sort((a, b) => b.lastSeen - a.lastSeen)
.map(err => ({
section: "open_errors" as const,
line: `- [${err.category}] ${err.summary}`,
}));
const decisions = state.recentDecisions.map(item => ({
section: "recent_decisions" as const,
line: `- ${item.text}`,
}));
const pendingMemories = dedupePendingMemories(state.pendingMemories).map(item => ({
section: "pending_memories" as const,
line: `- [${item.type}] ${item.text}`,
memoryId: item.id,
}));
const renderedActiveFiles = capHotStateItems(activeFiles, HOT_STATE_LIMITS.maxActiveFilesRendered, "start", omitted);
const renderedOpenErrors = capHotStateItems(openErrors, HOT_STATE_LIMITS.maxOpenErrorsRendered, "start", omitted);
const renderedDecisions = capHotStateItems(decisions, HOT_STATE_LIMITS.maxRecentDecisionsStored, "end", omitted);
const renderedPendingMemories = capHotStateItems(
pendingMemories,
HOT_STATE_LIMITS.maxPendingMemoriesRendered,
"end",
omitted,
);
return [
{ heading: "active_files:", items: renderedActiveFiles },
{ heading: "open_errors:", items: renderedOpenErrors },
{ heading: "recent_decisions:", items: renderedDecisions },
{ heading: "pending_memories:", items: renderedPendingMemories },
];
}
function capHotStateItems(
items: HotStateRenderItem[],
cap: number,
keep: "start" | "end",
omitted: HotStateOmittedItem[],
): HotStateRenderItem[] {
if (items.length <= cap) return items;
const renderedItems = keep === "start" ? items.slice(0, cap) : items.slice(-cap);
const omittedItems = keep === "start" ? items.slice(cap) : items.slice(0, items.length - cap);
omitted.push(...omittedItems.map(item => omitHotStateItem(item, "section_cap")));
return renderedItems;
}
function omitHotStateItem(item: HotStateRenderItem, reason: HotStateOmissionReason): HotStateOmittedItem {
return {
section: item.section,
reason,
text: item.line,
...(item.memoryId ? { memoryId: item.memoryId } : {}),
};
}
function wouldFit(lines: string[], nextLine: string, maxRenderedChars: number): boolean {
return [...lines, nextLine].join("\n").length <= maxRenderedChars;
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
}
function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] {
-304
View File
@@ -1,304 +0,0 @@
import {
formatMemoryHelp,
formatMemoryList,
getMemoryList,
renderMemoryCommand,
type MemoryVisibilityCommand,
} from "./memory-visibility.ts";
type DialogContext = {
clear?: () => void;
};
type DialogSize = "medium" | "large" | "xlarge";
type DialogElement = unknown;
type DialogStackContext = {
clear?: () => void;
replace?: (render: () => DialogElement, onClose?: () => void) => void;
setSize?: (size: DialogSize) => void;
};
type DialogAlertComponent = (props: { title: string; message: string; onConfirm?: () => void }) => DialogElement;
type DialogSelectOption<Value = string> = {
title: string;
value: Value;
description?: string;
footer?: string;
category?: string;
disabled?: boolean;
onSelect?: () => void | Promise<void>;
};
type DialogSelectProps<Value = string> = {
title: string;
placeholder?: string;
options: DialogSelectOption<Value>[];
onSelect?: (option: DialogSelectOption<Value>) => void | Promise<void>;
skipFilter?: boolean;
};
type DialogSelectComponent = <Value = string>(props: DialogSelectProps<Value>) => DialogElement;
type TuiCommand = {
title: string;
value: string;
description?: string;
category?: string;
suggested?: boolean;
slash?: {
name: string;
aliases?: string[];
};
onSelect?: (dialog?: DialogContext) => void | Promise<void>;
};
type TuiRouteCurrent =
| { name: "home" }
| { name: "session"; params: { sessionID: string; prompt?: unknown } }
| { name: string; params?: Record<string, unknown> };
type TuiPluginApi = {
command: {
register: (cb: () => TuiCommand[]) => () => void;
};
route: ({ readonly current: TuiRouteCurrent } | TuiRouteCurrent);
ui: {
DialogAlert?: DialogAlertComponent;
DialogSelect?: DialogSelectComponent;
toast: (input: { variant?: "info" | "success" | "warning" | "error"; message: string }) => void;
dialog?: DialogStackContext;
};
state: {
path: {
directory: string;
};
};
client?: unknown;
};
type TuiPlugin = (api: TuiPluginApi, options: unknown, meta: unknown) => Promise<void>;
function currentRoute(api: TuiPluginApi): TuiRouteCurrent {
const route = api.route as ({ readonly current?: TuiRouteCurrent } & Partial<TuiRouteCurrent>);
return route.current ?? (route as TuiRouteCurrent);
}
function renderErrorReport(error: unknown): string {
const detail = error instanceof Error ? error.message : String(error);
return [
"## Memory error",
"",
"Unable to render local memory visibility output.",
`Error: ${detail}`,
"",
"Local only: no LLM request was made.",
].join("\n");
}
function dialogSizeForCommand(command: MemoryVisibilityCommand): DialogSize {
if (command === "list") return "xlarge";
if (command === "status") return "large";
return "medium";
}
function fallbackTitleForCommand(command: MemoryVisibilityCommand): string {
if (command === "list") return "Current workspace memories";
if (command === "status") return "Memory status";
return "Memory help";
}
function dialogCopyFromMarkdown(text: string, fallbackTitle: string): { title: string; message: string } {
const match = /^##\s+(.+)$/m.exec(text);
if (!match) return { title: fallbackTitle, message: text };
const headingStart = match.index;
const headingEnd = text.indexOf("\n", headingStart);
const before = text.slice(0, headingStart);
const after = headingEnd === -1 ? "" : text.slice(headingEnd + 1);
return {
title: match[1].trim(),
message: `${before}${after}`.replace(/^\s+/, ""),
};
}
function getDialogApi(api: TuiPluginApi): {
DialogAlert: DialogAlertComponent;
DialogSelect: DialogSelectComponent;
dialog: Required<Pick<DialogStackContext, "replace" | "setSize">>;
} | undefined {
if (
typeof api.ui.DialogAlert !== "function" ||
typeof api.ui.DialogSelect !== "function" ||
typeof api.ui.dialog?.replace !== "function" ||
typeof api.ui.dialog?.setSize !== "function"
) {
api.ui.toast({
variant: "error",
message: "Memory dialog UI is unavailable in this OpenCode runtime.",
});
return undefined;
}
return {
DialogAlert: api.ui.DialogAlert,
DialogSelect: api.ui.DialogSelect,
dialog: {
replace: api.ui.dialog.replace,
setSize: api.ui.dialog.setSize,
},
};
}
function showDialogError(api: TuiPluginApi, error: unknown): void {
const detail = error instanceof Error ? error.message : String(error);
api.ui.toast({
variant: "error",
message: `Unable to show memory dialog: ${detail}`,
});
}
function showAlertFromMarkdown(api: TuiPluginApi, text: string, fallbackTitle: string, size: DialogSize): void {
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
const { title, message } = dialogCopyFromMarkdown(text, fallbackTitle);
try {
dialogApi.dialog.replace(() => dialogApi.DialogAlert({ title, message }));
dialogApi.dialog.setSize(size);
} catch (error) {
showDialogError(api, error);
}
}
function showMemoryMenu(api: TuiPluginApi, dialog?: DialogContext): void {
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
const options: DialogSelectOption[] = [
{
title: "Status",
value: "memory.status",
description: "Show local memory statistics",
onSelect: () => showMemoryStatus(api),
},
{
title: "Current memories",
value: "memory.list",
description: "Browse active workspace memories with display-local refs",
onSelect: () => showMemoryList(api),
},
{
title: "Help",
value: "memory.help",
description: "Show memory command help",
onSelect: () => showMemoryHelp(api),
},
];
try {
dialog?.clear?.();
dialogApi.dialog.replace(() => dialogApi.DialogSelect({
title: "Memory",
placeholder: "Search memory actions",
options,
}));
dialogApi.dialog.setSize("large");
} catch (error) {
showDialogError(api, error);
}
}
async function showMemoryStatus(api: TuiPluginApi): Promise<void> {
const route = currentRoute(api);
if (route.name !== "session" || typeof route.params?.sessionID !== "string") {
api.ui.toast({
variant: "warning",
message: "Open a session to use memory commands.",
});
return;
}
const sessionID = route.params.sessionID;
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
let text: string;
let fallbackTitle = fallbackTitleForCommand("status");
try {
text = await renderMemoryCommand(api.state.path.directory, sessionID, "status");
} catch (error) {
text = renderErrorReport(error);
fallbackTitle = "Memory error";
}
const { title, message } = dialogCopyFromMarkdown(text, fallbackTitle);
try {
dialogApi.dialog.replace(() => dialogApi.DialogAlert({ title, message }));
dialogApi.dialog.setSize(dialogSizeForCommand("status"));
} catch (error) {
showDialogError(api, error);
}
}
function showMemoryHelp(api: TuiPluginApi): void {
showAlertFromMarkdown(api, formatMemoryHelp(), "Memory help", "medium");
}
const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const;
async function showMemoryList(api: TuiPluginApi): Promise<void> {
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
try {
const model = await getMemoryList(api.state.path.directory);
if (model.renderedMemories === 0) {
showAlertFromMarkdown(api, formatMemoryList(model), "Current workspace memories", "medium");
return;
}
const options: DialogSelectOption[] = [];
for (const type of MEMORY_TYPE_ORDER) {
for (const item of model.groups[type]) {
options.push({
title: `[${item.ref}] ${item.text}`,
value: item.ref,
category: type,
footer: "display-local",
});
}
}
dialogApi.dialog.replace(() => dialogApi.DialogSelect({
title: "Current workspace memories",
placeholder: "Search memory refs",
options,
}));
dialogApi.dialog.setSize("xlarge");
} catch (error) {
showAlertFromMarkdown(api, renderErrorReport(error), "Memory error", "medium");
}
}
function memoryCommands(api: TuiPluginApi): TuiCommand[] {
return [
{
title: "Memory",
value: "memory.menu",
description: "Browse local working memory.",
category: "Memory",
slash: { name: "memory" },
onSelect: (dialog?: DialogContext) => showMemoryMenu(api, dialog),
},
];
}
export const MemoryTuiPlugin: TuiPlugin = async (api) => {
api.command.register(() => memoryCommands(api));
};
export default {
id: "working-memory-tui",
tui: MemoryTuiPlugin,
};
-13
View File
@@ -28,18 +28,6 @@ export type LongTermMemoryEntry = {
safetyCritical?: boolean;
};
export type CompactionMemoryRef = {
ref: string;
compactionId?: string;
memoryId: string;
type: LongTermType;
source: LongTermSource;
exactKey: string;
identityKey: string;
textPreview: string;
capturedAt: number;
};
export type WorkspaceMemoryStore = {
version: 1;
workspace: {
@@ -104,7 +92,6 @@ export type SessionState = {
openErrors: OpenError[];
recentDecisions: SessionDecision[];
pendingMemories: LongTermMemoryEntry[];
compactionMemoryRefs: CompactionMemoryRef[];
};
export const LONG_TERM_LIMITS = {
+28 -312
View File
@@ -1,6 +1,6 @@
import { appendFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import type { CompactionMemoryRef, LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
import { LONG_TERM_LIMITS } from "./types.ts";
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
@@ -9,17 +9,14 @@ import { redactCredentials } from "./redaction.ts";
import {
RETENTION_TYPE_MAX,
calculateRetentionStrength,
tryReinforceMemory,
type ReinforcementDecision,
reinforceMemory,
} from "./retention.ts";
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
import { appendEvidenceEvents } from "./evidence-log.ts";
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
const MIN_ENVELOPE_LENGTH = 80;
const MIGRATION_ID = "2026-04-26-p0-cleanup";
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
const RETENTION_CLOCK_BACKFILL_MIGRATION_ID = "2026-05-01-retention-clock-backfill";
export type MemoryConsolidationReason =
| "promoted"
@@ -60,16 +57,6 @@ export type WorkspaceMemoryRenderAccounting = {
prompt: string;
};
export type WorkspaceMemoryCompactionRefsAccounting = WorkspaceMemoryRenderAccounting & {
refs: CompactionMemoryRef[];
};
type WorkspaceMemoryRenderSelection = {
active: LongTermMemoryEntry[];
omitted: WorkspaceMemoryRenderAccounting["omitted"];
maxChars: number;
};
export type QualityCleanupMigrationLogEntry = {
migrationId: string;
timestamp: string;
@@ -85,17 +72,6 @@ export type QualityCleanupMigrationLogEntry = {
afterStatus: "superseded";
};
export type P0CleanupMigrationResult = {
store: WorkspaceMemoryStore;
events: EvidenceEventInput[];
};
export type QualityCleanupMigrationResult = {
store: WorkspaceMemoryStore;
events: QualityCleanupMigrationLogEntry[];
evidence: EvidenceEventInput[];
};
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
return {
@@ -137,14 +113,6 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
// writes for ordering/capacity/timestamp-only normalization.
if (hasSecurityOrMigrationChange(store, normalized.store)) {
await atomicWriteJSON(path, normalized.store);
// loadWorkspaceMemory has a narrow migration side effect: when a first-load
// migration actually supersedes entries, append evidence for those storage
// changes. Migration IDs keep this idempotent on repeated loads.
const migrationEvidence = normalized.evidence.filter(event => event.type === "memory_migration_superseded");
if (migrationEvidence.length > 0) {
await appendEvidenceEvents(root, migrationEvidence);
}
}
return normalized.store;
@@ -161,7 +129,6 @@ function hasSecurityOrMigrationChange(
if (beforeEntry.text !== afterEntry.text) return true;
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
if (beforeEntry.status !== afterEntry.status) return true;
if ((beforeEntry.retentionClock ?? null) !== (afterEntry.retentionClock ?? null)) return true;
}
const beforeMigrations = JSON.stringify(before.migrations ?? []);
@@ -190,15 +157,8 @@ export async function updateWorkspaceMemoryWithAccounting(
const fallback = await emptyWorkspaceMemory(root);
let finalResult: WorkspaceMemoryNormalizationResult | undefined;
const store = await updateJSON(path, () => fallback, async current => {
const currentNormalization = await normalizeWorkspaceMemoryWithAccounting(root, current);
const currentMigrationEvidence = currentNormalization.evidence.filter(event => event.type === "memory_migration_superseded");
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(currentNormalization.store));
if (currentMigrationEvidence.length > 0) {
finalResult = {
...finalResult,
evidence: [...currentMigrationEvidence, ...finalResult.evidence],
};
}
const normalized = await normalizeWorkspaceMemory(root, current);
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized));
return finalResult.store;
});
@@ -224,8 +184,7 @@ export async function normalizeWorkspaceMemoryWithAccounting(
root: string,
store: WorkspaceMemoryStore,
): Promise<WorkspaceMemoryNormalizationResult> {
const nowMs = Date.now();
const nowIso = new Date(nowMs).toISOString();
const nowIso = new Date().toISOString();
let result: WorkspaceMemoryStore = {
...store,
@@ -262,12 +221,10 @@ export async function normalizeWorkspaceMemoryWithAccounting(
const beforeQualityCleanup = result;
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
result = qualityCleanup.store;
let migrationEvidence: EvidenceEventInput[] = [];
let skipRemainingMigrations = false;
if (qualityCleanup.events.length > 0) {
try {
await appendQualityCleanupMigrationLog(qualityCleanup.events);
migrationEvidence = [...migrationEvidence, ...qualityCleanup.evidence];
} catch (error) {
console.error("[memory] failed to write quality cleanup migration log:", error);
console.error("[memory] aborting migration to maintain audit trail integrity");
@@ -276,18 +233,7 @@ export async function normalizeWorkspaceMemoryWithAccounting(
}
}
if (!skipRemainingMigrations) {
const p0Cleanup = runMigrationP0Cleanup(result, nowIso);
result = p0Cleanup.store;
migrationEvidence = [...migrationEvidence, ...p0Cleanup.events];
}
result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs));
if (!result.migrations.includes(RETENTION_CLOCK_BACKFILL_MIGRATION_ID)) {
result = {
...result,
migrations: [...result.migrations, RETENTION_CLOCK_BACKFILL_MIGRATION_ID],
updatedAt: nowIso,
};
result = runMigrationP0Cleanup(result, nowIso);
}
// P0 accounting only considers active entries. Entries that were already
@@ -311,64 +257,39 @@ export async function normalizeWorkspaceMemoryWithAccounting(
dropped: accounting.dropped,
absorbed: accounting.absorbed,
superseded: accounting.superseded,
evidence: [...migrationEvidence, ...accounting.evidence],
evidence: accounting.evidence,
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
};
}
function backfillRetentionClock(entry: LongTermMemoryEntry, nowMs: number): LongTermMemoryEntry {
if (Number.isFinite(entry.retentionClock)) {
return entry;
}
const createdAtMs = new Date(entry.createdAt).getTime();
if (Number.isFinite(createdAtMs)) {
return { ...entry, retentionClock: createdAtMs };
}
const updatedAtMs = new Date(entry.updatedAt).getTime();
if (Number.isFinite(updatedAtMs)) {
return { ...entry, retentionClock: updatedAtMs };
}
return { ...entry, retentionClock: nowMs };
}
export function runMigrationP0Cleanup(
store: WorkspaceMemoryStore,
nowIso: string,
): P0CleanupMigrationResult {
): WorkspaceMemoryStore {
if (store.migrations?.includes(MIGRATION_ID)) {
return { store, events: [] };
return store;
}
const events: EvidenceEventInput[] = [];
const entries = store.entries.map(entry => {
if (entry.source !== "compaction") return entry;
if (entry.type !== "project") return entry;
if (entry.status === "superseded") return entry;
if (isProgressSnapshotViolation(entry.text)) {
const superseded = {
return {
...entry,
status: "superseded" as const,
updatedAt: nowIso,
};
events.push(migrationSupersededEvidence(superseded, ["migration:p0_cleanup"], MIGRATION_ID));
return superseded;
}
return entry;
});
return {
store: {
...store,
entries,
migrations: [...(store.migrations || []), MIGRATION_ID],
updatedAt: nowIso,
},
events,
...store,
entries,
migrations: [...(store.migrations || []), MIGRATION_ID],
updatedAt: nowIso,
};
}
@@ -382,13 +303,12 @@ async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationL
export function runMigrationQualityCleanup(
store: WorkspaceMemoryStore,
nowIso: string,
): QualityCleanupMigrationResult {
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
return { store, events: [], evidence: [] };
return { store, events: [] };
}
const events: QualityCleanupMigrationLogEntry[] = [];
const evidence: EvidenceEventInput[] = [];
let changed = false;
const entries = store.entries.map(entry => {
if (entry.source !== "compaction") return entry;
@@ -422,19 +342,12 @@ export function runMigrationQualityCleanup(
...hardReasons.map(reason => `quality:${reason}`),
]);
const superseded = {
return {
...entry,
status: "superseded" as const,
updatedAt: nowIso,
tags: [...tags],
};
evidence.push(migrationSupersededEvidence(
superseded,
["migration:quality_cleanup", ...hardReasons.map(reason => `quality:${reason}`)],
QUALITY_CLEANUP_MIGRATION_ID,
{ hardReasons },
));
return superseded;
});
return {
@@ -445,7 +358,6 @@ export function runMigrationQualityCleanup(
updatedAt: changed ? nowIso : store.updatedAt,
},
events,
evidence,
};
}
@@ -529,7 +441,7 @@ function extractConcreteIdentityKey(text: string): string | null {
if (pathIdentity) return pathIdentity;
}
const pathMatch = text.match(/(?:\/[^\0\s`"'<>]+|(?:\.{1,2}[\\/]|[A-Za-z0-9_.-]+[\\/])[^\s`"'<>]+|[A-Za-z0-9_.-]+\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config))(?:\b|$)/);
const pathMatch = text.match(/(?:\/[^\s`"'<>]+|(?:\.{1,2}[\\/]|[A-Za-z0-9_.-]+[\\/])[^\s`"'<>]+|[A-Za-z0-9_.-]+\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config))(?:\b|$)/);
if (!pathMatch) return null;
return normalizeConcretePathIdentity(pathMatch[0]);
@@ -558,61 +470,6 @@ function consolidationEvent(
};
}
function capacityRemovalEvidence(
memory: LongTermMemoryEntry,
reason: "type_cap" | "global_cap",
details: {
strengthAtRemoval: number;
rankAtRemoval: number;
typeRankAtRemoval: number;
ageDaysAtRemoval: number;
},
): EvidenceEventInput {
return {
type: "memory_removed_capacity",
phase: "storage",
outcome: "removed",
reasonCodes: [reason],
memory: memoryEvidenceRef(memory),
relations: [{
role: "removed",
memory: memoryEvidenceRef(memory),
}],
details: {
type: memory.type,
globalCap: LONG_TERM_LIMITS.maxEntries,
...(reason === "type_cap" ? { typeCap: RETENTION_TYPE_MAX[memory.type] } : {}),
...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}),
...(memory.createdAt ? { createdAt: memory.createdAt } : {}),
...(memory.source ? { source: memory.source } : {}),
...details,
},
};
}
function migrationSupersededEvidence(
memory: LongTermMemoryEntry,
reasonCodes: string[],
migrationId: string,
details: EvidenceEventInput["details"] = {},
): EvidenceEventInput {
return {
type: "memory_migration_superseded",
phase: "storage",
outcome: "superseded",
memory: memoryEvidenceRef(memory),
relations: [{ role: "superseded", memory: memoryEvidenceRef(memory) }],
reasonCodes,
details: {
migrationId,
type: memory.type,
source: memory.source,
...details,
},
textPreview: memory.text,
};
}
/** Choose better memory when identity/topic keys conflict */
function chooseBetterMemory(
a: LongTermMemoryEntry,
@@ -668,13 +525,6 @@ export function enforceLongTermLimitsWithAccounting(
const capped = applyTypeMaxCaps(sorted);
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
const keptIds = new Set(kept.map(entry => entry.id));
const cappedIds = new Set(capped.map(entry => entry.id));
const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id));
const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id));
const capacityEvidence: EvidenceEventInput[] = [
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))),
];
const capacityDropped = sorted
.filter(entry => !keptIds.has(entry.id))
.map(entry => consolidationEvent(entry, "rejected_capacity"));
@@ -684,29 +534,7 @@ export function enforceLongTermLimitsWithAccounting(
dropped: [...dedupeResult.dropped, ...capacityDropped],
absorbed: dedupeResult.absorbed,
superseded: dedupeResult.superseded,
evidence: [...dedupeResult.evidence, ...capacityEvidence],
};
}
function capacityRemovalSnapshot(
memory: LongTermMemoryEntry,
sorted: LongTermMemoryEntry[],
now: number,
lastActivityAt?: string,
): {
strengthAtRemoval: number;
rankAtRemoval: number;
typeRankAtRemoval: number;
ageDaysAtRemoval: number;
} {
const createdAtMs = new Date(memory.createdAt).getTime();
const rank = sorted.findIndex(entry => entry.id === memory.id);
const typeRank = sorted.filter(entry => entry.type === memory.type).findIndex(entry => entry.id === memory.id);
return {
strengthAtRemoval: calculateRetentionStrength(memory, now, lastActivityAt),
rankAtRemoval: rank >= 0 ? rank + 1 : -1,
typeRankAtRemoval: typeRank >= 0 ? typeRank + 1 : -1,
ageDaysAtRemoval: Number.isFinite(createdAtMs) ? Math.floor(Math.max(0, now - createdAtMs) / 86_400_000) : 0,
evidence: dedupeResult.evidence,
};
}
@@ -741,11 +569,6 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const evidence: EvidenceEventInput[] = [];
// For project/reference/feedback: dedupe by concrete identity or exact canonical text.
// Feedback is grouped with project/reference for entity dedupe, but
// workspaceMemoryIdentityKey() returns exact key for feedback (no concrete
// identity extraction). This means feedback absorbed_identity is currently
// impossible. When identity key extraction is extended to all types, feedback
// with matching concrete identifiers will correctly produce absorbed_identity.
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
// Build identity key dedup for project/reference/feedback.
@@ -762,13 +585,12 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "absorbed_identity" as const;
const decision = tryReinforceMemory(
const reinforced = reinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
absorbed.push(consolidationEvent(dropped, reason, reinforced));
@@ -790,14 +612,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const dropped = retained === entry ? existing : entry;
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs
const decision = tryReinforceMemory(
: "superseded_existing" as const;
const reinforced = reinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
if (reason === "superseded_existing") {
@@ -839,41 +660,11 @@ function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
function reinforcementEvidence(
retained: LongTermMemoryEntry,
dropped: LongTermMemoryEntry,
decision: ReinforcementDecision,
reinforced: LongTermMemoryEntry,
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
attemptedAt: number,
): EvidenceEventInput | undefined {
if ((reinforced.reinforcementCount ?? 0) <= (retained.reinforcementCount ?? 0)) return undefined;
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
if (decision.outcome === "blocked") {
return {
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: memoryEvidenceRef(retained),
relations: [
{ role: "target", memory: memoryEvidenceRef(retained) },
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`],
details: {
memoryId: retained.id,
droppedMemoryId: dropped.id,
blockReason: decision.blockReason,
attemptedAtMs: attemptedAt,
attemptedAtIso: new Date(attemptedAt).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
},
textPreview: retained.text,
};
}
const reinforced = decision.memory;
return {
type: "memory_reinforced",
phase: "reinforcement",
@@ -884,13 +675,6 @@ function reinforcementEvidence(
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
details: {
memoryId: reinforced.id,
droppedMemoryId: dropped.id,
reinforcementOutcome: "reinforced",
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
},
textPreview: reinforced.text,
};
}
@@ -929,13 +713,14 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
return accountWorkspaceMemoryRender(store).prompt;
}
function selectWorkspaceMemoryForRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderSelection {
export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting {
const now = Date.now();
const maxChars = Math.min(
store.limits.maxRenderedChars,
LONG_TERM_LIMITS.maxRenderedChars
);
const omitted: WorkspaceMemoryRenderAccounting["omitted"] = [];
const evidence: EvidenceEventInput[] = [];
for (const entry of store.entries) {
if (entry.status === "superseded") {
@@ -952,13 +737,6 @@ function selectWorkspaceMemoryForRender(store: WorkspaceMemoryStore): WorkspaceM
const active = typeCapResult.kept.slice(0, LONG_TERM_LIMITS.maxEntries);
for (const memory of typeCapResult.kept.slice(LONG_TERM_LIMITS.maxEntries)) omitted.push({ memory, reason: "global_cap" });
return { active, omitted, maxChars };
}
export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting {
const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store);
const evidence: EvidenceEventInput[] = [];
if (active.length === 0) {
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
return { rendered: [], omitted, evidence, prompt: "" };
@@ -1003,68 +781,6 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works
return { rendered, omitted, evidence, prompt: lines.join("\n") };
}
export function accountWorkspaceMemoryCompactionRefs(store: WorkspaceMemoryStore): WorkspaceMemoryCompactionRefsAccounting {
const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store);
const evidence: EvidenceEventInput[] = [];
const originalById = new Map(store.entries.map(entry => [entry.id, entry]));
if (active.length === 0) {
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
return { rendered: [], omitted, evidence, prompt: "", refs: [] };
}
const lines: string[] = [
"Existing workspace memories available for consolidation:",
];
const rendered: LongTermMemoryEntry[] = [];
const refs: CompactionMemoryRef[] = [];
const capturedAt = Date.now();
for (const type of ["feedback", "project", "decision", "reference"] as const) {
const items = active.filter(entry => entry.type === type);
if (items.length === 0) continue;
const sectionLines: string[] = [`${type}:`];
for (const item of items) {
const ref = `M${refs.length + 1}`;
const line = `[${ref}] ${item.text}`;
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
const original = originalById.get(item.id) ?? item;
sectionLines.push(line);
rendered.push(item);
refs.push({
ref,
memoryId: item.id,
type: original.type,
source: original.source,
exactKey: workspaceMemoryExactKey(original),
identityKey: workspaceMemoryIdentityKey(original),
textPreview: item.text,
capturedAt,
});
} else {
omitted.push({ memory: item, reason: "char_budget" });
}
}
if (sectionLines.length > 1) {
lines.push(...sectionLines);
}
}
for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered"));
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
return {
rendered,
omitted,
evidence,
prompt: rendered.length > 0 ? lines.join("\n") : "",
refs,
};
}
function renderEvidence(
memory: LongTermMemoryEntry,
outcome: "rendered" | "omitted",
-111
View File
@@ -132,116 +132,6 @@ test("queryEvidenceEvents filters by type outcome and memory id", async () => {
}
});
test("memory_removed_capacity event round-trips through append and query", async () => {
const root = await tempRoot();
try {
await appendEvidenceEvent(root, eventInput({
type: "memory_removed_capacity",
phase: "storage",
outcome: "removed",
reasonCodes: ["global_cap"],
memory: { memoryId: "removed-memory", type: "reference", source: "compaction", status: "active" },
relations: [{ role: "removed", memory: { memoryId: "removed-memory", type: "reference", source: "compaction", status: "active" } }],
details: {
type: "reference",
globalCap: 28,
retentionClock: Date.UTC(2026, 4, 1),
createdAt: "2026-05-01T00:00:00.000Z",
source: "compaction",
},
}));
const result = await queryEvidenceEvents(root, {
types: ["memory_removed_capacity"],
phases: ["storage"],
outcomes: ["removed"],
memoryId: "removed-memory",
});
assert.equal(result.length, 1);
assert.equal(result[0].type, "memory_removed_capacity");
assert.equal(result[0].phase, "storage");
assert.equal(result[0].outcome, "removed");
assert.deepEqual(result[0].details, {
type: "reference",
globalCap: 28,
retentionClock: Date.UTC(2026, 4, 1),
createdAt: "2026-05-01T00:00:00.000Z",
source: "compaction",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory_migration_superseded event round-trips through append and query", async () => {
const root = await tempRoot();
try {
await appendEvidenceEvent(root, eventInput({
type: "memory_migration_superseded",
phase: "storage",
outcome: "superseded",
reasonCodes: ["migration:quality_cleanup", "quality:progress_snapshot"],
memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" },
relations: [{ role: "superseded", memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" } }],
details: {
migrationId: "2026-04-28-quality-cleanup",
type: "project",
source: "compaction",
},
}));
const result = await queryEvidenceEvents(root, {
types: ["memory_migration_superseded"],
phases: ["storage"],
outcomes: ["superseded"],
memoryId: "migrated-memory",
});
assert.equal(result.length, 1);
assert.equal(result[0].type, "memory_migration_superseded");
assert.equal(result[0].phase, "storage");
assert.equal(result[0].outcome, "superseded");
assert.ok(result[0].reasonCodes.includes("migration:quality_cleanup"));
assert.equal(result[0].memory?.status, "superseded");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory_reverted_numbered_ref event round-trips through append and query", async () => {
const root = await tempRoot();
try {
await appendEvidenceEvent(root, eventInput({
type: "memory_reverted_numbered_ref",
phase: "storage",
outcome: "recovered",
reasonCodes: ["manual_revert_numbered_ref"],
memory: { memoryId: "replacement-memory", type: "decision", source: "compaction", status: "superseded" },
relations: [
{ role: "superseded", memory: { memoryId: "replacement-memory", type: "decision", source: "compaction", status: "superseded" } },
{ role: "recovered", memory: { memoryId: "restored-memory", type: "decision", source: "compaction", status: "active" } },
],
}));
const result = await queryEvidenceEvents(root, {
types: ["memory_reverted_numbered_ref"],
phases: ["storage"],
outcomes: ["recovered"],
memoryId: "restored-memory",
});
assert.equal(result.length, 1);
assert.equal(result[0].type, "memory_reverted_numbered_ref");
assert.equal(result[0].phase, "storage");
assert.equal(result[0].outcome, "recovered");
assert.ok(result[0].reasonCodes.includes("manual_revert_numbered_ref"));
assert.ok(result[0].relations?.some(relation => relation.role === "recovered" && relation.memory?.memoryId === "restored-memory"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("queryEvidenceEvents supports newestFirst and limit", async () => {
const root = await tempRoot();
try {
@@ -349,7 +239,6 @@ test("evidence relation roles reject sublimation placeholders at compile-time su
"reinforced_by",
"rendered",
"omitted",
"removed",
];
assert.equal(allowedRoles.includes("candidate"), true);
-89
View File
@@ -212,65 +212,6 @@ Memory candidates:
assert.match(result.evidence[0].textPreview ?? "", /accounting evidence events/);
});
test("parseWorkspaceMemoryCandidatesWithEvidence returns mixed valid commands and entries", () => {
const summary = `
Memory candidates:
REINFORCE [M3]
REPLACE [M4] [decision] Use numbered refs for safe memory consolidation.
- [feedback] User prefers concise implementation handoffs.
`;
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
assert.deepEqual(result.commands, [
{ kind: "REINFORCE", ref: "M3" },
{ kind: "REPLACE", ref: "M4", type: "decision", text: "Use numbered refs for safe memory consolidation." },
]);
assert.equal(result.entries.length, 1);
assert.equal(result.entries[0].type, "feedback");
assert.equal(result.entries[0].text, "User prefers concise implementation handoffs.");
});
test("parseWorkspaceMemoryCandidatesWithEvidence rejects malformed command attempts", () => {
const summary = `
Memory candidates:
REINFORCE M3
REINFORCE [X3]
REPLACE [M3] text without type
REPLACE [M3] [invalid] text
REPLACE [M3] [decision]
`;
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
const rejections = result.evidence.filter(event => event.type === "extraction_candidate_rejected");
assert.equal(result.entries.length, 0);
assert.deepEqual(result.commands, []);
assert.equal(rejections.length, 5);
assert.ok(rejections.every(event => event.phase === "extraction"));
assert.ok(rejections.every(event => event.outcome === "rejected"));
assert.deepEqual(rejections.map(event => event.reasonCodes[0]), [
"invalid_memory_ref",
"invalid_memory_ref",
"invalid_memory_type",
"invalid_memory_type",
"empty_replacement_text",
]);
});
test("parseWorkspaceMemoryCandidates accepts bracketed candidates without bullets", () => {
const summary = `
Memory candidates:
[project] This repository uses local JSON stores for workspace memory.
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1);
assert.equal(items[0].type, "project");
assert.equal(items[0].text, "This repository uses local JSON stores for workspace memory.");
});
test("compaction rejected candidate returns rejection evidence without secrets", () => {
const summary = `
Memory candidates:
@@ -446,36 +387,6 @@ Memory candidates:
}
});
test("new rejection records include workspaceKey and workspaceRootHash when provided", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-scope-data-"));
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
const summary = `
Memory candidates:
- feedback Wave 1 completed successfully and all tests passed
`;
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary, {
workspaceKey: "testkey1234567",
workspaceRootHash: "abcdef123456",
});
assert.equal(result.entries.length, 0);
const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl");
const lines = (await waitForFile(logPath)).trim().split("\n");
assert.equal(lines.length, 1);
const event = JSON.parse(lines[0]);
assert.equal(event.workspaceKey, "testkey1234567");
assert.equal(event.workspaceRootHash, "abcdef123456");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(dataHome, { recursive: true, force: true });
}
});
test("parseWorkspaceMemoryCandidates redacts secrets in extraction rejection log", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-"));
const previousXdgDataHome = process.env.XDG_DATA_HOME;
-135
View File
@@ -1,135 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
import type { LongTermMemoryEntry } from "../src/types.ts";
import type { MemoryInspectionReadModel } from "../scripts/memory-diag/types.ts";
import { CliInputError, normalizeRejection, rejectionQualitySummary, sinceCutoff } from "../scripts/memory-diag/rejections-model.ts";
import { coverageRows, disappearanceRows } from "../scripts/memory-diag/inspection-model.ts";
import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts";
import { statusFromTraceEvent } from "../scripts/memory-diag/trace-model.ts";
function entry(id: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
const now = new Date("2026-01-01T00:00:00.000Z").toISOString();
return {
id,
type,
text: `${id} text`,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
};
}
function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 {
return {
version: 1,
eventId: `evt-${overrides.type}-${Math.random()}`,
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
workspaceKey: "workspace-key",
workspaceRootHash: "workspace-root-hash",
reasonCodes: [],
...overrides,
};
}
function model(entries: LongTermMemoryEntry[], events: EvidenceEventV1[]): MemoryInspectionReadModel {
return {
store: {
version: 1,
workspace: { root: "/tmp/workspace", key: "workspace-key" },
limits: { maxRenderedChars: 24_000, maxEntries: 28 },
entries,
migrations: [],
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
},
pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() },
evidenceEvents: events,
rejectionRecords: [],
currentById: new Map(entries.map(memory => [memory.id, memory])),
evidenceByMemoryId: groupEvidenceByMemoryId(events),
};
}
test("normalizeRejection infers origins from source", () => {
assert.equal(normalizeRejection({ source: "compaction", text: "a", reasons: ["bad_decision"] })?.origin, "compaction_candidate");
assert.equal(normalizeRejection({ source: "explicit", text: "a", reasons: ["bad_feedback"] })?.origin, "explicit_trigger");
assert.equal(normalizeRejection({ source: "manual", text: "a", reasons: ["bad_feedback"] })?.origin, "manual");
assert.equal(normalizeRejection({ source: "unknown-source", text: "a", reasons: ["bad_feedback"] })?.origin, "unknown");
});
test("sinceCutoff accepts relative durations and ISO timestamps", () => {
const now = new Date("2026-01-15T12:00:00.000Z").getTime();
assert.equal(sinceCutoff("14d", now), now - 14 * 86_400_000);
assert.equal(sinceCutoff("3h", now), now - 3 * 3_600_000);
assert.equal(sinceCutoff("30m", now), now - 30 * 60_000);
assert.equal(sinceCutoff("2026-01-01T00:00:00.000Z", now), new Date("2026-01-01T00:00:00.000Z").getTime());
assert.throws(() => sinceCutoff("forever", now), (error: unknown) => {
assert.ok(error instanceof CliInputError);
assert.equal((error as Error).message, "Invalid --since value: forever");
return true;
});
});
test("rejectionQualitySummary keeps architecture-like false-positive grouping", () => {
const records = [
normalizeRejection({ type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] }),
normalizeRejection({ type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] }),
normalizeRejection({ type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] }),
].filter(record => record !== null);
const summary = rejectionQualitySummary(records);
assert.equal(summary.totalRecords, 3);
assert.equal(summary.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
assert.equal(summary.possibleFalsePositiveGroups.clearly_garbage.count, 1);
assert.equal(summary.possibleFalsePositiveGroups.ambiguous.count, 1);
});
test("coverageRows classifies current and historical memory evidence", () => {
const entries = [entry("mem-full", "feedback"), entry("mem-render-only", "decision"), entry("mem-no-evidence", "project")];
const events = [
event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }),
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }),
event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-render-only", type: "decision", source: "compaction" } }),
event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "historical-cap", type: "project", source: "compaction" }, reasonCodes: ["global_cap"] }),
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "reference", source: "compaction" } }),
];
const rows = coverageRows(model(entries, events), true);
const byId = new Map(rows.map(row => [row.id, row.class]));
assert.equal(byId.get("mem-full"), "full_lifecycle");
assert.equal(byId.get("mem-render-only"), "render_only");
assert.equal(byId.get("mem-no-evidence"), "no_evidence");
assert.equal(byId.get("historical-cap"), "historical_absent_with_reason");
assert.equal(byId.get("historical-unknown"), "historical_absent_unknown_reason");
});
test("disappearanceRows surfaces terminal capacity evidence", () => {
const events = [
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" } }),
event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" }, reasonCodes: ["type_cap"] }),
];
const rows = disappearanceRows(model([], events));
assert.equal(rows.length, 1);
assert.equal(rows[0].id, "capacity-loser");
assert.equal(rows[0].classification, "historical_absent_with_reason");
assert.equal(rows[0].terminalType, "memory_removed_capacity");
assert.deepEqual(rows[0].reasonCodes, ["type_cap"]);
});
test("statusFromTraceEvent maps lifecycle events", () => {
assert.equal(statusFromTraceEvent(undefined), "unknown");
assert.equal(statusFromTraceEvent(event({ type: "render_selected", phase: "render", outcome: "rendered" })), "rendered");
assert.equal(statusFromTraceEvent(event({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["type_cap"] })), "omitted_type_cap");
assert.equal(statusFromTraceEvent(event({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed" })), "omitted_absorbed_duplicate");
assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_scheduled", phase: "promotion", outcome: "retried" })), "pending_retry");
assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_exhausted", phase: "promotion", outcome: "exhausted" })), "pending_rejected_capacity");
assert.equal(statusFromTraceEvent(event({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined" })), "quarantined_corrupt_store");
assert.equal(statusFromTraceEvent(event({ type: "promotion_superseded", phase: "promotion", outcome: "superseded" })), "omitted_superseded");
});
-188
View File
@@ -1,188 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseArgs } from "../scripts/memory-diag/cli.ts";
test("help returns usage without exposing hidden or removed commands", () => {
const parsed = parseArgs(["--help"]);
assert.equal(parsed.ok, true);
assert.equal("help" in parsed && parsed.help, true);
assert.match(parsed.usage, /Usage:/);
assert.match(parsed.usage, /memory-diag \[status\]/);
assert.match(parsed.usage, /memory-diag quality/);
assert.match(parsed.usage, /memory-diag commands/);
assert.match(parsed.usage, /memory-diag revert/);
for (const command of ["health", "rejections", "disappearances", "trace", "coverage", "audit"]) {
assert.doesNotMatch(parsed.usage, new RegExp(command));
}
});
test("status defaults when no subcommand", () => {
const parsed = parseArgs([]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "status");
assert.deepEqual("options" in parsed && parsed.options, { raw: false, positional: [] });
});
test("unknown command returns usage error", () => {
const parsed = parseArgs(["unknown"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
assert.equal(parsed.message, "Unknown subcommand: unknown");
assert.equal(parsed.exitCode, 1);
assert.match(parsed.usage, /Usage:/);
});
test("removed legacy aliases are ordinary unknown subcommands", () => {
for (const command of ["health", "rejections", "disappearances", "trace"]) {
const parsed = parseArgs([command]);
assert.equal(parsed.ok, false, command);
if (parsed.ok) continue;
assert.equal(parsed.message, `Unknown subcommand: ${command}`);
assert.match(parsed.usage, /Usage:/);
}
});
test("quality accepts read-only workspace json and display flags", () => {
const parsed = parseArgs(["quality", "--workspace", "/tmp/workspace", "--json", "--verbose", "--raw", "--no-emoji"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "quality");
assert.equal("options" in parsed && parsed.options.workspace, "/tmp/workspace");
assert.equal("options" in parsed && parsed.options.json, true);
assert.equal("options" in parsed && parsed.options.verbose, true);
assert.equal("options" in parsed && parsed.options.raw, true);
assert.equal("options" in parsed && parsed.options.noEmoji, true);
});
test("quality rejects mutation filter and drill-down flags", () => {
const cases: Array<{ args: string[]; message: string }> = [
{ args: ["quality", "--all"], message: "quality does not accept --all" },
{ args: ["quality", "--apply"], message: "quality does not accept --apply" },
{ args: ["quality", "--memory", "mem-1"], message: "quality does not accept --memory" },
{ args: ["quality", "--event", "evt-1"], message: "quality does not accept --event" },
{ args: ["quality", "--reason", "bad_decision"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--since", "7d"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--soft-only"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--trigger-only"], message: "quality does not accept rejection filters" },
{ args: ["quality", "--include-historical"], message: "quality does not accept --include-historical" },
{ args: ["quality", "--explain"], message: "quality does not accept --explain" },
];
for (const item of cases) {
const parsed = parseArgs(item.args);
assert.equal(parsed.ok, false, item.args.join(" "));
if (parsed.ok) continue;
assert.equal(parsed.message, item.message);
assert.match(parsed.usage, /Usage:/);
}
});
test("hidden maintainer commands are accepted with neutral notices", () => {
const coverage = parseArgs(["coverage"]);
assert.equal(coverage.ok, true);
assert.equal("command" in coverage && coverage.command, "coverage");
assert.equal("deprecationNotice" in coverage && coverage.deprecationNotice, "Note: 'coverage' is a maintainer-only diagnostic and is not part of the public CLI surface.");
const audit = parseArgs(["audit"]);
assert.equal(audit.ok, true);
assert.equal("command" in audit && audit.command, "audit");
assert.equal("deprecationNotice" in audit && audit.deprecationNotice, "Note: 'audit' is a maintainer-only diagnostic and is not part of the public CLI surface.");
});
test("current command flag validation messages are preserved", () => {
const cases: Array<{ args: string[]; message: string }> = [
{ args: ["status", "--all"], message: "status does not accept --all" },
{ args: ["coverage", "--all"], message: "coverage does not accept --all" },
{ args: ["missing", "--all"], message: "missing does not accept --all" },
{ args: ["rejected", "--all"], message: "rejected does not accept --all" },
{ args: ["audit", "--workspace", "/tmp/workspace"], message: "audit does not accept --all or --workspace" },
{ args: ["explain", "--all"], message: "explain does not accept --all" },
{ args: ["status", "--since", "forever"], message: "status does not accept rejection filters" },
{ args: ["status", "--reason", "bad_decision"], message: "status does not accept rejection filters" },
{ args: ["status", "--quality"], message: "Unknown option: --quality" },
{ args: ["rejected", "--unique"], message: "Unknown option: --unique" },
];
for (const item of cases) {
const parsed = parseArgs(item.args);
assert.equal(parsed.ok, false, item.args.join(" "));
if (parsed.ok) continue;
assert.equal(parsed.message, item.message);
assert.match(parsed.usage, /Usage:/);
}
});
test("commands accepts workspace json and verbose flags", () => {
const parsed = parseArgs(["commands", "--workspace", "/tmp/workspace", "--json", "--verbose"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "commands");
assert.equal("options" in parsed && parsed.options.workspace, "/tmp/workspace");
assert.equal("options" in parsed && parsed.options.json, true);
assert.equal("options" in parsed && parsed.options.verbose, true);
});
test("commands accepts memory drill-down selector", () => {
const parsed = parseArgs(["commands", "--workspace", "/tmp/workspace", "--memory", "mem-1", "--json", "--verbose"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "commands");
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
assert.equal("options" in parsed && parsed.options.json, true);
assert.equal("options" in parsed && parsed.options.verbose, true);
});
test("revert accepts memory or event selectors and apply flag", () => {
const byMemory = parseArgs(["revert", "--memory", "mem-new", "--workspace", "/tmp/workspace", "--apply"]);
assert.equal(byMemory.ok, true);
assert.equal("command" in byMemory && byMemory.command, "revert");
assert.equal("options" in byMemory && byMemory.options.memory, "mem-new");
assert.equal("options" in byMemory && byMemory.options.workspace, "/tmp/workspace");
assert.equal("options" in byMemory && byMemory.options.apply, true);
const byEvent = parseArgs(["revert", "--event", "evt_1"]);
assert.equal(byEvent.ok, true);
assert.equal("command" in byEvent && byEvent.command, "revert");
assert.equal("options" in byEvent && byEvent.options.event, "evt_1");
assert.equal("options" in byEvent && byEvent.options.apply, undefined);
});
test("revert requires exactly one selector", () => {
const missing = parseArgs(["revert"]);
assert.equal(missing.ok, false);
if (!missing.ok) assert.equal(missing.message, "revert requires --memory or --event");
const both = parseArgs(["revert", "--memory", "mem-new", "--event", "evt_1"]);
assert.equal(both.ok, false);
if (!both.ok) assert.equal(both.message, "Use either --memory or --event, not both");
});
test("rejected invalid since value returns current error", () => {
const parsed = parseArgs(["rejected", "--since", "forever"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
assert.equal(parsed.message, "Invalid --since value: forever");
assert.match(parsed.usage, /Usage:/);
});
test("explain accepts positional memory id", () => {
const parsed = parseArgs(["explain", "mem-1", "--workspace", "/tmp/workspace"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "explain");
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
assert.deepEqual("options" in parsed && parsed.options.positional, ["mem-1"]);
});
test("explain with both positional and memory flag errors", () => {
const parsed = parseArgs(["explain", "mem-1", "--memory", "mem-2"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
assert.equal(parsed.message, "Use either explain <memory-id> or --memory, not both");
});
-110
View File
@@ -1,110 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
import type { MemoryInspectionReadModel, WorkspaceDiagSnapshot } from "../scripts/memory-diag/types.ts";
import { formatCoverage } from "../scripts/memory-diag/formatters/coverage.ts";
import { formatMigrationAudit } from "../scripts/memory-diag/formatters/audit.ts";
import { formatExplain } from "../scripts/memory-diag/formatters/explain.ts";
import { buildMissingJSON, formatMissing } from "../scripts/memory-diag/formatters/missing.ts";
import { formatTrace } from "../scripts/memory-diag/formatters/trace.ts";
function emptyInspectionModel(): MemoryInspectionReadModel {
return {
store: {
version: 1,
workspace: { root: "/tmp/workspace", key: "workspace-key" },
limits: { maxRenderedChars: 24_000, maxEntries: 28 },
entries: [],
migrations: [],
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
},
pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() },
evidenceEvents: [],
rejectionRecords: [],
currentById: new Map(),
evidenceByMemoryId: new Map(),
};
}
function emptySnapshot(): WorkspaceDiagSnapshot {
return {
store: emptyInspectionModel().store,
journal: emptyInspectionModel().pending,
retention: { sorted: [], rendered: [], typeCapped: [], globalCapped: [] },
memories: [],
recentEvents: [],
allEvents: [],
summary: {
storedActive: 0,
rendered: 0,
pending: 0,
rejectedLast7Days: 0,
corruptStoresQuarantinedLast30Days: 0,
},
};
}
function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 {
return {
version: 1,
eventId: `evt-${overrides.type}`,
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
workspaceKey: "workspace-key",
workspaceRootHash: "workspace-root-hash",
reasonCodes: [],
...overrides,
};
}
test("coverage formatter includes class counts section", () => {
const output = formatCoverage([]);
assert.match(output, /Class counts:/);
assert.match(output, /Per-memory rows:\n \(none\)/);
});
test("missing formatter verbose output preserves disappearance details", () => {
const output = formatMissing([], { verbose: true });
assert.match(output, /No missing memories found\./);
});
test("missing JSON includes disappearance rows and summary", () => {
const rows = [{
id: "historical-1",
classification: "historical_absent_unknown_reason" as const,
terminalType: "unknown" as const,
reasonCodes: [],
events: [event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted" })],
event: undefined,
}];
const output = buildMissingJSON(rows, { generatedAt: "2026-01-01T00:00:00.000Z" });
assert.equal(output.version, 1);
assert.deepEqual(output.summary, { total: 1, explained: 0, needsReview: 1 });
assert.deepEqual((output.disappearances as Array<{ id: string }>)[0]?.id, "historical-1");
});
test("trace formatter includes lifecycle section", () => {
const output = formatTrace("mem-1", emptySnapshot(), {
events: [event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-1", type: "feedback", source: "explicit" } })],
});
assert.match(output, /Lifecycle:/);
assert.match(output, /evt-render_selected render_selected/);
});
test("audit formatter preserves no-log output", () => {
const output = formatMigrationAudit([], { raw: false });
assert.match(output, /Migration audit report/);
assert.match(output, /No migration logs found\./);
});
test("explain formatter preserves no-memory output", () => {
const output = formatExplain(emptySnapshot());
assert.match(output, /Workspace memory explainability/);
assert.match(output, /No memories found\./);
});
File diff suppressed because it is too large Load Diff
-54
View File
@@ -1,54 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { canonicalMemoryText, cleanText, countBy, sortedCounts, truncate } from "../scripts/memory-diag/text.ts";
import { readJSONLFile } from "../scripts/memory-diag/io.ts";
test("cleanText redacts credentials and absolute paths unless raw", () => {
const text = "Use password: sushi and api_key=abc123 in /Users/alice/project/config.json";
const cleaned = cleanText(text, false);
assert.match(cleaned, /password: \[REDACTED\]/);
assert.match(cleaned, /api_key=\[REDACTED\]/);
assert.match(cleaned, /<path>/);
assert.doesNotMatch(cleaned, /sushi/);
assert.doesNotMatch(cleaned, /\/Users\/alice/);
assert.equal(cleanText(text, true), text);
});
test("truncate collapses whitespace and applies ellipsis at max length", () => {
assert.equal(truncate(" hello\n\tworld "), "hello world");
assert.equal(truncate("abcdef", 5), "abcd…");
});
test("canonicalMemoryText normalizes punctuation and case", () => {
assert.equal(canonicalMemoryText("Hello, WORLD!!! Path?"), "hello world path");
});
test("sortedCounts sorts by count descending then key ascending", () => {
const counts = new Map<string, number>([["b", 2], ["a", 2], ["c", 3]]);
assert.deepEqual(sortedCounts(counts), [["c", 3], ["a", 2], ["b", 2]]);
});
test("countBy counts string items", () => {
assert.deepEqual([...countBy(["beta", "alpha", "beta"]).entries()], [["beta", 2], ["alpha", 1]]);
});
test("readJSONLFile returns valid records and invalid line count", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-utils-"));
try {
const path = join(root, "records.jsonl");
await writeFile(path, '{"id":"one"}\nnot-json\n\n{"id":"two"}\n', "utf8");
const result = await readJSONLFile<{ id: string }>(path);
assert.deepEqual(result.records, [{ id: "one" }, { id: "two" }]);
assert.equal(result.invalidLines, 1);
} finally {
await rm(root, { recursive: true, force: true });
}
});
-224
View File
@@ -1,224 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { appendEvidenceEvents, queryEvidenceEvents, summarizeMemoryEvidence, type EvidenceEventInput, type EvidenceEventV1 } from "../src/evidence-log.ts";
import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts";
import { retentionCandidatesForDiag } from "../scripts/memory-diag/retention-model.ts";
import { buildMemoryDiagJSON, memoryDiagJSONFromSnapshot, normalizedJournal, normalizedStore, snapshotForOptions } from "../scripts/memory-diag/workspace-snapshot.ts";
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { LONG_TERM_LIMITS, type LongTermMemoryEntry, type PendingMemoryJournalStore, type WorkspaceMemoryStore } from "../src/types.ts";
function entry(id: string, text: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
const now = new Date("2026-01-01T00:00:00.000Z").toISOString();
return {
id,
type,
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: new Date(now).getTime(),
};
}
function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
return {
type: "promotion_promoted",
phase: "promotion",
outcome: "promoted",
memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" },
reasonCodes: ["new_workspace_entry"],
...overrides,
};
}
function groupedEvidenceSummary(grouped: Map<string, EvidenceEventV1[]>, memoryId: string): { eventIds: string[]; reasonCodes: string[] } {
const events = grouped.get(memoryId) ?? [];
const reasonCodes = new Set<string>();
for (const event of events) {
for (const reason of event.reasonCodes) reasonCodes.add(reason);
}
return {
eventIds: events.map(event => event.eventId),
reasonCodes: [...reasonCodes],
};
}
async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
const key = await workspaceKey(root);
const path = await workspaceMemoryPath(root);
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries,
migrations: [],
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
};
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
const key = await workspaceKey(root);
const path = await workspacePendingJournalPath(root);
const store: PendingMemoryJournalStore = {
version: 1,
workspace: { root, key },
entries,
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
};
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
test("normalizedStore returns an empty store with limits and empty arrays", () => {
const store = normalizedStore(null, "/tmp/example-workspace", "workspace-key");
assert.equal(store.version, 1);
assert.deepEqual(store.workspace, { root: "/tmp/example-workspace", key: "workspace-key" });
assert.deepEqual(store.limits, { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries });
assert.deepEqual(store.entries, []);
assert.deepEqual(store.migrations, []);
});
test("normalizedJournal returns an empty journal", () => {
const journal = normalizedJournal(null);
assert.equal(journal.version, 1);
assert.deepEqual(journal.workspace, { root: "", key: "" });
assert.deepEqual(journal.entries, []);
assert.equal(journal.updatedAt, new Date(0).toISOString());
});
test("retentionCandidatesForDiag separates rendered, type-capped, and global-capped entries", () => {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 11 }, (_, i) => entry(`feedback-${String(i).padStart(2, "0")}`, `Feedback memory ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`decision-${String(i).padStart(2, "0")}`, `Decision memory ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`project-${String(i).padStart(2, "0")}`, `Project memory ${i}`, "project")),
...Array.from({ length: 6 }, (_, i) => entry(`reference-${String(i).padStart(2, "0")}`, `Reference memory ${i}`, "reference")),
];
const store = normalizedStore({ entries, workspace: { root: "/tmp/root", key: "key" } } as WorkspaceMemoryStore, "/tmp/root", "key");
const candidates = retentionCandidatesForDiag(store, new Date("2026-01-02T00:00:00.000Z").getTime());
assert.equal(candidates.rendered.length, LONG_TERM_LIMITS.maxEntries);
assert.equal(candidates.typeCapped.length, 1);
assert.equal(candidates.globalCapped.length, 6);
assert.equal(candidates.typeCapped[0].entry.type, "feedback");
});
test("buildMemoryDiagJSON redacts previews, includes pending entries, and preserves summary fields", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-workspace-model-"));
try {
const active = entry("mem-active", "Remember password: sushi and file /Users/alice/private.txt", "decision");
const pending = { ...entry("mem-pending", "Pending api_key=secret-value", "project"), promotionAttempts: 1 };
await writeWorkspaceStore(root, [active]);
await writePendingJournal(root, [pending]);
const diag = await buildMemoryDiagJSON(root);
assert.equal(diag.version, 1);
assert.equal(diag.summary.storedActive, 1);
assert.equal(diag.summary.rendered, 1);
assert.equal(diag.summary.pending, 1);
assert.equal(diag.summary.rejectedLast7Days, 0);
assert.equal(diag.summary.corruptStoresQuarantinedLast30Days, 0);
assert.equal(diag.memories.length, 2);
assert.equal(diag.memories.find(memory => memory.id === "mem-pending")?.status, "pending_retry");
assert.ok(diag.memories.some(memory => memory.textPreview?.includes("[REDACTED]")));
assert.ok(diag.memories.some(memory => memory.textPreview?.includes("<path>")));
assert.ok(!JSON.stringify(diag).includes("sushi"));
assert.ok(!JSON.stringify(diag).includes("secret-value"));
assert.equal(diag.workspace.key, await workspaceKey(root));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memoryDiagJSONFromSnapshot serializes an existing snapshot with fixed generatedAt", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-snapshot-json-"));
try {
const active = entry("mem-active", "Stable decision memory", "decision");
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
await writeWorkspaceStore(root, [active]);
await writePendingJournal(root, [pending]);
const snapshot = await snapshotForOptions({ raw: false, workspace: root });
const generatedAt = "2026-05-02T00:00:00.000Z";
const diag = memoryDiagJSONFromSnapshot(root, snapshot, generatedAt);
assert.equal(diag.version, 1);
assert.equal(diag.generatedAt, generatedAt);
assert.equal(diag.memories, snapshot.memories);
assert.equal(diag.recentEvents, snapshot.recentEvents);
assert.equal(diag.summary, snapshot.summary);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("grouped evidence summaries match per-memory summaries for stored pending and absorbed memories", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-equivalence-"));
try {
const active = entry("mem-active", "Stable decision memory", "decision");
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
await writeWorkspaceStore(root, [active]);
await writePendingJournal(root, [pending]);
await appendEvidenceEvents(root, [
evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }),
evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }),
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }),
]);
const grouped = groupEvidenceByMemoryId(await queryEvidenceEvents(root));
for (const id of ["mem-active", "mem-pending", "mem-absorbed"]) {
const oldSummary = await summarizeMemoryEvidence(root, { memoryId: id });
const groupedSummary = groupedEvidenceSummary(grouped, id);
assert.deepEqual(groupedSummary.eventIds, oldSummary.eventIds);
assert.deepEqual(groupedSummary.reasonCodes, oldSummary.reasonCodes);
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("buildMemoryDiagJSON preserves evidence ids and reason codes for stored pending and absorbed memories", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-rows-"));
try {
const active = entry("mem-active", "Stable decision memory", "decision");
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
await writeWorkspaceStore(root, [active]);
await writePendingJournal(root, [pending]);
const events = await appendEvidenceEvents(root, [
evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }),
evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }),
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }),
]);
const diag = await buildMemoryDiagJSON(root);
const activeRow = diag.memories.find(memory => memory.id === "mem-active");
const pendingRow = diag.memories.find(memory => memory.id === "mem-pending");
const absorbedRow = diag.memories.find(memory => memory.id === "mem-absorbed");
assert.ok(activeRow);
assert.ok(pendingRow);
assert.ok(absorbedRow);
assert.deepEqual(activeRow.evidenceEventIds, [events[0].eventId]);
assert.ok(activeRow.reasonCodes.includes("stored_reason"));
assert.deepEqual(pendingRow.evidenceEventIds, [events[1].eventId]);
assert.ok(pendingRow.reasonCodes.includes("pending_reason"));
assert.deepEqual(absorbedRow.evidenceEventIds, [events[2].eventId]);
assert.ok(absorbedRow.reasonCodes.includes("same_exact_key"));
assert.ok(absorbedRow.reasonCodes.includes("absorbed_duplicate"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
+41 -953
View File
File diff suppressed because it is too large Load Diff
-122
View File
@@ -35,18 +35,6 @@ const acceptedCases = [
expectedType: "reference",
expectedText: /bracketless/,
},
{
name: "stable URL query reference",
line: "- [reference] Memory diagnostics dashboard URL is https://example.test/search?q=memory&view=summary",
expectedType: "reference",
expectedText: /search\?q=memory/,
},
{
name: "bilingual stable rule",
line: "- [decision] 使用 durable evidence records 保持 memory command auditability",
expectedType: "decision",
expectedText: /保持 memory command auditability/,
},
] as const;
const rejectedCases = [
@@ -90,34 +78,6 @@ const rejectedCases = [
name: "session internal review note",
line: "- [feedback] The assistant reviewed the code reviewer feedback and updated the plan",
},
{
name: "unresolved question suffix",
line: "- [project] Should we add semantic merge to workspace memory?",
},
{
name: "unresolved question prefix",
line: "- [reference] TODO: decide whether to keep this migration path",
},
{
name: "unresolved Chinese question",
line: "- [decision] 需要決定是否要增加新的記憶壓縮策略",
},
{
name: "transient bug state",
line: "- [project] Tests are failing and the next step is to fix the retry path",
},
{
name: "Chinese transient bug state",
line: "- [reference] 目前正在 debug storage lock failure,暫時 workaround 待修",
},
{
name: "deployment snapshot",
line: "- [reference] Latest deployed revision is rev-a8F3kL9pQ2xZ7bN4",
},
{
name: "Chinese deployment snapshot",
line: "- [project] 目前部署版本 build-9f8A7c6D5e4F3g2H 是 active release",
},
] as const;
for (const item of acceptedCases) {
@@ -165,57 +125,6 @@ test("progress snapshot rejection is type independent", () => {
}
});
test("new v1.6 hard quality reasons are emitted by concrete heuristics", () => {
const cases = [
{
reason: "unresolved_question",
entry: { type: "reference" as const, text: "Open question: whether to keep legacy prompt rendering", source: "compaction" as const },
},
{
reason: "unresolved_question",
entry: { type: "project" as const, text: "We need to decide storage migration order?", source: "compaction" as const },
},
{
reason: "transient_bug_state",
entry: { type: "project" as const, text: "Currently debugging memory replacement and tests are failing", source: "compaction" as const },
},
{
reason: "deployment_snapshot",
entry: { type: "reference" as const, text: "Current active release build is build-X9kLmN42pQ7rT6z", source: "compaction" as const },
},
];
for (const { reason, entry } of cases) {
const result = assessMemoryQuality(entry);
assert.equal(result.accepted, false, `${entry.text} should reject`);
assert.ok(result.reasons.includes(reason), `${entry.text} -> ${result.reasons.join(",")}`);
}
});
test("unresolved question guardrails preserve stable URL queries and durable rules", () => {
const urlResult = assessMemoryQuality({
type: "reference",
text: "Memory dashboard URL is https://example.test/search?q=memory&view=summary",
source: "compaction",
});
assert.equal(urlResult.reasons.includes("unresolved_question"), false, urlResult.reasons.join(","));
assert.equal(urlResult.accepted, true);
const durableRule = assessMemoryQuality({
type: "decision",
text: "Use verifier questions only when acceptance evidence is missing?",
source: "compaction",
});
assert.equal(durableRule.reasons.includes("unresolved_question"), false, durableRule.reasons.join(","));
});
test("terse_label is diagnostic only and does not block quality acceptance", () => {
const result = assessMemoryQuality({ type: "reference", text: "Cache key", source: "compaction" });
assert.equal(result.accepted, true);
assert.deepEqual(result.reasons, []);
assert.ok(result.diagnostics?.includes("terse_label"));
});
test("feedback must be stable user preference or instruction", () => {
assert.equal(assessMemoryQuality({ type: "feedback", text: "User prefers concise architecture reviews", source: "compaction" }).accepted, true);
assert.equal(assessMemoryQuality({ type: "feedback", text: "Implemented owner-aware cleanup in plugin.ts", source: "compaction" }).accepted, false);
@@ -227,33 +136,6 @@ test("decision must be future-facing rule, not completed implementation note", (
assert.equal(assessMemoryQuality({ type: "decision", text: "Added semantic merge tests in the previous wave", source: "compaction" }).accepted, false);
});
test("bad_decision 3-tier gate: architecture-like decisions accepted without future-rule imperative", () => {
const architectureLikeCases = [
{ text: "Rule 不在記憶系統 schema 內,歸用戶(agent.md / claude.md),系統最多到 Preference + Suggestion", type: "decision" as const },
{ text: "Ghost memory root cause: normalization 把 capacity losers 從 store 移除時沒有 emit terminal evidence", type: "decision" as const },
{ text: "BASE_HALF_LIFE_DAYS 應從 60 降低到 45", type: "decision" as const },
{ text: "採用 decay-rate 模型取代 priority+penalty 模型", type: "decision" as const },
{ text: "從 scoring 移除 confidence,目前是固定值無意義", type: "decision" as const },
];
const stillRejectedCases = [
{ text: "Implemented phase 2 and updated tests", type: "decision" as const },
{ text: "Implemented CI_SCHEMA_UPDATE for compatibility run 42", type: "decision" as const },
{ text: "Session reviewed the architecture model changes", type: "decision" as const },
{ text: "Some random text with no architecture keywords or future rules", type: "decision" as const },
];
for (const entry of architectureLikeCases) {
const result = assessMemoryQuality({ ...entry, source: "compaction" });
assert.equal(result.reasons.includes("bad_decision"), false, `${entry.text} -> ${result.reasons.join(",")}`);
}
for (const entry of stillRejectedCases) {
const result = assessMemoryQuality({ ...entry, source: "compaction" });
assert.equal(result.reasons.includes("bad_decision"), true, `${entry.text} -> ${result.reasons.join(",")}`);
}
});
test("shared quality gate owns extractor low-quality syntax rejections", () => {
const rejected = [
{ type: "project" as const, text: "fix: add new feature" },
@@ -287,11 +169,7 @@ test("hard quality reasons exclude soft whitelist failures", () => {
assert.equal(isHardQualityReason("code_or_api_signature"), true);
assert.equal(isHardQualityReason("path_heavy"), true);
assert.equal(isHardQualityReason("empty"), true);
assert.equal(isHardQualityReason("unresolved_question"), true);
assert.equal(isHardQualityReason("transient_bug_state"), true);
assert.equal(isHardQualityReason("deployment_snapshot"), true);
assert.equal(isHardQualityReason("bad_feedback"), false);
assert.equal(isHardQualityReason("bad_decision"), false);
assert.equal(isHardQualityReason("terse_label"), false);
});
-216
View File
@@ -1,216 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { appendPendingMemories } from "../src/pending-journal.ts";
import { saveSessionState } from "../src/session-state.ts";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
import { saveWorkspaceMemory } from "../src/workspace-memory.ts";
import {
formatMemoryHelp,
formatMemoryList,
formatMemoryStatus,
getMemoryList,
getMemoryStatus,
renderMemoryCommand,
} from "../src/memory-visibility.ts";
async function tempRoot(): Promise<string> {
return mkdtemp(join(tmpdir(), "memory-visibility-test-"));
}
function memory(id: string, text: string, overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
id,
type: "decision",
text,
source: "compaction",
confidence: 0.8,
status: "active",
createdAt: now,
updatedAt: now,
...overrides,
};
}
test("formats status counts from workspace, session, and pending journal stores", async () => {
const root = await tempRoot();
try {
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "test" },
limits: { maxRenderedChars: 115, maxEntries: 28 },
entries: [
memory("mem-short", "Keep tests focused."),
memory("mem-long", "Long memory with password: sushi ".repeat(20), { type: "reference" }),
memory("mem-old", "Superseded memory should not be active.", { status: "superseded" }),
],
migrations: [],
updatedAt: now,
};
await saveWorkspaceMemory(root, store);
await saveSessionState(root, {
version: 1,
sessionID: "ses_status",
turn: 1,
updatedAt: now,
activeFiles: [],
openErrors: [{
id: "err-1",
category: "typecheck",
summary: "Typecheck failed",
fingerprint: "typecheck",
status: "open",
firstSeen: Date.now(),
lastSeen: Date.now(),
seenCount: 1,
}],
recentDecisions: [{ id: "dec-1", text: "Prefer local rendering", source: "user", createdAt: Date.now() }],
pendingMemories: [memory("pending-session", "Pending for this session", { source: "explicit", pendingOwnerSessionID: "ses_status" })],
compactionMemoryRefs: [],
});
await appendPendingMemories(root, [memory("pending-journal", "Pending in durable journal", { source: "explicit", pendingOwnerSessionID: "ses_status" })]);
const output = formatMemoryStatus(await getMemoryStatus(root, "ses_status"));
assert.match(output, /^## Memory status/);
assert.match(output, /Workspace:/);
assert.match(output, /- Active memories: 2/);
assert.match(output, /- Rendered in prompt: 1/);
assert.match(output, /- Omitted active memories: 1/);
assert.match(output, /- Superseded memories: 1/);
assert.match(output, /Pending:/);
assert.match(output, /- Pending in this session: 1/);
assert.match(output, /- Pending journal memories: 1/);
assert.match(output, /Session:/);
assert.match(output, /- Open errors: 1/);
assert.match(output, /- Recent decisions: 1/);
assert.match(output, /Use \/memory → Current memories to browse current \[M1\]-\[M28\] memory refs\./);
assert.match(output, /Local only: no LLM request was made\./);
assert.equal(output.includes("Recent active memory previews"), false);
assert.equal(output.includes("sushi"), false, "status output should not include memory previews");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("getMemoryStatus redacts previews without rewriting workspace memory", async () => {
const root = await tempRoot();
try {
const now = new Date().toISOString();
const path = await workspaceMemoryPath(root);
await mkdir(dirname(path), { recursive: true });
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "test" },
limits: { maxRenderedChars: 3600, maxEntries: 28 },
entries: [memory("mem-secret", "Remember password: sushi for the fake test fixture.", { createdAt: now, updatedAt: now })],
migrations: [],
updatedAt: now,
};
const before = JSON.stringify(store, null, 2);
await writeFile(path, before, "utf8");
const output = formatMemoryStatus(await getMemoryStatus(root, "ses_readonly"));
const after = await readFile(path, "utf8");
assert.match(output, /- Active memories: 1/);
assert.equal(output.includes("Recent active memory previews"), false);
assert.equal(output.includes("sushi"), false, "status output should not include memory previews");
assert.equal(after, before, "status display must not persist normalization, migration, or redaction changes");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("formats current workspace memories grouped by type with display-local refs", async () => {
const root = await tempRoot();
try {
const now = new Date().toISOString();
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: "test" },
limits: { maxRenderedChars: 3600, maxEntries: 28 },
entries: [
memory("mem-feedback", "Remember password: sushi for the fake test.", { type: "feedback" }),
memory("mem-project", "Project memory should render in its own group.", { type: "project" }),
memory("mem-decision", "Decision memory should render in its own group.", { type: "decision" }),
memory("mem-reference", "Reference memory should render in its own group.", { type: "reference" }),
memory("mem-superseded", "Superseded memory should not be active", { type: "reference", status: "superseded" }),
],
migrations: [],
updatedAt: now,
});
const output = formatMemoryList(await getMemoryList(root));
assert.match(output, /^## Current workspace memories/);
assert.match(output, /Display refs are local to this output/);
assert.match(output, /feedback:\n- \[M\d+\]/);
assert.match(output, /project:\n- \[M\d+\]/);
assert.match(output, /decision:\n- \[M\d+\]/);
assert.match(output, /reference:\n- \[M\d+\]/);
assert.match(output, /Shown: \d+ of \d+ active memories\./);
assert.match(output, /Shown: 4 of 4 active memories\./);
assert.match(output, /Omitted active memories: 0\./);
assert.equal(output.includes("[M1]"), true, "at least one display-local ref should render");
assert.equal(output.includes("sushi"), false, "list previews should redact credential-like text");
assert.equal(output.includes("Superseded memory should not be active"), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("formats empty memory list state", () => {
const output = formatMemoryList({
activeMemories: 0,
renderedMemories: 0,
omittedActiveMemories: 0,
groups: { feedback: [], project: [], decision: [], reference: [] },
});
assert.match(output, /^## Current workspace memories/);
assert.match(output, /No active workspace memories are stored yet\./);
assert.match(output, /Local only: no LLM request was made\./);
assert.equal(output.includes("feedback:"), false);
});
test("formats help text for available display commands", () => {
const output = formatMemoryHelp();
assert.match(output, /^## Memory help/);
assert.match(output, /\/memory — open the local memory menu\./);
assert.match(output, /Status — show local memory statistics\./);
assert.match(output, /Current memories — browse active workspace memories as display-local \[M1\]-\[M28\] refs\./);
assert.match(output, /Help — show this help\./);
for (const removedCommand of ["/memory-" + "status", "/memory-" + "list", "/memory-" + "help"]) {
assert.equal(output.includes(removedCommand), false);
}
assert.equal(output.includes("/memory activity"), false);
assert.equal(output.includes("/memory last"), false);
assert.equal(output.includes("/memory status"), false);
assert.equal(output.includes("/memory help"), false);
assert.match(output, /do not call the LLM/);
});
test("renderMemoryCommand routes list output", async () => {
const root = await tempRoot();
try {
const output = await renderMemoryCommand(root, "ses_list", "list");
assert.match(output, /^## Current workspace memories/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("renderMemoryCommand falls back to help for unknown command values", async () => {
const root = await tempRoot();
try {
const output = await renderMemoryCommand(root, "ses_unknown", "unknown" as never);
assert.match(output, /^## Memory help/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+6 -1047
View File
File diff suppressed because it is too large Load Diff
-103
View File
@@ -1,103 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
REINFORCEMENT_MAX_COUNT,
REINFORCEMENT_MIN_INTERVAL_MS,
tryReinforceMemory,
} from "../src/retention.ts";
import type { LongTermMemoryEntry } from "../src/types.ts";
const baseMemory = (overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry => ({
id: "mem-retention",
type: "decision",
text: "Durable decision for reinforcement",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: "2026-05-10T00:00:00.000Z",
updatedAt: "2026-05-10T00:00:00.000Z",
...overrides,
});
test("tryReinforceMemory blocks same session with exact reason", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: now - 2 * REINFORCEMENT_MIN_INTERVAL_MS,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-a", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "same_session");
assert.equal(decision.memory, memory);
});
test("tryReinforceMemory blocks different session on same UTC day", () => {
const lastAt = Date.UTC(2026, 4, 12, 0, 15, 0);
const now = Date.UTC(2026, 4, 12, 23, 30, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "same_utc_day");
assert.equal(decision.lastReinforcedAt, lastAt);
});
test("tryReinforceMemory blocks min interval across UTC day boundary", () => {
const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0);
const now = Date.UTC(2026, 4, 13, 0, 15, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_interval");
assert.equal(decision.minIntervalMs, REINFORCEMENT_MIN_INTERVAL_MS);
});
test("tryReinforceMemory blocks max count with exact reason", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0),
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "max_count");
assert.equal(decision.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.maxReinforcementCount, REINFORCEMENT_MAX_COUNT);
});
test("tryReinforceMemory reinforces allowed memory and wrapper returns memory only", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0),
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.previousReinforcementCount, 1);
assert.equal(decision.newReinforcementCount, 2);
assert.notEqual(decision.memory, memory);
assert.equal(decision.memory.reinforcementCount, 2);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.memory.retentionClock, now);
});
-363
View File
@@ -1,363 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { MemoryV2Plugin } from "../src/plugin.ts";
import * as sessionStateModule from "../src/session-state.ts";
import type { HotSessionStateRenderAccounting } from "../src/session-state.ts";
import { sessionStatePath, workspaceMemoryPath } from "../src/paths.ts";
import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "../src/types.ts";
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
type AccountHotSessionStateRender = (state: SessionState, workspaceRoot: string) => HotSessionStateRenderAccounting;
const accountHotSessionStateRender = (
sessionStateModule as typeof sessionStateModule & { accountHotSessionStateRender: AccountHotSessionStateRender }
).accountHotSessionStateRender;
const { createEmptySessionState, loadSessionState, renderHotSessionState, saveSessionState } = sessionStateModule;
const root = "/repo";
function state(overrides: Partial<SessionState> = {}): SessionState {
return {
version: 1,
sessionID: "session-state-test",
turn: 0,
updatedAt: "2026-05-05T00:00:00.000Z",
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [],
compactionMemoryRefs: [],
...overrides,
};
}
function compactionRef(index: number, overrides: Partial<CompactionMemoryRef> = {}): CompactionMemoryRef {
return {
ref: `M${index}`,
memoryId: `memory-${index}`,
type: "decision",
source: "compaction",
exactKey: `decision:durable fact ${index}`,
identityKey: `decision:durable fact ${index}`,
textPreview: `Durable fact ${index}`,
capturedAt: 1_777_000_000_000 + index,
...overrides,
};
}
function mockRootClient(summary = "") {
return {
session: {
get: async () => ({ data: { parentID: null } }),
messages: async () => ({
data: summary
? [{ info: { role: "assistant", summary: true }, parts: [{ type: "text", text: summary }] }]
: [],
}),
todo: async () => ({ data: [] }),
},
};
}
function activeFile(path: string, action: ActiveFile["action"], count: number, lastSeen: number): ActiveFile {
return { path, action, count, lastSeen };
}
function openError(id: string, summary: string, lastSeen: number): OpenError {
return {
id,
category: "test",
summary,
fingerprint: `fingerprint-${id}`,
status: "open",
firstSeen: lastSeen - 1,
lastSeen,
seenCount: 1,
};
}
function decision(id: string, text: string, createdAt: number): SessionDecision {
return { id, text, source: "assistant", createdAt };
}
function memory(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
return {
id,
type,
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: "2026-05-05T00:00:00.000Z",
updatedAt: "2026-05-05T00:00:00.000Z",
};
}
test("accountHotSessionStateRender returns empty prompt and no omissions for empty state", () => {
const accounting = accountHotSessionStateRender(createEmptySessionState("empty-session"), root);
assert.equal(accounting.prompt, "");
assert.deepEqual(accounting.omitted, []);
assert.equal(accounting.maxRenderedChars, HOT_STATE_LIMITS.maxRenderedChars);
});
test("accountHotSessionStateRender renders hot-state sections in stable order", () => {
const accounting = accountHotSessionStateRender(state({
activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)],
openErrors: [openError("err-1", "one failing test", 2)],
recentDecisions: [decision("dec-1", "Keep renderer simple", 3)],
pendingMemories: [memory("mem-1", "Promote useful fact")],
}), root);
assert.ok(accounting.prompt.startsWith("Hot session state (current session):"));
assert.ok(accounting.prompt.indexOf("active_files:") < accounting.prompt.indexOf("open_errors:"));
assert.ok(accounting.prompt.indexOf("open_errors:") < accounting.prompt.indexOf("recent_decisions:"));
assert.ok(accounting.prompt.indexOf("recent_decisions:") < accounting.prompt.indexOf("pending_memories:"));
});
test("accountHotSessionStateRender ranks active files by score then lastSeen descending", () => {
const accounting = accountHotSessionStateRender(state({
activeFiles: [
activeFile("/repo/src/edit.ts", "edit", 1, 400),
activeFile("/repo/src/write.ts", "write", 5, 200),
activeFile("/repo/src/grep.ts", "grep", 10, 300),
activeFile("/repo/src/read.ts", "read", 12, 100),
],
}), root);
const lines = accounting.prompt.split("\n");
const activeLines = lines.filter(line => line.startsWith("- src/"));
assert.deepEqual(activeLines, [
"- src/grep.ts (grep, 10x)",
"- src/write.ts (write, 5x)",
"- src/read.ts (read, 12x)",
"- src/edit.ts (edit, 1x)",
]);
});
test("accountHotSessionStateRender reports section-cap omissions for every capped section", () => {
const accounting = accountHotSessionStateRender(state({
activeFiles: Array.from({ length: 9 }, (_, index) => activeFile(`/repo/a${index}.ts`, "read", 1, 100 - index)),
openErrors: Array.from({ length: 4 }, (_, index) => openError(`err-${index}`, `e${index}`, 100 - index)),
recentDecisions: Array.from({ length: 9 }, (_, index) => decision(`dec-${index}`, `d${index}`, index)),
pendingMemories: Array.from({ length: 7 }, (_, index) => memory(`mem-${index}`, `m${index}`)),
}), root);
const sectionCapOmissions = accounting.omitted.filter(item => item.reason === "section_cap");
assert.deepEqual(
sectionCapOmissions.map(item => item.section).sort(),
["active_files", "open_errors", "pending_memories", "recent_decisions"].sort(),
);
assert.equal(sectionCapOmissions.find(item => item.section === "pending_memories")?.memoryId, "mem-0");
});
test("accountHotSessionStateRender omits over-budget entries without cutting rendered lines", () => {
const longPath = `/repo/${"x".repeat(650)}.ts`;
const accounting = accountHotSessionStateRender(state({
activeFiles: [
activeFile("/repo/src/short.ts", "read", 1, 20),
activeFile(longPath, "read", 1, 10),
],
}), root);
assert.equal(accounting.prompt, [
"Hot session state (current session):",
"active_files:",
"- src/short.ts (read, 1x)",
].join("\n"));
assert.equal(accounting.omitted.length, 1);
assert.equal(accounting.omitted[0]?.reason, "char_budget");
assert.equal(accounting.omitted[0]?.section, "active_files");
assert.ok(!accounting.prompt.includes("x".repeat(20)));
});
test("accountHotSessionStateRender includes exact 700-char prompt but omits one additional character", () => {
const fixedPrompt = [
"Hot session state (current session):",
"pending_memories:",
"- [decision] ",
].join("\n");
const exactText = "x".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length);
const exactAccounting = accountHotSessionStateRender(state({
pendingMemories: [memory("mem-exact", exactText)],
}), root);
assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars);
assert.equal(exactAccounting.omitted.length, 0);
const overAccounting = accountHotSessionStateRender(state({
pendingMemories: [memory("mem-over", `${exactText}y`)],
}), root);
assert.equal(overAccounting.prompt, "");
assert.equal(overAccounting.omitted.length, 1);
assert.equal(overAccounting.omitted[0]?.reason, "char_budget");
assert.equal(overAccounting.omitted[0]?.memoryId, "mem-over");
});
test("accountHotSessionStateRender suppresses header-only sections when no entries fit", () => {
const accounting = accountHotSessionStateRender(state({
activeFiles: [activeFile(`/repo/${"z".repeat(720)}.ts`, "read", 1, 1)],
}), root);
assert.equal(accounting.prompt, "");
assert.equal(accounting.omitted.length, 1);
assert.equal(accounting.omitted[0]?.reason, "char_budget");
assert.ok(!accounting.prompt.includes("active_files:"));
});
test("renderHotSessionState delegates to accounted renderer prompt for empty and seeded states", () => {
const empty = createEmptySessionState("compat-empty");
const seeded = state({
activeFiles: [activeFile("/repo/src/a.ts", "edit", 2, 1)],
pendingMemories: [memory("mem-compat", "Compatibility prompt")],
});
assert.equal(renderHotSessionState(empty, root), accountHotSessionStateRender(empty, root).prompt);
assert.equal(renderHotSessionState(seeded, root), accountHotSessionStateRender(seeded, root).prompt);
});
test("accountHotSessionStateRender counts newline separators in the 700-char budget", () => {
const fixedPrompt = [
"Hot session state (current session):",
"recent_decisions:",
"- ",
].join("\n");
const exactText = "n".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length);
const exactAccounting = accountHotSessionStateRender(state({
recentDecisions: [decision("dec-exact", exactText, 1)],
}), root);
assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars);
assert.equal(exactAccounting.omitted.length, 0);
const overAccounting = accountHotSessionStateRender(state({
recentDecisions: [decision("dec-over", `${exactText}!`, 1)],
}), root);
assert.equal(overAccounting.prompt, "");
assert.equal(overAccounting.omitted.length, 1);
assert.equal(overAccounting.omitted[0]?.reason, "char_budget");
});
test("compaction memory refs round-trip through session state and are capped", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-"));
try {
const refs = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, index) => compactionRef(
index + 1,
index === 0 ? { compactionId: "compaction-snapshot-1" } : {},
));
await saveSessionState(tmpDir, state({
sessionID: "compaction-ref-roundtrip",
compactionMemoryRefs: refs,
}));
const loaded = await loadSessionState(tmpDir, "compaction-ref-roundtrip");
assert.equal(loaded.compactionMemoryRefs.length, LONG_TERM_LIMITS.maxEntries);
assert.deepEqual(loaded.compactionMemoryRefs, refs.slice(0, LONG_TERM_LIMITS.maxEntries));
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("invalid stored compaction memory refs normalize to empty without crashing", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-"));
try {
const path = await sessionStatePath(tmpDir, "invalid-compaction-ref-session");
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify({
version: 1,
sessionID: "stale-id",
turn: 0,
updatedAt: "2026-05-05T00:00:00.000Z",
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [],
compactionMemoryRefs: [
compactionRef(1),
{ ...compactionRef(2), ref: "not-a-numbered-ref" },
],
}), "utf8");
const loaded = await loadSessionState(tmpDir, "invalid-compaction-ref-session");
assert.equal(loaded.sessionID, "invalid-compaction-ref-session");
assert.deepEqual(loaded.compactionMemoryRefs, []);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("compaction memory refs never render in hot session state", () => {
const rendered = renderHotSessionState(state({
activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)],
compactionMemoryRefs: [compactionRef(1, {
memoryId: "secret-memory-id",
exactKey: "decision:secret exact key",
identityKey: "decision:secret identity key",
textPreview: "Secret compaction ref preview must not render",
})],
}), root);
assert.match(rendered, /active_files:/);
assert.doesNotMatch(rendered, /Secret compaction ref preview/);
assert.doesNotMatch(rendered, /secret-memory-id/);
assert.doesNotMatch(rendered, /secret exact key/);
assert.doesNotMatch(rendered, /secret identity key/);
assert.doesNotMatch(rendered, /\bM1\b/);
});
test("session.compacted clears compaction memory refs after processing", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-"));
try {
await saveSessionState(tmpDir, state({
sessionID: "clear-compaction-refs-session",
compactionMemoryRefs: [compactionRef(1)],
}));
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "clear-compaction-refs-session" } },
});
const loaded = await loadSessionState(tmpDir, "clear-compaction-refs-session");
assert.deepEqual(loaded.compactionMemoryRefs, []);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session.compacted clears compaction memory refs even when promotion fails", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-"));
try {
await saveSessionState(tmpDir, state({
sessionID: "clear-compaction-refs-failure-session",
pendingMemories: [memory("mem-pending-failure", "Keep pending memory when promotion fails")],
compactionMemoryRefs: [compactionRef(1)],
}));
const workspacePath = await workspaceMemoryPath(tmpDir);
await rm(workspacePath, { force: true }).catch(() => undefined);
await mkdir(workspacePath, { recursive: true });
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "clear-compaction-refs-failure-session" } },
});
const loaded = await loadSessionState(tmpDir, "clear-compaction-refs-failure-session");
assert.deepEqual(loaded.compactionMemoryRefs, []);
assert.equal(loaded.pendingMemories.length, 1,
"unrelated retryable pending memory should remain on promotion failure");
assert.equal(loaded.pendingMemories[0].id, "mem-pending-failure");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
-51
View File
@@ -1,51 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
const maxBuffer = 10 * 1024 * 1024;
function executable(name: "npm" | "npx"): string {
return process.platform === "win32" ? `${name}.cmd` : name;
}
test("packed memory-diag bin runs from a temp consumer project", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-packaging-"));
const packDir = join(tempRoot, "pack");
const consumerDir = join(tempRoot, "consumer");
try {
await mkdir(packDir, { recursive: true });
await mkdir(consumerDir, { recursive: true });
const packResult = await execFileAsync(executable("npm"), [
"pack",
repoRoot,
"--pack-destination",
packDir,
"--silent",
], { cwd: tempRoot, maxBuffer });
const tarballName = packResult.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
assert.ok(tarballName, `npm pack did not report a tarball name. stdout:\n${packResult.stdout}\nstderr:\n${packResult.stderr}`);
const tarballPath = join(packDir, tarballName);
const runResult = await execFileAsync(executable("npx"), [
"--yes",
"--package",
tarballPath,
"memory-diag",
"--help",
], { cwd: consumerDir, maxBuffer });
assert.match(runResult.stdout, /Usage:/);
assert.match(runResult.stdout, /memory-diag \[status\]/);
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
-365
View File
@@ -1,365 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TuiCommand } from "@opencode-ai/plugin/tui";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { saveWorkspaceMemory } from "../src/workspace-memory.ts";
// ---------------------------------------------------------------------------
// Mock infrastructure
// ---------------------------------------------------------------------------
type MockDialogSize = "medium" | "large" | "xlarge";
type MockDialogAlertProps = { title: string; message: string; onConfirm?: () => void };
type MockDialogSelectOption<Value = string> = {
title: string;
value: Value;
description?: string;
footer?: string;
category?: string;
disabled?: boolean;
onSelect?: () => void | Promise<void>;
};
type MockDialogSelectProps<Value = string> = {
title: string;
placeholder?: string;
options: MockDialogSelectOption<Value>[];
onSelect?: (option: MockDialogSelectOption<Value>) => void | Promise<void>;
skipFilter?: boolean;
};
type MockDialogElement =
| { type: "DialogAlert"; props: MockDialogAlertProps }
| { type: "DialogSelect"; props: MockDialogSelectProps };
type MockDialogRender = () => MockDialogElement;
type MockDialogContext = {
clear: () => void;
replace?: (render: MockDialogRender, onClose?: () => void) => void;
setSize?: (size: MockDialogSize) => void;
renders: MockDialogElement[];
sizes: MockDialogSize[];
events: string[];
};
type RuntimeCommand = { value: string; suggested?: boolean; slash?: { name: string; aliases?: string[] }; onSelect?: (dialog: MockDialogContext) => void | Promise<void> };
type MockPromptCall = Record<string, unknown>;
interface MockTuiApi {
commands: RuntimeCommand[];
prompts: MockPromptCall[];
toasts: Array<{ variant?: string; message: string }>;
dialog: MockDialogContext;
route: { name: string; params?: Record<string, unknown> };
state: { path: { directory: string } };
command: { register: (cb: () => TuiCommand[]) => () => void };
ui: {
toast: (input: { variant?: string; message: string }) => void;
dialog?: Partial<MockDialogContext>;
DialogAlert?: (props: MockDialogAlertProps) => MockDialogElement;
DialogSelect?: (props: MockDialogSelectProps) => MockDialogElement;
};
client: { session: { prompt: (input: MockPromptCall) => Promise<void> } };
}
function makeMockTuiApi(options: {
route: { name: string; params?: Record<string, unknown> };
directory?: string;
missingDialogAlert?: boolean;
missingDialogSelect?: boolean;
missingDialogReplace?: boolean;
missingDialogSetSize?: boolean;
dialogReplaceThrows?: boolean;
}): MockTuiApi {
const commands: RuntimeCommand[] = [];
const prompts: MockPromptCall[] = [];
const toasts: Array<{ variant?: string; message: string }> = [];
const dialog: MockDialogContext = {
clear: () => { dialog.events.push("clear"); },
replace: (render: MockDialogRender) => {
dialog.events.push("replace");
if (options.dialogReplaceThrows) throw new Error("dialog failure");
dialog.renders.push(render());
},
setSize: (size: MockDialogSize) => {
dialog.events.push(`setSize:${size}`);
dialog.sizes.push(size);
},
renders: [],
sizes: [],
events: [],
};
const uiDialog: Partial<MockDialogContext> = {
clear: dialog.clear,
replace: options.missingDialogReplace ? undefined : dialog.replace,
setSize: options.missingDialogSetSize ? undefined : dialog.setSize,
};
return {
commands,
prompts,
toasts,
dialog,
route: options.route,
state: { path: { directory: options.directory ?? "/mock/workspace" } },
command: {
register: (cb: () => TuiCommand[]) => {
const items = cb();
for (const item of items) {
commands.push({
value: item.value,
suggested: item.suggested,
slash: item.slash,
onSelect: item.onSelect
? (dialogContext: MockDialogContext = dialog) => (item.onSelect as (dialog: MockDialogContext) => void | Promise<void>)(dialogContext)
: undefined,
});
}
return () => {};
},
},
ui: {
toast: (input: { variant?: string; message: string }) => { toasts.push(input); },
dialog: uiDialog,
DialogAlert: options.missingDialogAlert ? undefined : (props: MockDialogAlertProps): MockDialogElement => ({ type: "DialogAlert", props }),
DialogSelect: options.missingDialogSelect ? undefined : (props: MockDialogSelectProps): MockDialogElement => ({ type: "DialogSelect", props }),
},
client: {
session: {
prompt: async (input: MockPromptCall) => { prompts.push(input); },
},
},
};
}
async function selectCommand(api: MockTuiApi, value: string): Promise<void> {
const command = api.commands.find((item): item is RuntimeCommand => item.value === value);
assert.ok(command, `registered command ${value}`);
await command.onSelect?.(api.dialog);
}
function lastDialog(api: MockTuiApi): MockDialogElement {
const hit = api.dialog.renders.at(-1);
assert.ok(hit, "expected a rendered dialog");
return hit;
}
async function chooseSelectOption(api: MockTuiApi, value: string): Promise<void> {
const dialog = lastDialog(api);
assert.equal(dialog.type, "DialogSelect");
const option = dialog.props.options.find(item => item.value === value);
assert.ok(option, `expected select option ${value}`);
// Source evidence: OpenCode's plugin API maps option.onSelect to a zero-arg
// callback and DialogSelect invokes option.onSelect before top-level onSelect.
await option.onSelect?.();
}
async function tempRoot(): Promise<string> {
return mkdtemp(join(tmpdir(), "memory-tui-test-"));
}
function memory(id: string, text: string, overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
id,
type: "decision",
text,
source: "compaction",
confidence: 0.8,
status: "active",
createdAt: now,
updatedAt: now,
...overrides,
};
}
async function seedWorkspaceMemories(root: string): Promise<void> {
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "test" },
limits: { maxRenderedChars: 3600, maxEntries: 28 },
entries: [
memory("mem-feedback", "Remember password: sushi for the fake test.", { type: "feedback" }),
memory("mem-project", "Project memory should render in its group.", { type: "project" }),
memory("mem-decision", "Decision memory should render in its group.", { type: "decision" }),
memory("mem-reference", "Reference memory should render in its group.", { type: "reference" }),
memory("mem-superseded", "Superseded memory should not be active", { type: "reference", status: "superseded" }),
],
migrations: [],
updatedAt: now,
};
await saveWorkspaceMemory(root, store);
}
// Dynamic import to allow module-level mocking
const { MemoryTuiPlugin } = await import("../src/tui-plugin.ts");
test("registers one unsuggested /memory slash command", async () => {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
assert.deepEqual(api.commands.map(command => command.value), ["memory.menu"]);
assert.deepEqual(api.commands.map(command => command.slash?.name).filter(Boolean), ["memory"]);
assert.deepEqual(api.commands.map(command => command.suggested), [undefined]);
for (const removedName of ["memory-" + "status", "memory-" + "list", "memory-" + "help"]) {
assert.equal(api.commands.some(command => command.slash?.name === removedName), false);
}
});
test("opens the memory submenu without prompt injection", async () => {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
assert.equal(api.prompts.length, 0);
const dialog = lastDialog(api);
assert.equal(dialog.type, "DialogSelect");
assert.equal(dialog.props.title, "Memory");
assert.equal(dialog.props.placeholder, "Search memory actions");
assert.deepEqual(dialog.props.options.map(item => item.title), ["Status", "Current memories", "Help"]);
assert.deepEqual(dialog.props.options.map(item => item.value), ["memory.status", "memory.list", "memory.help"]);
assert.ok(api.dialog.events.indexOf("clear") < api.dialog.events.indexOf("replace"));
assert.ok(api.dialog.events.indexOf("replace") < api.dialog.events.indexOf("setSize:large"));
});
test("supports home-route menu, list, and help while status warns", async () => {
const root = await tempRoot();
try {
const api = makeMockTuiApi({ route: { name: "home" }, directory: root });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
assert.equal(lastDialog(api).type, "DialogSelect");
assert.equal(lastDialog(api).props.title, "Memory");
await chooseSelectOption(api, "memory.list");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Current workspace memories");
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.help");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Memory help");
await selectCommand(api, "memory.menu");
const beforeStatusRenders = api.dialog.renders.length;
await chooseSelectOption(api, "memory.status");
assert.equal(api.dialog.renders.length, beforeStatusRenders, "status should not render without an active session");
assert.ok(api.toasts.some(t => t.variant === "warning" && t.message === "Open a session to use memory commands."));
assert.equal(api.prompts.length, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("shows status and help alerts from the submenu", async () => {
const root = await tempRoot();
try {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, directory: root });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.status");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Memory status");
assert.match(lastDialog(api).props.message, /Workspace:/);
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.help");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Memory help");
assert.match(lastDialog(api).props.message, /Status/);
assert.equal(api.prompts.length, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("shows current memories in a grouped DialogSelect with no-op row selection", async () => {
const root = await tempRoot();
try {
await seedWorkspaceMemories(root);
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, directory: root });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.list");
const dialog = lastDialog(api);
assert.equal(dialog.type, "DialogSelect");
assert.equal(dialog.props.title, "Current workspace memories");
assert.equal(dialog.props.placeholder, "Search memory refs");
assert.deepEqual([...new Set(dialog.props.options.map(item => item.category))], ["feedback", "project", "decision", "reference"]);
assert.ok(dialog.props.options.every(item => /^\[M\d+\] /.test(item.title)));
assert.ok(dialog.props.options.every(item => typeof item.footer === "string"));
assert.equal(dialog.props.options.some(item => item.title.includes("sushi")), false);
assert.equal(dialog.props.options.some(item => item.title.includes("Superseded memory should not be active")), false);
assert.equal(api.dialog.sizes.at(-1), "xlarge");
const beforeRenders = api.dialog.renders.length;
const beforeToasts = api.toasts.length;
await chooseSelectOption(api, dialog.props.options[0].value);
assert.equal(api.dialog.renders.length, beforeRenders, "memory row selection should not replace dialog in this wave");
assert.equal(api.toasts.length, beforeToasts, "memory row selection should not expose mutation/action toast");
assert.equal(api.prompts.length, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("shows empty current memories as an alert", async () => {
const root = await tempRoot();
try {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, directory: root });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.list");
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Current workspace memories");
assert.match(lastDialog(api).props.message, /No active workspace memories are stored yet\./);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("shows local read failures in a memory error alert", async () => {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } } });
api.state.path.directory = undefined as never;
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
await chooseSelectOption(api, "memory.status");
assert.equal(api.prompts.length, 0);
assert.equal(lastDialog(api).type, "DialogAlert");
assert.equal(lastDialog(api).props.title, "Memory error");
assert.match(lastDialog(api).props.message, /Unable to render local memory visibility output\./);
});
test("shows error toast when dialog runtime API is unavailable", async () => {
for (const options of [
{ missingDialogAlert: true },
{ missingDialogSelect: true },
{ missingDialogReplace: true },
{ missingDialogSetSize: true },
]) {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, ...options });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
assert.equal(api.prompts.length, 0, "should not fall back to prompt injection");
assert.equal(api.dialog.renders.length, 0, "should not partially open a dialog when API guard fails");
assert.ok(api.toasts.some(t => t.variant === "error" && t.message === "Memory dialog UI is unavailable in this OpenCode runtime."));
}
});
test("shows error toast when dialog replacement fails without prompt fallback", async () => {
const api = makeMockTuiApi({ route: { name: "session", params: { sessionID: "ses_1" } }, dialogReplaceThrows: true });
await MemoryTuiPlugin(api as any, undefined, { id: "test", source: "file", spec: "test", target: "./tui", first_time: Date.now(), last_time: Date.now(), time_changed: Date.now(), load_count: 1, fingerprint: "test", state: "first" });
await selectCommand(api, "memory.menu");
assert.equal(api.prompts.length, 0);
assert.equal(api.dialog.renders.length, 0);
assert.equal(api.dialog.sizes.length, 0);
assert.ok(api.toasts.some(t => t.variant === "error" && /^Unable to show memory dialog: dialog failure$/.test(t.message)), "should show error toast on dialog failure");
});
+24 -623
View File
@@ -6,11 +6,9 @@ import { tmpdir } from "node:os";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
import { queryEvidenceEvents } from "../src/evidence-log.ts";
import {
renderWorkspaceMemory,
accountWorkspaceMemoryRender,
accountWorkspaceMemoryCompactionRefs,
enforceLongTermLimits,
dedupeLongTermEntriesWithAccounting,
enforceLongTermLimitsWithAccounting,
@@ -30,7 +28,7 @@ import {
calculateRetentionStrength,
calculateDormantDays,
calculateEffectiveAgeDays,
tryReinforceMemory,
reinforceMemory,
} from "../src/retention.ts";
import { redactCredentials } from "../src/redaction.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
@@ -45,15 +43,6 @@ test("default prompt budgets use calibrated conservative character caps", () =>
assert.equal(HOT_STATE_LIMITS.maxRenderedChars, 700);
});
test("retention type caps use v1.6 decision headroom without changing other caps", () => {
assert.equal(RETENTION_TYPE_MAX.feedback, 10);
assert.equal(RETENTION_TYPE_MAX.decision, 12);
assert.equal(RETENTION_TYPE_MAX.project, 8);
assert.equal(RETENTION_TYPE_MAX.reference, 6);
assert.equal(LONG_TERM_LIMITS.maxEntries, 28);
assert.equal(LONG_TERM_LIMITS.maxRenderedChars, 3600);
});
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
@@ -169,92 +158,6 @@ test("renderWorkspaceMemory returns empty for no entries", () => {
assert.equal(rendered, "");
});
test("accountWorkspaceMemoryCompactionRefs returns empty prompt and refs for no entries", () => {
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [],
updatedAt: new Date().toISOString(),
};
const accounting = accountWorkspaceMemoryCompactionRefs(store);
assert.equal(accounting.prompt, "");
assert.deepEqual(accounting.refs, []);
});
test("accountWorkspaceMemoryCompactionRefs renders numbered refs by type with snapshot keys", () => {
const originalNow = Date.now;
const capturedAt = Date.UTC(2026, 4, 7, 12, 0, 0);
Date.now = () => capturedAt;
try {
const createdAt = new Date(capturedAt).toISOString();
const entries: LongTermMemoryEntry[] = [
{ ...entry("mem-feedback", "User requires verifier confirmation after each wave.", "feedback"), createdAt, updatedAt: createdAt, source: "explicit" },
{ ...entry("mem-project", "This repository is an OpenCode plugin using local JSON stores.", "project"), createdAt, updatedAt: createdAt },
{ ...entry("mem-decision", "Decision dedupe stays exact-only.", "decision"), createdAt, updatedAt: createdAt },
{ ...entry("mem-reference", "Workspace memory is rendered as frozen system[1] during normal chat turns.", "reference"), createdAt, updatedAt: createdAt },
{ ...entry("mem-superseded", "Superseded memory should not get a ref.", "decision"), status: "superseded" as const },
];
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries,
updatedAt: createdAt,
lastActivityAt: createdAt,
};
const accounting = accountWorkspaceMemoryCompactionRefs(store);
assert.equal(accounting.prompt, [
"Existing workspace memories available for consolidation:",
"feedback:",
"[M1] User requires verifier confirmation after each wave.",
"project:",
"[M2] This repository is an OpenCode plugin using local JSON stores.",
"decision:",
"[M3] Decision dedupe stays exact-only.",
"reference:",
"[M4] Workspace memory is rendered as frozen system[1] during normal chat turns.",
].join("\n"));
assert.deepEqual(accounting.refs.map(ref => ref.ref), ["M1", "M2", "M3", "M4"]);
assert.deepEqual(accounting.refs.map(ref => ref.memoryId), ["mem-feedback", "mem-project", "mem-decision", "mem-reference"]);
assert.deepEqual(accounting.refs.map(ref => ref.exactKey), accounting.rendered.map(workspaceMemoryExactKey));
assert.deepEqual(accounting.refs.map(ref => ref.identityKey), accounting.rendered.map(workspaceMemoryIdentityKey));
assert.ok(accounting.refs.every(ref => ref.capturedAt === capturedAt));
assert.equal(accounting.refs.some(ref => ref.memoryId === "mem-superseded"), false);
} finally {
Date.now = originalNow;
}
});
test("accountWorkspaceMemoryCompactionRefs is bounded by long-term caps", () => {
const now = new Date().toISOString();
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 10 }, (_, i) => entry(`compaction-feedback-${i}`, `Compaction feedback ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`compaction-decision-${i}`, `Compaction decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`compaction-project-${i}`, `Compaction project ${i}`, "project")),
...Array.from({ length: 6 }, (_, i) => entry(`compaction-reference-${i}`, `Compaction reference ${i}`, "reference")),
].map(memory => ({ ...memory, createdAt: now, updatedAt: now }));
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries,
updatedAt: now,
lastActivityAt: now,
};
const accounting = accountWorkspaceMemoryCompactionRefs(store);
assert.equal(accounting.refs.length, LONG_TERM_LIMITS.maxEntries);
assert.equal(accounting.prompt.includes("[M28]"), true);
assert.equal(accounting.prompt.includes("[M29]"), false);
});
test("accountWorkspaceMemoryRender reports rendered and omitted reasons", () => {
const store: WorkspaceMemoryStore = {
version: 1,
@@ -583,7 +486,7 @@ test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for str
test("reinforceMemory enforces session interval and max guards", () => {
const now = Date.UTC(2026, 3, 29);
const base = entry("reinforce", "Durable memory should reinforce only when gated");
const reinforced = tryReinforceMemory(base, "session-a", now).memory;
const reinforced = reinforceMemory(base, "session-a", now);
assert.notEqual(reinforced, base);
assert.equal(reinforced.reinforcementCount, 1);
@@ -591,15 +494,15 @@ test("reinforceMemory enforces session interval and max guards", () => {
assert.equal(reinforced.lastReinforcedSessionID, "session-a");
assert.equal(reinforced.retentionClock, now);
assert.equal(tryReinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000).memory, reinforced);
assert.equal(tryReinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000).memory, reinforced);
assert.equal(reinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000), reinforced);
assert.equal(reinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000), reinforced);
const atMax: LongTermMemoryEntry = {
...base,
reinforcementCount: 6,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
};
assert.equal(tryReinforceMemory(atMax, "session-c", now).memory, atMax);
assert.equal(reinforceMemory(atMax, "session-c", now), atMax);
});
test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => {
@@ -613,9 +516,9 @@ test("reinforceMemory requires distinct UTC calendar days between reinforcements
lastReinforcedSessionID: "session-a",
};
assert.equal(tryReinforceMemory(base, "session-b", sameUtcDayMuchLater).memory, base);
assert.equal(reinforceMemory(base, "session-b", sameUtcDayMuchLater), base);
const reinforcedNextDay = tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory;
const reinforcedNextDay = reinforceMemory(base, "session-b", nextUtcDayAfterInterval);
assert.notEqual(reinforcedNextDay, base);
assert.equal(reinforcedNextDay.reinforcementCount, 2);
assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval);
@@ -626,7 +529,7 @@ test("reinforceMemory requires distinct UTC calendar days between reinforcements
...base,
reinforcementCount: 6,
};
assert.equal(tryReinforceMemory(atMax, "session-c", nextUtcDayAfterInterval).memory, atMax);
assert.equal(reinforceMemory(atMax, "session-c", nextUtcDayAfterInterval), atMax);
});
test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => {
@@ -659,23 +562,6 @@ test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates",
));
});
test("dedupeLongTermEntriesWithAccounting decision same-identity variants absorb exact only", () => {
const retained = entry("decision-a", "Use pnpm for package management!!!", "decision");
const duplicate = entry("decision-b", "use pnpm for package management", "decision");
assert.notEqual(retained.text, duplicate.text);
assert.equal(workspaceMemoryIdentityKey(retained), workspaceMemoryIdentityKey(duplicate));
assert.equal(workspaceMemoryExactKey(retained), workspaceMemoryExactKey(duplicate));
const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]);
assert.equal(result.kept.length, 1);
assert.equal(result.absorbed.length, 1);
assert.equal(result.absorbed[0].reason, "absorbed_exact");
assert.equal(result.superseded.length, 0);
assert.equal(result.absorbed.some(event => event.reason === "superseded_existing"), false);
});
test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => {
const now = Date.now();
const retained: LongTermMemoryEntry = {
@@ -737,34 +623,7 @@ test("dedupe reinforcement does not increment for same session", () => {
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "same-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "same_session"));
});
test("dedupe blocked reinforcement emits exact block reason details", () => {
const now = Date.now();
const existing: LongTermMemoryEntry = {
...entry("existing-blocked", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 1,
lastReinforcedAt: now - 30 * 60 * 1000,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-blocked", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
assert.ok(blocked, "blocked duplicate reinforcement should emit diagnostic evidence");
assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked"));
assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_interval"));
assert.equal(blocked.details?.blockReason, "min_interval");
assert.equal(blocked.details?.reinforcementCount, 1);
assert.equal(blocked.details?.maxReinforcementCount, 6);
assert.equal(blocked.details?.minIntervalMs, 60 * 60 * 1000);
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
});
test("dedupe reinforcement does not increment under one hour", () => {
@@ -787,8 +646,7 @@ test("dedupe reinforcement does not increment under one hour", () => {
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "old-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "min_interval"));
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
});
test("dedupe reinforcement does not emit evidence at max reinforcement count", () => {
@@ -804,8 +662,7 @@ test("dedupe reinforcement does not emit evidence at max reinforcement count", (
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "max_count"));
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
});
test("enforceLongTermLimits orders entries by retention strength", () => {
@@ -978,15 +835,15 @@ test("mixed retention scenario applies caps and reinforcement ordering", () => {
test("type max sum above global cap still respects maxEntries", () => {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: RETENTION_TYPE_MAX.feedback }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference ${i}`, "feedback")),
...Array.from({ length: RETENTION_TYPE_MAX.decision }, (_, i) => entry(`decision-${i}`, `Unique durable decision ${i}`, "decision")),
...Array.from({ length: RETENTION_TYPE_MAX.project }, (_, i) => entry(`project-${i}`, `Unique project fact ${i}`, "project")),
...Array.from({ length: RETENTION_TYPE_MAX.reference }, (_, i) => entry(`reference-${i}`, `Unique reference fact ${i}`, "reference")),
...Array.from({ length: 10 }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`decision-${i}`, `Unique durable decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`project-${i}`, `Unique project fact ${i}`, "project")),
...Array.from({ length: 6 }, (_, i) => entry(`reference-${i}`, `Unique reference fact ${i}`, "reference")),
];
const kept = enforceLongTermLimits(entries);
assert.equal(entries.length, Object.values(RETENTION_TYPE_MAX).reduce((sum, count) => sum + count, 0));
assert.equal(entries.length, 34);
assert.equal(kept.length, LONG_TERM_LIMITS.maxEntries);
});
@@ -1142,16 +999,6 @@ test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () =
assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading");
});
test("workspaceMemoryIdentityKey returns exact key for feedback entries", () => {
const feedback = entry(
"feedback-exact-identity",
"User prefers references to mention `.opencode/opencode.json` explicitly.",
"feedback",
);
assert.equal(workspaceMemoryIdentityKey(feedback), workspaceMemoryExactKey(feedback));
});
test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => {
const root = "/repo";
const now = new Date().toISOString();
@@ -1182,143 +1029,6 @@ test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounti
assert.equal(result.store.entries[0].text, "Admin PIN 是 [REDACTED]");
});
test("retentionClock backfill: missing clock becomes createdAt timestamp", async () => {
const root = "/repo";
const createdAt = "2026-01-02T03:04:05.000Z";
const memory = {
...entry("backfill-created", "Use durable createdAt for retention backfill", "decision"),
createdAt,
updatedAt: "2026-04-01T03:04:05.000Z",
};
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries: [memory],
updatedAt: createdAt,
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.store.entries[0].retentionClock, new Date(createdAt).getTime());
});
test("retentionClock backfill: invalid createdAt uses updatedAt", async () => {
const root = "/repo";
const updatedAt = "2026-02-03T04:05:06.000Z";
const memory = {
...entry("backfill-updated", "Use updatedAt only when createdAt is invalid", "decision"),
createdAt: "not-a-date",
updatedAt,
};
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries: [memory],
updatedAt,
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.store.entries[0].retentionClock, new Date(updatedAt).getTime());
});
test("retentionClock backfill: both invalid uses Date.now()", async () => {
const root = "/repo";
const before = Date.now();
const memory = {
...entry("backfill-now", "Use current wall clock when stored timestamps are invalid", "decision"),
createdAt: "not-a-date",
updatedAt: "also-not-a-date",
};
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries: [memory],
updatedAt: new Date(before).toISOString(),
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
const after = Date.now();
const retentionClock = result.store.entries[0].retentionClock;
assert.equal(typeof retentionClock, "number");
assert.ok(Number.isFinite(retentionClock));
assert.ok((retentionClock ?? 0) >= before);
assert.ok((retentionClock ?? 0) <= after);
});
test("retentionClock backfill: valid clock is unchanged", async () => {
const root = "/repo";
const createdAt = "2026-01-02T03:04:05.000Z";
const retentionClock = new Date("2025-12-31T00:00:00.000Z").getTime();
const memory = {
...entry("backfill-valid", "Preserve existing valid retention clocks", "decision"),
createdAt,
updatedAt: "2026-04-01T03:04:05.000Z",
retentionClock,
};
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries: [memory],
updatedAt: createdAt,
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.store.entries[0].retentionClock, retentionClock);
});
test("retentionClock backfill: rendered IDs unchanged before and after", async () => {
const root = "/repo";
const oldCreatedAt = "2026-01-01T00:00:00.000Z";
const newCreatedAt = "2026-02-01T00:00:00.000Z";
const entries = [
{
...entry("with-clock", "Decision with a pre-existing retention clock", "decision"),
createdAt: newCreatedAt,
updatedAt: newCreatedAt,
retentionClock: new Date(newCreatedAt).getTime(),
},
{
...entry("missing-clock", "Decision missing a retention clock but with createdAt", "decision"),
createdAt: oldCreatedAt,
updatedAt: "2026-04-01T00:00:00.000Z",
},
];
const baseStore: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries,
updatedAt: newCreatedAt,
};
const prefilledStore: WorkspaceMemoryStore = {
...baseStore,
entries: entries.map(memory => ({
...memory,
retentionClock: memory.retentionClock ?? new Date(memory.createdAt).getTime(),
})),
};
const missingClockResult = await normalizeWorkspaceMemoryWithAccounting(root, baseStore);
const prefilledResult = await normalizeWorkspaceMemoryWithAccounting(root, prefilledStore);
assert.deepEqual(
missingClockResult.kept.map(memory => memory.id),
prefilledResult.kept.map(memory => memory.id),
);
});
test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => {
const root = "/repo";
const now = new Date().toISOString();
@@ -1405,50 +1115,6 @@ test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted
}
});
test("updateWorkspaceMemoryWithAccounting includes migration evidence from pre-update normalization", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-migration-update-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-26T00:00:00.000Z";
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify({
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "update_p0_progress",
type: "project",
text: "Phase 1-4 completed successfully",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
}],
migrations: ["2026-04-28-quality-cleanup"],
updatedAt: now,
}, null, 2), "utf8");
const result = await updateWorkspaceMemoryWithAccounting(root, store => store);
const migrationEvidence = result.evidence.filter(event => event.type === "memory_migration_superseded");
assert.equal(result.store.entries.find(memory => memory.id === "update_p0_progress")?.status, "superseded");
assert.equal(migrationEvidence.length, 1);
assert.deepEqual(migrationEvidence[0].reasonCodes, ["migration:p0_cleanup"]);
assert.equal(migrationEvidence[0].memory?.memoryId, "update_p0_progress");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(sandbox, { recursive: true, force: true });
}
});
// ============================================
// P0d: identity-key dedup, supersession, staleness
// ============================================
@@ -1801,7 +1467,7 @@ test("redactCredentials is idempotent and also redacts rationale text", () => {
})),
},
now,
).store;
);
assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]");
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
});
@@ -1860,69 +1526,14 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o
};
const once = runMigrationP0Cleanup(store, now);
assert.deepEqual(once.store.migrations, ["2026-04-26-p0-cleanup"]);
assert.equal(once.store.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
assert.equal(once.store.entries.find(e => e.id === "project-explicit")?.status, "active");
assert.equal(once.store.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
assert.equal(once.events.length, 1);
assert.equal(once.events[0].type, "memory_migration_superseded");
assert.equal(once.events[0].phase, "storage");
assert.equal(once.events[0].outcome, "superseded");
assert.ok(once.events[0].reasonCodes.includes("migration:p0_cleanup"));
assert.equal(once.events[0].memory?.memoryId, "project-snapshot");
assert.deepEqual(once.migrations, ["2026-04-26-p0-cleanup"]);
assert.equal(once.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
assert.equal(once.entries.find(e => e.id === "project-explicit")?.status, "active");
assert.equal(once.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
const twice = runMigrationP0Cleanup(once.store, later);
assert.deepEqual(twice.store.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
assert.equal(twice.store.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.store.entries.find(e => e.id === "project-snapshot")?.updatedAt);
assert.equal(twice.events.length, 0);
});
test("loadWorkspaceMemory appends P0 migration evidence once", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-p0-evidence-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-26T00:00:00.000Z";
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify({
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "p0_progress",
type: "project",
text: "Phase 1-4 completed successfully",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
}],
migrations: ["2026-04-28-quality-cleanup"],
updatedAt: now,
}, null, 2), "utf8");
const firstLoad = await loadWorkspaceMemory(root);
await loadWorkspaceMemory(root);
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
assert.equal(firstLoad.entries.find(memory => memory.id === "p0_progress")?.status, "superseded");
assert.equal(events.length, 1);
assert.equal(events[0].type, "memory_migration_superseded");
assert.equal(events[0].phase, "storage");
assert.equal(events[0].outcome, "superseded");
assert.deepEqual(events[0].reasonCodes, ["migration:p0_cleanup"]);
assert.equal(events[0].memory?.memoryId, "p0_progress");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(sandbox, { recursive: true, force: true });
}
const twice = runMigrationP0Cleanup(once, later);
assert.deepEqual(twice.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
});
test("quality cleanup migration preserves soft-only feedback and decision violations", async () => {
@@ -2049,56 +1660,6 @@ test("quality cleanup migration writes audit log for hard supersedes", async ()
}
});
test("quality cleanup migration appends superseded evidence with hard reasons", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-evidence-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-28T00:00:00.000Z";
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify({
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "quality_progress",
type: "project",
text: "Test suite: 1237 tests pass, 226 suites",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
staleAfterDays: 60,
}],
migrations: [],
updatedAt: now,
}, null, 2), "utf8");
const loaded = await loadWorkspaceMemory(root);
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
assert.equal(loaded.entries.find(memory => memory.id === "quality_progress")?.status, "superseded");
assert.equal(events.length, 1);
assert.equal(events[0].type, "memory_migration_superseded");
assert.equal(events[0].phase, "storage");
assert.equal(events[0].outcome, "superseded");
assert.ok(events[0].reasonCodes.includes("migration:quality_cleanup"));
assert.ok(events[0].reasonCodes.includes("quality:progress_snapshot"));
assert.equal(events[0].memory?.memoryId, "quality_progress");
assert.equal(events[0].memory?.status, "superseded");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(sandbox, { recursive: true, force: true });
}
});
test("quality cleanup migration aborts supersede when audit log cannot be written", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-"));
const dataHome = join(sandbox, "xdg-data-home");
@@ -2618,163 +2179,3 @@ test("loadWorkspaceMemory normalizes and persists credentials from legacy unreda
await rm(sandbox, { recursive: true, force: true });
}
});
function decisionEntry(id: string, text: string, timestampMs: number): LongTermMemoryEntry {
const timestamp = new Date(timestampMs).toISOString();
return {
id,
type: "decision",
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
retentionClock: timestampMs,
};
}
test("enforceLongTermLimitsWithAccounting keeps 11th and 12th decisions and type-caps the 13th", () => {
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
const thirtyDaysAgo = now - 30 * DAY_MS;
const existingDecisions = Array.from({ length: 10 }, (_, i) =>
decisionEntry(
`existing-decision-${i}`,
`Existing durable architecture decision ${i}`,
thirtyDaysAgo,
)
);
const newDecisions = Array.from({ length: 3 }, (_, i) =>
decisionEntry(
`new-decision-${i}`,
`Newer durable architecture decision ${i}`,
now,
)
);
const result = enforceLongTermLimitsWithAccounting([...existingDecisions, ...newDecisions]);
const capacityDrops = result.dropped.filter(event => event.reason === "rejected_capacity");
const droppedIds = new Set(capacityDrops.map(event => event.memory.id));
const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity");
assert.equal(result.kept.filter(memory => memory.type === "decision").length, RETENTION_TYPE_MAX.decision);
assert.equal(result.kept.some(memory => memory.id === "new-decision-0"), true);
assert.equal(result.kept.some(memory => memory.id === "new-decision-1"), true);
assert.equal(result.kept.some(memory => memory.id === "new-decision-2"), true);
assert.equal(capacityDrops.length, 1);
assert.equal(capacityEvidence.length, 1);
for (const event of capacityEvidence) {
assert.equal(event.phase, "storage");
assert.equal(event.outcome, "removed");
assert.ok(event.memory?.memoryId, "capacity evidence should include removed memory id");
assert.ok(event.memory.memoryKeyHash, "capacity evidence should include removed memory key hash");
assert.ok(event.memory.identityKeyHash, "capacity evidence should include removed identity key hash");
assert.equal(droppedIds.has(event.memory.memoryId), true);
assert.ok(event.relations?.[0]?.memory.memoryKeyHash, "removed relation should include memory key hash");
assert.ok(event.relations?.[0]?.memory.identityKeyHash, "removed relation should include identity key hash");
assert.deepEqual(event.reasonCodes, ["type_cap"]);
assert.equal(typeof event.details?.strengthAtRemoval, "number");
assert.equal(typeof event.details?.rankAtRemoval, "number");
assert.equal(typeof event.details?.typeRankAtRemoval, "number");
assert.equal(typeof event.details?.ageDaysAtRemoval, "number");
}
});
test("enforceLongTermLimitsWithAccounting emits global_cap evidence for global cap losers", () => {
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: RETENTION_TYPE_MAX.feedback }, (_, i) =>
decisionEntry(`global-feedback-${i}`, `Global cap feedback ${i}`, now)
).map(memory => ({ ...memory, type: "feedback" as const })),
...Array.from({ length: RETENTION_TYPE_MAX.decision }, (_, i) =>
decisionEntry(`global-decision-${i}`, `Global cap decision ${i}`, now)
),
...Array.from({ length: RETENTION_TYPE_MAX.project }, (_, i) =>
decisionEntry(`global-project-${i}`, `Global cap project ${i}`, now)
).map(memory => ({ ...memory, type: "project" as const })),
...Array.from({ length: RETENTION_TYPE_MAX.reference }, (_, i) =>
decisionEntry(`global-reference-${i}`, `Global cap reference ${i}`, now)
).map(memory => ({ ...memory, type: "reference" as const })),
];
const result = enforceLongTermLimitsWithAccounting(entries);
const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity");
const globalCapEvidence = capacityEvidence.filter(event => event.reasonCodes.includes("global_cap"));
assert.equal(entries.length, Object.values(RETENTION_TYPE_MAX).reduce((sum, count) => sum + count, 0));
assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries);
assert.equal(globalCapEvidence.length, entries.length - LONG_TERM_LIMITS.maxEntries);
assert.equal(capacityEvidence.some(event => event.reasonCodes.includes("type_cap")), false);
assert.ok(globalCapEvidence.every(event => event.phase === "storage" && event.outcome === "removed"));
});
test("accountWorkspaceMemoryRender emits render_omitted for type_cap with 13 decisions", () => {
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: Array.from({ length: 13 }, (_, i) =>
decisionEntry(`render-decision-${i}`, `Render durable decision ${i}`, now)
),
updatedAt: new Date(now).toISOString(),
lastActivityAt: new Date(now).toISOString(),
};
const accounting = accountWorkspaceMemoryRender(store);
const typeCapOmissions = accounting.omitted.filter(item => item.reason === "type_cap");
const typeCapEvidence = accounting.evidence.filter(event =>
event.type === "render_omitted" && event.reasonCodes.includes("type_cap")
);
assert.equal(accounting.rendered.length, RETENTION_TYPE_MAX.decision);
assert.equal(typeCapOmissions.length, 1);
assert.equal(typeCapEvidence.length, 1);
});
test("accountWorkspaceMemoryRender records char-budget omissions when 12 decisions crowd other types", () => {
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
const longText = (prefix: string, i: number) => `${prefix} ${i}: ${"durable architecture context ".repeat(8)}`;
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 2 }, (_, i) => ({
...decisionEntry(`crowd-feedback-${i}`, longText("User feedback preference", i), now),
type: "feedback" as const,
source: "explicit" as const,
})),
...Array.from({ length: 2 }, (_, i) => ({
...decisionEntry(`crowd-project-${i}`, longText("Project fact", i), now),
type: "project" as const,
})),
...Array.from({ length: RETENTION_TYPE_MAX.decision }, (_, i) =>
decisionEntry(`crowd-decision-${i}`, longText("Decision rule", i), now)
),
...Array.from({ length: 2 }, (_, i) => ({
...decisionEntry(`crowd-reference-${i}`, longText("Reference fact", i), now),
type: "reference" as const,
})),
];
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries,
updatedAt: new Date(now).toISOString(),
lastActivityAt: new Date(now).toISOString(),
};
const accounting = accountWorkspaceMemoryRender(store);
const charBudgetOmissions = accounting.omitted.filter(item => item.reason === "char_budget");
const charBudgetEvidence = accounting.evidence.filter(event =>
event.type === "render_omitted" && event.reasonCodes.includes("char_budget")
);
const omittedIds = new Set(charBudgetOmissions.map(item => item.memory.id));
const evidenceIds = new Set(charBudgetEvidence.map(event => event.memory?.memoryId).filter(Boolean));
assert.ok(accounting.prompt.length <= LONG_TERM_LIMITS.maxRenderedChars);
assert.equal(accounting.omitted.some(item => item.reason === "type_cap"), false);
assert.equal(accounting.omitted.some(item => item.reason === "global_cap"), false);
assert.ok(charBudgetOmissions.length > 0, "crowded render should omit by char budget");
assert.ok(charBudgetOmissions.some(item => item.memory.type === "reference"), "later non-decision types should be accounted if crowded out");
assert.equal(charBudgetEvidence.length, charBudgetOmissions.length);
assert.deepEqual(evidenceIds, omittedIds);
});
+1 -1
View File
@@ -25,6 +25,6 @@
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["index.ts", "src/**/*.ts", "scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts"],
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
-27
View File
@@ -1,27 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"noEmit": false,
"outDir": "dist",
"rootDir": ".",
"declaration": false,
"sourceMap": false,
"rewriteRelativeImportExtensions": true,
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts", "src/**/*.ts"],
"exclude": [
"node_modules",
"dist",
"tests",
"src/plugin.ts",
"src/tui-plugin.ts",
"src/opencode.ts",
"src/session-state.ts",
"src/extractors.ts",
"src/pending-journal.ts",
"src/promotion-accounting.ts",
"src/memory-visibility.ts"
]
}