Compare commits

..

54 Commits

Author SHA1 Message Date
Ralph Chang f9bdcdd785 chore(release): prepare v1.5.3 2026-05-02 20:49:23 +08:00
Ralph Chang cf05b9fa69 feat(memory-diag): publish diagnostics CLI 2026-05-02 20:36:58 +08:00
Ralph Chang aaa4016ae8 feat(reinforcement): compaction prompt wording reuse, migration evidence, and validation baseline
Wave 1 — Compaction prompt improvement:
- Add three wording-reuse bullets to buildCompactionPrompt() under
  CRITICAL MEMORY RULES: do not create rephrased duplicates, reuse
  existing wording exactly when re-emitting, only emit new memories
  when the fact is new, materially corrected, or more specific.
- This attacks the root cause of zero reinforcement: compaction
  generating variant text for the same durable fact.

Wave 2 — Bug fixes:
- Bug #2: Add placeholder comment to superseded_existing branch in
  decision dedupe (unreachable until v1.5.4 numbered refs). Preserve
  as const type assertions.
- Bug #3: Add memory_migration_superseded evidence event type. Both
  P0 and quality cleanup migrations now produce evidence events for
  superseded entries. loadWorkspaceMemory appends migration evidence
  on first-load migrations only (idempotent via migration IDs). No
  historical backfill.
- Bug #4: Add documentation comment explaining that feedback identity
  key returns exact key (absorbed_identity currently impossible for
  feedback). Add test verifying this behavior.

Wave 3 — Validation baseline script:
- Add scripts/dev/validate-identity-keys.ts: read-only script that
  scans workspace memory stores, computes exact/identity key
  collisions, and reports reinforcement statistics. Baseline matches
  audit: 0 exact collisions, 0 identity collisions, 0 reinforcement
  events across 123 active memories.

Identity extension is gated on measurement: if the prompt change
produces measurable reinforcement (reinforcementCount > 0), identity
extension may be unnecessary. Decision dedupe stays exact-only
(Wave 4 deferred).
2026-05-02 15:03:34 +08:00
Ralph Chang ff5c568cb7 chore(release): prepare v1.5.2 2026-05-01 16:00:44 +08:00
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
26 changed files with 979 additions and 672 deletions
-35
View File
@@ -5,41 +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.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
+2 -4
View File
@@ -85,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.
-59
View File
@@ -1,64 +1,5 @@
# Release Notes
## 1.5.5 (2026-05-05)
### Hot State Rendering Health
This release replaces blunt prompt truncation with a section-aware greedy renderer that fits whole lines and suppresses empty sections, plus render accounting types for future diagnostics.
> **Good rendering is selective too.** If a section doesn't fit, omit it — don't cut it in half.
### What Changed
- **Whole-line rendering**: the hot session state prompt no longer truncates mid-line with `.slice(0, maxRenderedChars)`. A greedy line accumulator fits complete lines and stops when the next line would exceed the 700-char budget.
- **Header-only sections suppressed**: a section heading is only rendered if at least one entry fits alongside it. No more orphaned `active_files:` headers with no content underneath.
- **Render accounting**: `accountHotSessionStateRender()` now returns `prompt`, `omitted[]`, and `maxRenderedChars` so future v2 diagnostics can surface which items fell out and why.
- **Two-layer omission model**: section caps (`section_cap`) trim per-type overflow first, then the char budget (`char_budget`) trims anything that doesn't fit. Each omitted item carries its reason and section.
- **Backward-compatible wrapper**: `renderHotSessionState()` delegates to the accounting function and returns just the prompt string, preserving existing plugin behavior.
### Docs Fixes
- **Active-file ranking formula corrected**: `docs/configuration.md` now matches the actual implementation — `ACTION_WEIGHT[action] + count * 3` with weights edit 50, write 45, grep 30, read 20, tie-break by `lastSeen` descending. The old docs claimed `count * action_weight * recency_decay` with weights 4/3/2/1.
- **Injection order clarified**: README now explains that workspace memory and hot state system-prompt positions depend on whether workspace memory state is available.
### Not Included Yet
- Evidence events for omitted items are deferred to v2, pending a `memory-diag` consumer to avoid unused storage litter.
### Upgrade Notes
- No configuration changes required.
- Existing workspace memory files remain compatible.
- Hot session state rendering behavior is intentionally different (cleaner output, no mid-line cuts), but the same information is presented when it fits within the budget.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 356 tests passing, `TEST_PASS`
---
## 1.5.4 (2026-05-02)
### Memory Diagnostics Surface Cleanup
This cleanup release keeps the newly published `memory-diag` CLI small before legacy spellings become compatibility debt.
### What Changed
- **Official commands only**: the public CLI surface is `status`, `rejected`, `missing`, and `explain`.
- **Pre-public aliases removed**: old spellings such as `health`, `quality`, `rejections`, `disappearances`, and `trace` are no longer recognized.
- **Maintainer diagnostics clarified**: hidden `coverage` and `audit` commands remain available as maintainer-only diagnostics and stay out of public usage output.
- **Cleaner internals**: current command metadata now has one source of truth, and legacy command/formatter wrappers were removed.
- **Internal cleanup script hidden**: the workspace cleanup helper is no longer exposed as an npm script; the underlying development tool remains in `scripts/dev/`.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 347 tests passing, `TEST_PASS`
---
## 1.5.3 (2026-05-02)
### Published Memory Diagnostics CLI
+6 -23
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
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-working-memory",
"version": "1.5.5",
"version": "1.5.3",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
@@ -24,6 +24,7 @@
"diag": "node --experimental-strip-types scripts/memory-diag.ts",
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
},
"keywords": [
+66 -15
View File
@@ -1,5 +1,4 @@
import { HIDDEN_COMMAND_NOTICES, isCommand } from "./command-metadata.ts";
import type { CliOptions, Command, ParsedArgs } from "./types.ts";
import type { CliOptions, Command, LegacyCommand, ParsedArgs } from "./types.ts";
export type { ParsedArgs } from "./types.ts";
@@ -22,6 +21,23 @@ function error(message: string): ParsedArgs {
return { ok: false, message, usage: usage(), exitCode: 1 };
}
function isCommand(value: string | undefined): value is Command {
return value === "status"
|| value === "rejected"
|| value === "missing"
|| value === "explain"
|| value === "coverage"
|| value === "audit";
}
function isLegacyCommand(value: string | undefined): value is LegacyCommand {
return value === "health"
|| value === "quality"
|| value === "rejections"
|| value === "disappearances"
|| value === "trace";
}
function isValidSince(rawSince: string): boolean {
if (/^(\d+)([dhm])$/i.test(rawSince)) return true;
return !Number.isNaN(new Date(rawSince).getTime());
@@ -34,19 +50,44 @@ export function parseArgs(argv: string[]): ParsedArgs {
}
let command: Command = "status";
let legacyCommand: LegacyCommand | undefined;
let deprecationNotice: string | undefined;
let rest = argv;
if (first && !first.startsWith("--")) {
rest = tail;
if (isCommand(first)) {
command = first;
deprecationNotice = HIDDEN_COMMAND_NOTICES[first];
if (first === "coverage") {
deprecationNotice = "Note: 'coverage' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit evidence'.";
} else if (first === "audit") {
deprecationNotice = "Note: 'audit' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit migrations'.";
}
} else if (isLegacyCommand(first)) {
legacyCommand = first;
if (first === "health") {
command = "status";
deprecationNotice = "Note: 'health' is now 'status'. This alias will be removed in v2.0.";
} else if (first === "quality") {
command = "status";
deprecationNotice = "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0.";
} else if (first === "rejections") {
command = "rejected";
deprecationNotice = "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0.";
} else if (first === "disappearances") {
command = "missing";
deprecationNotice = "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0.";
} else {
command = "explain";
deprecationNotice = "Note: 'trace --memory <id>' is now 'explain <memory-id>'. This alias will be removed in v2.0.";
}
} else {
return error(`Unknown subcommand: ${first}`);
}
}
const options: CliOptions = { raw: false, positional: [] };
if (legacyCommand) options.legacyCommand = legacyCommand;
if (legacyCommand === "quality") options.verbose = true;
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (arg === "--raw") options.raw = true;
@@ -57,6 +98,8 @@ export function parseArgs(argv: string[]): ParsedArgs {
else if (arg === "--soft-only") options.softOnly = true;
else if (arg === "--trigger-only") options.triggerOnly = true;
else if (arg === "--include-historical") options.includeHistorical = true;
else if (arg === "--quality") options.quality = true;
else if (arg === "--unique") options.unique = true;
else if (arg === "--explain") options.explain = true;
else if (arg === "--workspace") {
const value = rest[++i];
@@ -78,13 +121,14 @@ export function parseArgs(argv: string[]): ParsedArgs {
const value = rest[++i];
if (!value) return error("--memory requires an id");
options.memory = value;
} else if (!arg.startsWith("--") && command === "explain") {
} else if (!arg.startsWith("--") && command === "explain" && legacyCommand !== "trace") {
options.positional?.push(arg);
} else {
return error(`Unknown option: ${arg}`);
}
}
const displayCommand = legacyCommand ?? command;
if (command === "explain") {
const positional = options.positional ?? [];
if (positional.length > 1) return error("explain accepts at most one memory id");
@@ -94,27 +138,34 @@ export function parseArgs(argv: string[]): ParsedArgs {
return error(`Unknown option: ${options.positional?.[0]}`);
}
if (command === "status") {
if (options.all) return error(`${command} does not accept --all`);
if (legacyCommand === "health") {
if (options.all && options.workspace) return error("Use either --all or --workspace, not both");
if (options.json && options.all) return error("health --json does not support --all");
} else if (command === "status") {
if (options.all) return error(`${displayCommand} does not accept --all`);
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain") {
if (options.all) return error(`${command} does not accept --all`);
if (options.all) return error(`${displayCommand} does not accept --all`);
} else {
if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`);
if (options.all || options.workspace) return error(`${displayCommand} does not accept --all or --workspace`);
}
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage") {
return error(`${command} does not accept --json`);
return error(`${displayCommand} does not accept --json`);
}
if (legacyCommand === "rejections" && options.json && !options.quality) return error("rejections --json requires --quality");
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
return error(`${command} does not accept rejection filters`);
return error(`${displayCommand} does not accept rejection filters`);
}
if (command !== "coverage" && options.includeHistorical) return error(`${command} does not accept --include-historical`);
if (command !== "rejected" && options.reason) return error(`${command} does not accept rejection filters`);
if (command !== "missing" && options.explain) return error(`${command} does not accept --explain`);
if (command !== "coverage" && options.includeHistorical) return error(`${displayCommand} does not accept --include-historical`);
if (command !== "rejected" && (options.quality || options.reason || options.unique)) return error(`${displayCommand} does not accept rejection quality filters`);
if (command !== "missing" && options.explain) return error(`${displayCommand} does not accept --explain`);
if (command !== "audit" && options.migration) {
return error(`${command} does not accept --migration`);
return error(`${displayCommand} does not accept --migration`);
}
if (legacyCommand === "trace" && !options.memory) {
return error("--memory requires an id");
}
if (command !== "explain" && options.memory) {
return error(`${command} does not accept --memory`);
return error(`${displayCommand} does not accept --memory`);
}
if (command === "rejected" && options.since && !isValidSince(options.since)) {
return error(`Invalid --since value: ${options.since}`);
-16
View File
@@ -1,16 +0,0 @@
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain"] 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);
}
+11
View File
@@ -1,12 +1,23 @@
import { runAudit } from "./commands/audit.ts";
import { runCoverage } from "./commands/coverage.ts";
import { runDisappearances } from "./commands/disappearances.ts";
import { runExplain } from "./commands/explain.ts";
import { runHealth } from "./commands/health.ts";
import { runMissing } from "./commands/missing.ts";
import { runQuality } from "./commands/quality.ts";
import { runRejected } from "./commands/rejected.ts";
import { runRejections } from "./commands/rejections.ts";
import { runStatus } from "./commands/status.ts";
import { runTrace } from "./commands/trace.ts";
import type { CliOptions, Command, CommandResult } from "./types.ts";
export async function dispatch(command: Command, options: CliOptions): Promise<CommandResult> {
if (options.legacyCommand === "health") return runHealth(options);
if (options.legacyCommand === "quality") return runQuality(options);
if (options.legacyCommand === "rejections") return runRejections(options);
if (options.legacyCommand === "disappearances") return runDisappearances(options);
if (options.legacyCommand === "trace") return runTrace(options);
switch (command) {
case "status": return runStatus(options);
case "rejected": return runRejected(options);
@@ -0,0 +1,14 @@
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
import { buildDisappearancesJSON, formatDisappearances } from "../formatters/disappearances.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runDisappearances(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const rows = disappearanceRows(model);
if (options.json) {
return { stdout: JSON.stringify(buildDisappearancesJSON(rows, { explain: options.explain }), null, 2) };
}
return { stdout: formatDisappearances(rows, { explain: options.explain }) };
}
+2 -10
View File
@@ -1,18 +1,10 @@
import { traceMemoryLifecycle } from "../../../src/evidence-log.ts";
import { formatExplain } from "../formatters/explain.ts";
import { formatTrace } from "../formatters/trace.ts";
import { snapshotForOptions } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult } from "../types.ts";
import { runTrace } from "./trace.ts";
export async function runExplain(options: CliOptions): Promise<CommandResult> {
if (options.memory) {
const root = options.workspace ?? process.cwd();
const [snapshot, trace] = await Promise.all([
snapshotForOptions(options),
traceMemoryLifecycle(root, { memoryId: options.memory }),
]);
return { stdout: formatTrace(options.memory, snapshot, trace) };
}
if (options.memory) return runTrace(options);
const snapshot = await snapshotForOptions(options);
return { stdout: formatExplain(snapshot) };
+59
View File
@@ -0,0 +1,59 @@
import { join } from "node:path";
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../../../src/paths.ts";
import { scanWorkspaceResidues } from "../../../src/workspace-cleanup.ts";
import type { PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts";
import { formatWorkspaceHealth, type WorkspaceHealthInput } from "../formatters/health.ts";
import { pathExists, readJSONFile } from "../io.ts";
import { buildMemoryDiagJSON } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult } from "../types.ts";
type WorkspaceHealthCommandInput = Omit<WorkspaceHealthInput, "now">;
async function workspaceHealthOutput(input: WorkspaceHealthCommandInput): Promise<string> {
return formatWorkspaceHealth({ ...input, now: Date.now() }, {
rawStore: await readJSONFile<WorkspaceMemoryStore>(input.memoryPath),
rawJournal: await readJSONFile<PendingMemoryJournalStore>(input.pendingPath),
pendingExists: pathExists(input.pendingPath),
});
}
export async function runHealth(options: CliOptions): Promise<CommandResult> {
if (options.json) {
const root = options.workspace ?? process.cwd();
return { stdout: JSON.stringify(await buildMemoryDiagJSON(root), null, 2) };
}
if (options.all) {
const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 });
const lines: string[] = ["Workspace memory health", ""];
if (scan.results.length === 0) {
lines.push("No workspace stores found.");
return { stdout: lines.join("\n") };
}
for (let i = 0; i < scan.results.length; i += 1) {
const result = scan.results[i];
if (i > 0) lines.push("");
lines.push(await workspaceHealthOutput({
root: result.root,
key: result.workspaceKey,
memoryPath: join(result.workspaceDir, "workspace-memory.json"),
pendingPath: join(result.workspaceDir, "workspace-pending-journal.json"),
raw: options.raw,
}));
}
return { stdout: lines.join("\n") };
}
const root = options.workspace ?? process.cwd();
const key = await workspaceKey(root);
return {
stdout: await workspaceHealthOutput({
root,
key,
memoryPath: await workspaceMemoryPath(root),
pendingPath: await workspacePendingJournalPath(root),
raw: options.raw,
includeTitle: true,
}),
};
}
+14
View File
@@ -0,0 +1,14 @@
import { buildInspectionReadModel } from "../inspection-model.ts";
import { buildQualityJSON, formatQuality } from "../formatters/quality.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runQuality(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const now = Date.now();
if (options.json) {
return { stdout: JSON.stringify(buildQualityJSON(model, new Date(now).toISOString(), now), null, 2) };
}
return { stdout: formatQuality(model, now) };
}
@@ -0,0 +1,19 @@
import { formatRejections, formatRejectionQuality, buildRejectionQualityJSON } from "../formatters/rejections.ts";
import { loadRejectionRecords, rejectionFalsePositiveRisk, rejectionQualitySummary, uniqueByCanonicalText } from "../rejections-model.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runRejections(options: CliOptions): Promise<CommandResult> {
const { path, invalidLines, records } = await loadRejectionRecords(options);
const normalized = options.unique ? uniqueByCanonicalText(records) : records;
if (options.quality) {
const summary = rejectionQualitySummary(records);
if (options.json) {
return { stdout: JSON.stringify({ ...buildRejectionQualityJSON(summary), falsePositiveRisk: rejectionFalsePositiveRisk(summary) }, null, 2) };
}
return { stdout: formatRejectionQuality({ path, invalidLines, summary, raw: options.raw }) };
}
return { stdout: formatRejections({ path, invalidLines, records: normalized, raw: options.raw }) };
}
+3
View File
@@ -6,6 +6,7 @@ import { retentionCandidatesForDiag, retentionClockSummary, daysSinceIso } from
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import { isWithinDays, memoryDiagJSONFromSnapshot } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult, MemoryInspectionReadModel } from "../types.ts";
import { runHealth } from "./health.ts";
export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.now()): StatusReadout {
const active = model.store.entries.filter(entry => entry.status !== "superseded");
@@ -80,6 +81,8 @@ export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.
}
export async function runStatus(options: CliOptions): Promise<CommandResult> {
if (options.legacyCommand === "health" && options.all) return runHealth(options);
const now = Date.now();
const model = await buildInspectionReadModel(options);
const readout = buildStatusReadout(model, now);
+16
View File
@@ -0,0 +1,16 @@
import { traceMemoryLifecycle } from "../../../src/evidence-log.ts";
import { formatTrace } from "../formatters/trace.ts";
import { snapshotForOptions } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runTrace(options: CliOptions): Promise<CommandResult> {
const root = options.workspace ?? process.cwd();
const memoryId = options.memory;
if (!memoryId) return { stdout: "", stderr: "--memory requires an id", exitCode: 1 };
const [snapshot, trace] = await Promise.all([
snapshotForOptions(options),
traceMemoryLifecycle(root, { memoryId }),
]);
return { stdout: formatTrace(memoryId, snapshot, trace) };
}
@@ -0,0 +1,45 @@
import type { disappearanceRows } from "../inspection-model.ts";
import { eventCounts } from "../inspection-model.ts";
import { formatDetails } from "../text.ts";
export type DisappearanceRows = ReturnType<typeof disappearanceRows>;
export function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
return {
version: 1,
generatedAt: options.generatedAt ?? new Date().toISOString(),
disappearances: rows.map(row => ({
id: row.id,
classification: row.classification,
terminalType: row.terminalType,
reasonCodes: row.reasonCodes,
eventCounts: eventCounts(row.events),
details: options.explain ? row.event?.details : undefined,
})),
};
}
export function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string {
const lines: string[] = [];
lines.push("Memory disappearances");
lines.push("");
if (rows.length === 0) {
lines.push("No evidence-only memories found.");
return lines.join("\n");
}
for (const row of rows) {
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`);
if (options.explain) {
lines.push(` events: ${row.events.map(event => event.type).join(", ")}`);
if (row.event?.type === "memory_removed_capacity") {
lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`);
}
const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"));
if (renderTypeCap) {
lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`);
}
}
}
return lines.join("\n");
}
+193
View File
@@ -0,0 +1,193 @@
import { assessMemoryQuality } from "../../../src/memory-quality.ts";
import {
DORMANT_DECAY_MULTIPLIER,
RETENTION_TYPE_MAX,
WORKSPACE_DORMANT_AFTER_DAYS,
} from "../../../src/retention.ts";
import { renderWorkspaceMemory } from "../../../src/workspace-memory.ts";
import type { LongTermSource, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts";
import { LONG_TERM_LIMITS } from "../../../src/types.ts";
import { SUSPICIOUS_REASONS, TYPES } from "../constants.ts";
import {
ageDays,
daysSinceIso,
formatStrength,
isSafetyCriticalForDiag,
promotionLimit,
retentionCandidatesForDiag,
} from "../retention-model.ts";
import {
canonicalMemoryText,
cleanPath,
cleanText,
countBy,
formatPercent,
formatWorkspaceIdentity,
truncate,
} from "../text.ts";
import { normalizedJournal, normalizedStore } from "../workspace-snapshot.ts";
export type WorkspaceHealthInput = {
root?: string;
key: string;
memoryPath: string;
pendingPath: string;
raw: boolean;
now: number;
includeTitle?: boolean;
};
export type WorkspaceHealthLoadedData = {
rawStore: WorkspaceMemoryStore | null;
rawJournal: PendingMemoryJournalStore | null;
pendingExists: boolean;
};
export function formatWorkspaceHealth(input: WorkspaceHealthInput, loadedData: WorkspaceHealthLoadedData): string {
const lines: string[] = [];
if (input.includeTitle) {
lines.push("Workspace memory health");
lines.push("");
}
const rawStore = loadedData.rawStore;
const storeRoot = rawStore?.workspace?.root ?? input.root ?? "";
const storeKey = rawStore?.workspace?.key ?? input.key;
const store = normalizedStore(rawStore, storeRoot, storeKey);
const journal = normalizedJournal(loadedData.rawJournal);
const identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw);
if (identity) lines.push(identity);
lines.push(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`);
lines.push(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`);
if (!rawStore) lines.push("memory store: missing or unreadable (treated as empty)");
if (!loadedData.pendingExists) lines.push("pending journal: missing (treated as empty)");
lines.push("");
const active = store.entries.filter(entry => entry.status !== "superseded");
const superseded = store.entries.filter(entry => entry.status === "superseded");
const retention = retentionCandidatesForDiag(store, input.now);
const renderedEntries = retention.rendered.map(item => item.entry);
const renderedEstimate = renderWorkspaceMemory(store).length;
lines.push(`Stored active memories: ${active.length}`);
lines.push(`Superseded memories: ${superseded.length}`);
lines.push(`Rendered candidates: ${renderedEntries.length}`);
lines.push(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
lines.push("");
const pendingEntries = journal.entries;
const retryable = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) < promotionLimit(entry.source)).length;
const nearRetryLimit = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) >= promotionLimit(entry.source) - 1).length;
const pendingBySource = countBy(pendingEntries.map(entry => entry.source));
lines.push("Pending journal:");
lines.push(` total: ${pendingEntries.length}`);
lines.push(` retryable: ${retryable}`);
lines.push(` near retry limit: ${nearRetryLimit}`);
lines.push(" by source:");
for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) {
lines.push(` ${source}: ${pendingBySource.get(source) ?? 0}`);
}
lines.push("");
lines.push("By type:");
for (const type of TYPES) {
const storedCount = active.filter(entry => entry.type === type).length;
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
const supersededCount = superseded.filter(entry => entry.type === type).length;
lines.push(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${RETENTION_TYPE_MAX[type]} superseded=${supersededCount}`);
}
lines.push("");
lines.push("Retention caps:");
lines.push(` type-capped entries: ${retention.typeCapped.length}`);
lines.push(` global-cap overflow: ${retention.globalCapped.length}`);
lines.push("");
const olderThan30 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 30).length;
const olderThan90 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 90).length;
const staleMarked = active.filter(entry => {
const days = ageDays(entry, input.now);
return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays);
}).length;
lines.push("Age:");
lines.push(` stale-marked: ${staleMarked}`);
lines.push(` older than 30d: ${olderThan30}`);
lines.push(` older than 90d: ${olderThan90}`);
lines.push("");
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt, input.now);
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS;
const dormantDaysPastGrace = wallDaysSinceActivity === null
? 0
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
lines.push("Dormancy:");
lines.push(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
lines.push(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
lines.push(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
lines.push(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
lines.push(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`);
lines.push("");
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
const highImportanceAlert = highImportanceRatio > 0.3;
const safetyCriticalWarning = safetyCriticalCount > 0;
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
lines.push("Retention monitoring:");
lines.push(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
lines.push(` safety_critical_count: ${safetyCriticalCount} (deprecated field)${safetyCriticalWarning ? " WARNING" : ""}`);
lines.push(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
lines.push("");
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
lines.push("Quality warnings:");
lines.push(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`);
lines.push(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`);
lines.push(` duplicate-ish exact canonical text: ${duplicateExtras}`);
lines.push(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`);
lines.push("");
lines.push("Suspicious active memories:");
for (const reason of SUSPICIOUS_REASONS) {
lines.push(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`);
}
const failingQuality = qualityByEntry.filter(item => !item.quality.accepted);
if (failingQuality.length > 0) {
lines.push("");
lines.push("Active memories failing offline quality checks:");
for (const item of failingQuality.slice(0, 8)) {
lines.push(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`);
}
}
lines.push("");
lines.push("Top rendered candidates:");
const top = retention.rendered.slice(0, 5);
if (top.length === 0) {
lines.push(" (none)");
} else {
for (const item of top) {
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
lines.push("");
lines.push("Weakest active memories:");
const weakest = retention.sorted.slice(-5).reverse();
if (weakest.length === 0) {
lines.push(" (none)");
} else {
for (const item of weakest) {
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
return lines.join("\n");
}
+1 -45
View File
@@ -1,8 +1,4 @@
import type { disappearanceRows } from "../inspection-model.ts";
import { eventCounts } from "../inspection-model.ts";
import { formatDetails } from "../text.ts";
export type DisappearanceRows = ReturnType<typeof disappearanceRows>;
import { buildDisappearancesJSON, formatDisappearances, type DisappearanceRows } from "./disappearances.ts";
export type MissingSummary = {
total: number;
@@ -25,46 +21,6 @@ export function buildMissingJSON(rows: DisappearanceRows, options: { explain?: b
};
}
function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
return {
version: 1,
generatedAt: options.generatedAt ?? new Date().toISOString(),
disappearances: rows.map(row => ({
id: row.id,
classification: row.classification,
terminalType: row.terminalType,
reasonCodes: row.reasonCodes,
eventCounts: eventCounts(row.events),
details: options.explain ? row.event?.details : undefined,
})),
};
}
function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string {
const lines: string[] = [];
lines.push("Memory disappearances");
lines.push("");
if (rows.length === 0) {
lines.push("No evidence-only memories found.");
return lines.join("\n");
}
for (const row of rows) {
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`);
if (options.explain) {
lines.push(` events: ${row.events.map(event => event.type).join(", ")}`);
if (row.event?.type === "memory_removed_capacity") {
lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`);
}
const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"));
if (renderTypeCap) {
lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`);
}
}
}
return lines.join("\n");
}
export function formatMissing(rows: DisappearanceRows, options: { verbose?: boolean; explain?: boolean } = {}): string {
const summary = missingSummary(rows);
const lines: string[] = [];
+119
View File
@@ -0,0 +1,119 @@
import { RETENTION_TYPE_MAX } from "../../../src/retention.ts";
import { TYPES } from "../constants.ts";
import { disappearanceRows } from "../inspection-model.ts";
import { retentionCandidatesForDiag, retentionClockSummary } from "../retention-model.ts";
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import type { MemoryInspectionReadModel } from "../types.ts";
export function buildQualityJSON(model: MemoryInspectionReadModel, generatedAt = new Date().toISOString(), now = new Date(generatedAt).getTime()): Record<string, unknown> {
const active = model.store.entries.filter(entry => entry.status !== "superseded");
const retention = retentionCandidatesForDiag(model.store, now);
const clocks = retentionClockSummary(active);
const disappearances = disappearanceRows(model);
const evidenceCovered = active.filter(entry => (model.evidenceByMemoryId.get(entry.id) ?? []).length > 0).length;
const rejectionSummary = rejectionQualitySummary(model.rejectionRecords);
const falsePositiveRisk = rejectionFalsePositiveRisk(rejectionSummary);
const typeCounts = Object.fromEntries(TYPES.map(type => [type, active.filter(entry => entry.type === type).length]));
const capsFull = active.length >= model.store.limits.maxEntries || TYPES.some(type => (typeCounts[type] ?? 0) >= RETENTION_TYPE_MAX[type]);
const unknownDisappearances = disappearances.filter(row => row.classification === "historical_absent_unknown_reason").length;
const status = unknownDisappearances > 0 || clocks.invalid > 0
? "degraded"
: capsFull || rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high"
? "warning"
: "ok";
const summaryText = `Summary: Workspace memory quality is ${status}: ${active.length} active memories, ${evidenceCovered}/${active.length} with evidence, ${disappearances.length} evidence-only disappearances (${unknownDisappearances} unknown), ${clocks.invalid} invalid retention clocks, and ${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records.`;
const caps = {
active: active.length,
maxEntries: model.store.limits.maxEntries,
rendered: retention.rendered.length,
typeCapped: retention.typeCapped.length,
globalCapped: retention.globalCapped.length,
typeCounts,
capsFull,
};
const evidence = {
currentWithEvidence: evidenceCovered,
currentWithoutEvidence: active.length - evidenceCovered,
evidenceMemoryIds: model.evidenceByMemoryId.size,
disappearances: disappearances.length,
unknownDisappearances,
withTerminalReason: disappearances.length - unknownDisappearances,
};
return {
version: 1,
generatedAt,
status,
summaryText,
store: { active: active.length, pending: model.pending.entries.length, superseded: model.store.entries.length - active.length },
caps,
retention: clocks,
evidence,
rejections: { ...rejectionSummary, falsePositiveRisk },
};
}
export function formatQuality(model: MemoryInspectionReadModel, now: number): string {
const data = buildQualityJSON(model, new Date(now).toISOString(), now) as {
summaryText: string;
caps: {
active: number;
maxEntries: number;
rendered: number;
typeCapped: number;
globalCapped: number;
typeCounts: Record<string, number>;
capsFull: boolean;
};
retention: { present: number; missing: number; invalid: number };
evidence: {
currentWithEvidence: number;
currentWithoutEvidence: number;
evidenceMemoryIds: number;
disappearances: number;
unknownDisappearances: number;
};
rejections: {
totalRecords: number;
workspaceScopedCount: number;
legacyUnscopedCount: number;
falsePositiveRisk: string;
};
};
const lines: string[] = [];
lines.push("Memory quality inspection");
lines.push("");
lines.push(data.summaryText);
lines.push("");
lines.push("Caps:");
lines.push(` active: ${data.caps.active} / ${data.caps.maxEntries}`);
for (const type of TYPES) {
const count = data.caps.typeCounts[type] ?? 0;
const limit = RETENTION_TYPE_MAX[type];
const marker = count >= limit ? " FULL" : "";
lines.push(` ${type}: ${count} / ${limit}${marker}`);
}
lines.push(` rendered: ${data.caps.rendered}`);
lines.push(` type-capped entries: ${data.caps.typeCapped}`);
lines.push(` global-cap overflow: ${data.caps.globalCapped}`);
lines.push(` caps full: ${data.caps.capsFull ? "yes" : "no"}`);
lines.push("");
lines.push("Retention clocks:");
lines.push(` present: ${data.retention.present}`);
lines.push(` missing: ${data.retention.missing}`);
lines.push(` invalid: ${data.retention.invalid}`);
lines.push("");
lines.push("Evidence:");
lines.push(` current with evidence: ${data.evidence.currentWithEvidence}`);
lines.push(` current without evidence: ${data.evidence.currentWithoutEvidence}`);
lines.push(` evidence memory ids: ${data.evidence.evidenceMemoryIds}`);
lines.push(` disappearances: ${data.evidence.disappearances}`);
lines.push(` unknown disappearances: ${data.evidence.unknownDisappearances}`);
lines.push("");
lines.push("Rejection scoping:");
lines.push(` total records: ${data.rejections.totalRecords}`);
lines.push(` workspace scoped: ${data.rejections.workspaceScopedCount}`);
lines.push(` legacy unscoped: ${data.rejections.legacyUnscopedCount}`);
lines.push(` false-positive risk: ${data.rejections.falsePositiveRisk}`);
return lines.join("\n");
}
@@ -0,0 +1,98 @@
import { hasSoftReason, rejectionQualitySummary } from "../rejections-model.ts";
import { cleanPath, cleanText, countBy, formatWorkspaceIdentity, sortedCounts, truncate } from "../text.ts";
import type { NormalizedRejection } from "../types.ts";
export type RejectionQualitySummary = ReturnType<typeof rejectionQualitySummary>;
export function buildRejectionQualityJSON(summary: RejectionQualitySummary, generatedAt = new Date().toISOString()): Record<string, unknown> {
return {
version: 1,
generatedAt,
...summary,
};
}
export function formatRejections(input: {
path: string;
invalidLines: number;
records: NormalizedRejection[];
raw: boolean;
}): string {
const lines: string[] = [];
lines.push("Extraction rejection summary");
lines.push("");
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
lines.push("");
lines.push(`Total rejected: ${input.records.length}`);
lines.push("");
lines.push("By reason:");
const byReason = sortedCounts(countBy(input.records.flatMap(record => record.reasons)));
if (byReason.length === 0) lines.push(" (none)");
else for (const [reason, count] of byReason) lines.push(` ${reason.padEnd(24)} ${count}`);
lines.push("");
lines.push("By origin:");
const byOrigin = sortedCounts(countBy(input.records.map(record => record.origin)));
if (byOrigin.length === 0) lines.push(" (none)");
else for (const [origin, count] of byOrigin) lines.push(` ${origin.padEnd(24)} ${count}`);
lines.push("");
lines.push("Trigger-origin rejections (high priority for v1.5):");
const triggerReasons = sortedCounts(countBy(input.records.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons)));
if (triggerReasons.length === 0) lines.push(" (none)");
else for (const [reason, count] of triggerReasons) lines.push(` ${reason.padEnd(24)} ${count}`);
lines.push("");
lines.push("Recent suspicious soft rejects:");
const suspicious = input.records
.filter(hasSoftReason)
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
.slice(0, 8);
if (suspicious.length === 0) {
lines.push(" (none)");
} else {
for (const record of suspicious) {
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, input.raw);
lines.push(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, input.raw)))}`);
lines.push(` reasons: ${record.reasons.join(",")}`);
lines.push(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`);
}
}
return lines.join("\n");
}
export function formatRejectionQuality(input: {
path: string;
invalidLines: number;
summary: RejectionQualitySummary;
raw: boolean;
}): string {
const lines: string[] = [];
lines.push("Extraction rejection quality inspection");
lines.push("");
lines.push("Possible false-positive grouping is heuristic, not deterministic truth.");
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
lines.push("");
lines.push(`Total records: ${input.summary.totalRecords}`);
lines.push(`Unique texts: ${input.summary.uniqueTexts}`);
lines.push(`Workspace scoped: ${input.summary.workspaceScopedCount}`);
lines.push(`Legacy unscoped: ${input.summary.legacyUnscopedCount}`);
lines.push("");
lines.push("Reason distribution (raw records):");
for (const [reason, count] of Object.entries(input.summary.reasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
if (Object.keys(input.summary.reasonDistribution).length === 0) lines.push(" (none)");
lines.push("");
lines.push("Reason distribution (unique text):");
for (const [reason, count] of Object.entries(input.summary.uniqueReasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
if (Object.keys(input.summary.uniqueReasonDistribution).length === 0) lines.push(" (none)");
lines.push("");
lines.push("Possible false-positive groups (heuristic, not deterministic):");
for (const [group, data] of Object.entries(input.summary.possibleFalsePositiveGroups)) {
lines.push(` ${group}: ${data.count}`);
for (const sample of data.samples) lines.push(` - ${JSON.stringify(cleanText(sample, input.raw))}`);
}
return lines.join("\n");
}
+5 -3
View File
@@ -1,8 +1,5 @@
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome } from "../../src/evidence-log.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts";
import type { Command } from "./command-metadata.ts";
export type { Command, HiddenCommand, VisibleCommand } from "./command-metadata.ts";
export type MemoryRenderStatus =
| "rendered"
@@ -50,6 +47,8 @@ export type MemoryDiagJSON = {
}>;
};
export type Command = "status" | "rejected" | "missing" | "explain" | "coverage" | "audit";
export type LegacyCommand = "health" | "quality" | "rejections" | "disappearances" | "trace";
export type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
export type CliOptions = {
@@ -62,11 +61,14 @@ export type CliOptions = {
softOnly?: boolean;
triggerOnly?: boolean;
includeHistorical?: boolean;
quality?: boolean;
reason?: string;
unique?: boolean;
explain?: boolean;
since?: string;
migration?: string;
memory?: string;
legacyCommand?: LegacyCommand;
positional?: string[];
auditMode?: "coverage" | "migrations";
};
+34 -135
View File
@@ -189,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[] {
+75 -36
View File
@@ -2,16 +2,14 @@ import test from "node:test";
import assert from "node:assert/strict";
import { parseArgs } from "../scripts/memory-diag/cli.ts";
test("help returns usage without exposing hidden or removed commands", () => {
test("help returns usage without exiting", () => {
const parsed = parseArgs(["--help"]);
assert.equal(parsed.ok, true);
assert.equal("help" in parsed && parsed.help, true);
assert.match(parsed.usage, /Usage:/);
assert.match(parsed.usage, /memory-diag \[status\]/);
for (const command of ["health", "quality", "rejections", "disappearances", "trace", "coverage", "audit"]) {
assert.doesNotMatch(parsed.usage, new RegExp(command));
}
assert.doesNotMatch(parsed.usage, /health/);
});
test("status defaults when no subcommand", () => {
@@ -32,41 +30,17 @@ test("unknown command returns usage error", () => {
assert.match(parsed.usage, /Usage:/);
});
test("removed legacy aliases are ordinary unknown subcommands", () => {
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
const parsed = parseArgs([command]);
assert.equal(parsed.ok, false, command);
if (parsed.ok) continue;
assert.equal(parsed.message, `Unknown subcommand: ${command}`);
assert.match(parsed.usage, /Usage:/);
}
});
test("hidden maintainer commands are accepted with neutral notices", () => {
const coverage = parseArgs(["coverage"]);
assert.equal(coverage.ok, true);
assert.equal("command" in coverage && coverage.command, "coverage");
assert.equal("deprecationNotice" in coverage && coverage.deprecationNotice, "Note: 'coverage' is a maintainer-only diagnostic and is not part of the public CLI surface.");
const audit = parseArgs(["audit"]);
assert.equal(audit.ok, true);
assert.equal("command" in audit && audit.command, "audit");
assert.equal("deprecationNotice" in audit && audit.deprecationNotice, "Note: 'audit' is a maintainer-only diagnostic and is not part of the public CLI surface.");
});
test("current command flag validation messages are preserved", () => {
const cases: Array<{ args: string[]; message: string }> = [
{ args: ["status", "--all"], message: "status does not accept --all" },
{ args: ["health", "--json", "--all"], message: "health --json does not support --all" },
{ args: ["quality", "--all"], message: "quality does not accept --all" },
{ args: ["coverage", "--all"], message: "coverage does not accept --all" },
{ args: ["missing", "--all"], message: "missing does not accept --all" },
{ args: ["rejected", "--all"], message: "rejected does not accept --all" },
{ args: ["disappearances", "--all"], message: "disappearances does not accept --all" },
{ args: ["rejections", "--all"], message: "rejections does not accept --all" },
{ args: ["audit", "--workspace", "/tmp/workspace"], message: "audit does not accept --all or --workspace" },
{ args: ["explain", "--all"], message: "explain does not accept --all" },
{ args: ["status", "--since", "forever"], message: "status does not accept rejection filters" },
{ args: ["status", "--reason", "bad_decision"], message: "status does not accept rejection filters" },
{ args: ["status", "--quality"], message: "Unknown option: --quality" },
{ args: ["rejected", "--unique"], message: "Unknown option: --unique" },
{ args: ["trace", "--all", "--memory", "mem-1"], message: "trace does not accept --all" },
{ args: ["quality", "--since", "forever"], message: "quality does not accept rejection filters" },
];
for (const item of cases) {
@@ -78,8 +52,26 @@ test("current command flag validation messages are preserved", () => {
}
});
test("rejected invalid since value returns current error", () => {
const parsed = parseArgs(["rejected", "--since", "forever"]);
test("trace without memory returns current required id error", () => {
const parsed = parseArgs(["trace"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
assert.equal(parsed.message, "--memory requires an id");
assert.match(parsed.usage, /Usage:/);
});
test("health with all and workspace returns current conflict error", () => {
const parsed = parseArgs(["health", "--all", "--workspace", "/tmp/workspace"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
assert.equal(parsed.message, "Use either --all or --workspace, not both");
assert.match(parsed.usage, /Usage:/);
});
test("rejections invalid since value returns current error", () => {
const parsed = parseArgs(["rejections", "--since", "forever"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
@@ -87,6 +79,53 @@ test("rejected invalid since value returns current error", () => {
assert.match(parsed.usage, /Usage:/);
});
test("legacy health alias emits deprecation notice and maps to status", () => {
const parsed = parseArgs(["health"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "status");
assert.equal("options" in parsed && parsed.options.legacyCommand, "health");
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'health' is now 'status'. This alias will be removed in v2.0.");
});
test("legacy quality alias sets verbose and emits deprecation notice", () => {
const parsed = parseArgs(["quality"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "status");
assert.equal("options" in parsed && parsed.options.verbose, true);
assert.equal("options" in parsed && parsed.options.legacyCommand, "quality");
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0.");
});
test("legacy rejections alias emits deprecation notice", () => {
const parsed = parseArgs(["rejections"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "rejected");
assert.equal("options" in parsed && parsed.options.legacyCommand, "rejections");
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0.");
});
test("legacy disappearances alias emits deprecation notice", () => {
const parsed = parseArgs(["disappearances"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "missing");
assert.equal("options" in parsed && parsed.options.legacyCommand, "disappearances");
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0.");
});
test("legacy trace alias emits deprecation notice", () => {
const parsed = parseArgs(["trace", "--memory", "mem-1"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "explain");
assert.equal("options" in parsed && parsed.options.legacyCommand, "trace");
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'trace --memory <id>' is now 'explain <memory-id>'. This alias will be removed in v2.0.");
});
test("explain accepts positional memory id", () => {
const parsed = parseArgs(["explain", "mem-1", "--workspace", "/tmp/workspace"]);
+38 -21
View File
@@ -2,11 +2,15 @@ import test from "node:test";
import assert from "node:assert/strict";
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
import type { MemoryInspectionReadModel, WorkspaceDiagSnapshot } from "../scripts/memory-diag/types.ts";
import { formatWorkspaceHealth } from "../scripts/memory-diag/formatters/health.ts";
import { formatQuality } from "../scripts/memory-diag/formatters/quality.ts";
import { formatCoverage } from "../scripts/memory-diag/formatters/coverage.ts";
import { formatDisappearances } from "../scripts/memory-diag/formatters/disappearances.ts";
import { formatRejectionQuality } from "../scripts/memory-diag/formatters/rejections.ts";
import { formatMigrationAudit } from "../scripts/memory-diag/formatters/audit.ts";
import { formatExplain } from "../scripts/memory-diag/formatters/explain.ts";
import { buildMissingJSON, formatMissing } from "../scripts/memory-diag/formatters/missing.ts";
import { formatTrace } from "../scripts/memory-diag/formatters/trace.ts";
import { rejectionQualitySummary } from "../scripts/memory-diag/rejections-model.ts";
function emptyInspectionModel(): MemoryInspectionReadModel {
return {
@@ -56,6 +60,36 @@ function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType;
};
}
test("health formatter includes existing retention cap label", () => {
const output = formatWorkspaceHealth({
root: "/tmp/workspace",
key: "workspace-key",
memoryPath: "/tmp/workspace-memory.json",
pendingPath: "/tmp/workspace-pending-journal.json",
raw: false,
now: new Date("2026-01-01T00:00:00.000Z").getTime(),
includeTitle: true,
}, { rawStore: null, rawJournal: null, pendingExists: false });
assert.match(output, /Workspace memory health/);
assert.match(output, /Retention caps:/);
});
test("quality formatter includes caps and retention clock sections", () => {
const output = formatQuality(emptyInspectionModel(), new Date("2026-01-01T00:00:00.000Z").getTime());
assert.match(output, /Caps:/);
assert.match(output, /Retention clocks:/);
});
test("rejection quality formatter includes reason distribution sections", () => {
const summary = rejectionQualitySummary([]);
const output = formatRejectionQuality({ path: "/tmp/rejections.jsonl", invalidLines: 0, summary, raw: false });
assert.match(output, /Reason distribution \(raw records\):/);
assert.match(output, /Reason distribution \(unique text\):/);
});
test("coverage formatter includes class counts section", () => {
const output = formatCoverage([]);
@@ -63,27 +97,10 @@ test("coverage formatter includes class counts section", () => {
assert.match(output, /Per-memory rows:\n \(none\)/);
});
test("missing formatter verbose output preserves disappearance details", () => {
const output = formatMissing([], { verbose: true });
test("disappearances formatter preserves empty-state label", () => {
const output = formatDisappearances([]);
assert.match(output, /No missing memories found\./);
});
test("missing JSON includes disappearance rows and summary", () => {
const rows = [{
id: "historical-1",
classification: "historical_absent_unknown_reason" as const,
terminalType: "unknown" as const,
reasonCodes: [],
events: [event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted" })],
event: undefined,
}];
const output = buildMissingJSON(rows, { generatedAt: "2026-01-01T00:00:00.000Z" });
assert.equal(output.version, 1);
assert.deepEqual(output.summary, { total: 1, explained: 0, needsReview: 1 });
assert.deepEqual((output.disappearances as Array<{ id: string }>)[0]?.id, "historical-1");
assert.match(output, /No evidence-only memories found\./);
});
test("trace formatter includes lifecycle section", () => {
+157 -59
View File
@@ -47,8 +47,8 @@ async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[],
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
async function runMemoryDiagStatusVerbose(root: string): Promise<string> {
return runMemoryDiag(["status", "--workspace", root, "--verbose"]);
async function runMemoryDiagHealth(root: string): Promise<string> {
return runMemoryDiag(["health", "--workspace", root]);
}
async function runMemoryDiag(args: string[]): Promise<string> {
@@ -96,26 +96,27 @@ function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
};
}
test("status handles missing workspace store as empty", async () => {
test("health handles missing workspace store as empty", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-"));
try {
const stdout = await runMemoryDiag(["status", "--workspace", root]);
const stdout = await runMemoryDiag(["health", "--workspace", root]);
assert.match(stdout, /Memory status/);
assert.match(stdout, /active memories: 0/);
assert.match(stdout, /pending: 0/);
assert.match(stdout, /memory store: missing or unreadable \(treated as empty\)/);
assert.match(stdout, /pending journal: missing \(treated as empty\)/);
assert.match(stdout, /Stored active memories: 0/);
assert.match(stdout, /Pending journal:\n\s+total: 0/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("status verbose handles missing workspace store as empty", async () => {
test("quality handles missing workspace store as empty", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-quality-"));
try {
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
const stdout = await runMemoryDiag(["quality", "--workspace", root]);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /Caps:/);
assert.match(stdout, /Memory quality inspection/);
assert.match(stdout, /0 active memories/);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -135,15 +136,15 @@ test("coverage and disappearances handle missing workspace store as empty", asyn
}
});
test("status with unsupported all flag shows usage error", async () => {
test("health with conflicting flags shows usage error", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-conflicting-flags-"));
try {
await assert.rejects(
runMemoryDiagResult(["status", "--all", "--workspace", root]),
runMemoryDiagResult(["health", "--all", "--workspace", root]),
(error: unknown) => {
const err = error as { code?: number; stderr?: string };
assert.notEqual(err.code, 0);
assert.match(err.stderr ?? "", /status does not accept --all/);
assert.match(err.stderr ?? "", /Use either --all or --workspace, not both/);
assert.match(err.stderr ?? "", /Usage:/);
return true;
},
@@ -184,21 +185,26 @@ test("memory-diag defaults to status when no subcommand is supplied", async () =
assert.equal(result.stderr, "");
});
test("removed legacy aliases return unknown subcommand", async () => {
test("legacy health alias emits deprecation notice and still runs", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-"));
try {
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
await assert.rejects(
runMemoryDiagResult([command, "--workspace", root]),
(error: unknown) => {
const err = error as { code?: number; stderr?: string };
assert.notEqual(err.code, 0);
assert.match(err.stderr ?? "", new RegExp(`Unknown subcommand: ${command}`));
assert.match(err.stderr ?? "", /Usage:/);
return true;
},
);
}
const result = await runMemoryDiagResult(["health", "--workspace", root]);
assert.match(result.stdout, /Workspace memory health/);
assert.match(result.stderr, /Note: 'health' is now 'status'\. This alias will be removed in v2\.0\./);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("legacy trace alias emits deprecation notice and still traces memory", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-trace-"));
try {
const result = await runMemoryDiagResult(["trace", "--memory", "test-id", "--workspace", root]);
assert.match(result.stdout, /Memory test-id: unknown/);
assert.match(result.stdout, /Lifecycle:/);
assert.match(result.stderr, /Note: 'trace --memory <id>' is now 'explain <memory-id>'\. This alias will be removed in v2\.0\./);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -213,12 +219,11 @@ test("memory health reports stored vs rendered retention counts", async () => {
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagStatusVerbose(root);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /active: 28 \/ 28/);
assert.match(stdout, /rendered: 20/);
assert.match(stdout, /feedback: 17 \/ 10 FULL/);
assert.match(stdout, /Stored active memories:/);
assert.match(stdout, /Rendered candidates:/);
assert.match(stdout, /feedback\s+stored=17\s+rendered=10/);
assert.match(stdout, /Top rendered candidates:\n\s+- strength=/);
} finally {
await rm(root, { recursive: true, force: true });
@@ -237,14 +242,15 @@ test("memory health reports dormancy and retention monitoring deprecations", asy
}));
await writeWorkspaceStore(root, entries, { lastActivityAt });
const stdout = await runMemoryDiagStatusVerbose(root);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Dormancy:/);
assert.match(stdout, /wall days since activity: 19\.0/);
assert.match(stdout, /dormant discount active: yes/);
assert.match(stdout, /dormant days past grace: 5\.0/);
assert.match(stdout, /Top rendered candidates:/);
assert.match(stdout, /Weakest active memories:/);
assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
assert.match(stdout, /safety_critical_count: 6 .*deprecated.* WARNING/);
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -261,9 +267,9 @@ test("memory health reports global cap overflow separately from type caps", asyn
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagStatusVerbose(root);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /rendered: 28/);
assert.match(stdout, /Rendered candidates: 28/);
assert.match(stdout, /type-capped entries: 0/);
assert.match(stdout, /global-cap overflow: 6/);
} finally {
@@ -276,13 +282,14 @@ test("memory health reports missing dormancy and non-alert monitoring defaults",
try {
await writeWorkspaceStore(root, [], { omitLastActivityAt: true });
const stdout = await runMemoryDiagStatusVerbose(root);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /lastActivityAt: \(missing\)/);
assert.match(stdout, /wall days since activity: unknown/);
assert.match(stdout, /dormant discount active: no/);
assert.match(stdout, /Top rendered candidates:\n\s+\(none\)/);
assert.match(stdout, /Weakest active memories:\n\s+\(none\)/);
assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
assert.match(stdout, /safety_critical_count: 0 \(deprecated field\)\n/);
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -304,8 +311,8 @@ test("memory health --json prints parseable privacy-safe diagnostics matching hu
evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["within_caps", "within_char_budget"] }),
]);
const human = await runMemoryDiagStatusVerbose(root);
const jsonText = await runMemoryDiag(["status", "--workspace", root, "--json"]);
const human = await runMemoryDiagHealth(root);
const jsonText = await runMemoryDiag(["health", "--workspace", root, "--json"]);
const parsed = JSON.parse(jsonText) as {
version: 1;
summary: { storedActive: number; rendered: number; pending: number; rejectedLast7Days: number; corruptStoresQuarantinedLast30Days: number };
@@ -314,9 +321,9 @@ test("memory health --json prints parseable privacy-safe diagnostics matching hu
};
assert.equal(parsed.version, 1);
assert.equal(parsed.summary.storedActive, Number(human.match(/active: (\d+) \/ 28/)?.[1]));
assert.equal(parsed.summary.rendered, Number(human.match(/rendered: (\d+)/)?.[1]));
assert.equal(parsed.summary.pending, 1);
assert.equal(parsed.summary.storedActive, Number(human.match(/Stored active memories: (\d+)/)?.[1]));
assert.equal(parsed.summary.rendered, Number(human.match(/Rendered candidates: (\d+)/)?.[1]));
assert.equal(parsed.summary.pending, Number(human.match(/Pending journal:\n\s+total: (\d+)/)?.[1]));
assert.equal(parsed.summary.rejectedLast7Days, 1);
assert.equal(parsed.summary.corruptStoresQuarantinedLast30Days, 1);
assert.ok(parsed.recentEvents.some(event => event.eventId && event.type === "render_selected" && event.outcome === "rendered" && event.createdAt && event.reasonCodes.includes("within_caps")));
@@ -376,7 +383,7 @@ test("memory-diag explain shows rendered, omitted, pending, and evidence reason
}
});
test("memory-diag explain --memory prints lifecycle relations and redacts secrets", async () => {
test("memory-diag trace prints lifecycle relations and redacts secrets", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-"));
try {
await writeWorkspaceStore(root, [
@@ -392,7 +399,7 @@ test("memory-diag explain --memory prints lifecycle relations and redacts secret
evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "omitted", memory: { memoryId: "mem-life" } }], reasonCodes: ["superseded"] }),
]);
const stdout = await runMemoryDiag(["explain", "--workspace", root, "--memory", "mem-life"]);
const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "mem-life"]);
assert.match(stdout, /Memory mem-life: omitted_superseded/);
assert.match(stdout, /Lifecycle:/);
@@ -432,10 +439,21 @@ test("memory-diag explain positional memory id prints lifecycle", async () => {
}
});
test("memory-diag explain --memory reports unknown IDs", async () => {
test("memory-diag trace requires --memory and reports unknown IDs", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-"));
try {
const stdout = await runMemoryDiag(["explain", "--workspace", root, "--memory", "missing-memory"]);
await assert.rejects(
execFileAsync(process.execPath, ["--experimental-strip-types", "scripts/memory-diag.ts", "trace", "--workspace", root], { cwd: repoRoot }),
(error: unknown) => {
const err = error as { code?: number; stderr?: string };
assert.notEqual(err.code, 0);
assert.match(err.stderr ?? "", /--memory requires an id/);
assert.match(err.stderr ?? "", /Usage:/);
return true;
},
);
const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "missing-memory"]);
assert.match(stdout, /Memory missing-memory: unknown/);
assert.match(stdout, /Lifecycle:\n\(none\)/);
} finally {
@@ -463,12 +481,12 @@ async function setupQualityFixture(): Promise<string> {
return root;
}
test("status verbose output includes summary and aggregate inspection counts", async () => {
test("quality human output includes summary and aggregate inspection counts", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
const stdout = await runMemoryDiag(["quality", "--workspace", root]);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /Memory quality inspection/);
assert.match(stdout, /Summary: Workspace memory quality is degraded:/);
assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/);
assert.match(stdout, /decision: 14 \/ 10 FULL/);
@@ -481,18 +499,26 @@ test("status verbose output includes summary and aggregate inspection counts", a
}
});
test("status --json includes summary status fields", async () => {
test("quality --json includes summaryText, caps, retention, evidence, and rejections", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]);
const stdout = await runMemoryDiag(["quality", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as {
summary: { storedActive: number; status: string; evidenceCoveragePercent: number; needsAttention: string[] };
summaryText: string;
caps: { active: number; capsFull: boolean };
retention: { invalid: number };
evidence: { currentWithEvidence: number; unknownDisappearances: number };
rejections: { workspaceScopedCount: number; legacyUnscopedCount: number };
};
assert.equal(parsed.summary.storedActive, LONG_TERM_LIMITS.maxEntries);
assert.equal(parsed.summary.status, "degraded");
assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number");
assert.ok(parsed.summary.needsAttention.length > 0);
assert.match(parsed.summaryText, /Workspace memory quality is degraded/);
assert.equal(parsed.caps.active, LONG_TERM_LIMITS.maxEntries);
assert.equal(parsed.caps.capsFull, true);
assert.equal(parsed.retention.invalid, 1);
assert.equal(parsed.evidence.currentWithEvidence, 1);
assert.equal(parsed.evidence.unknownDisappearances, 1);
assert.equal(parsed.rejections.workspaceScopedCount, 1);
assert.equal(parsed.rejections.legacyUnscopedCount, 1);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -706,6 +732,25 @@ test("missing --json includes disappearances and additive summary", async () =>
}
});
test("legacy disappearances --explain emits deprecation notice and detailed output", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-legacy-"));
try {
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
await appendEvidenceEvents(root, [
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
]);
const result = await runMemoryDiagResult(["disappearances", "--workspace", root, "--explain"]);
assert.match(result.stderr, /Note: 'disappearances' is now 'missing'\. This alias will be removed in v2\.0\./);
assert.match(result.stdout, /^Memory disappearances/);
assert.match(result.stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/);
assert.match(result.stdout, /events:/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("rejected default output is concise with top reasons and samples", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-"));
try {
@@ -777,3 +822,56 @@ test("rejected --json includes quality summary and false-positive risk", async (
await rm(root, { recursive: true, force: true });
}
});
test("rejections --quality --reason bad_decision --unique groups architecture-like samples heuristically", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-quality-"));
try {
const key = await workspaceKey(root);
const now = new Date().toISOString();
await writeRejectionRecords([
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] },
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] },
{ timestamp: now, type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] },
]);
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--reason", "bad_decision", "--unique"]);
assert.match(stdout, /Extraction rejection quality inspection/);
assert.match(stdout, /Possible false-positive grouping is heuristic, not deterministic truth/);
assert.match(stdout, /architecture_like_possible_false_positive: 1/);
assert.match(stdout, /clearly_garbage: 1/);
assert.match(stdout, /ambiguous: 1/);
assert.doesNotMatch(stdout, /deterministic truth\s*:/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("rejections --quality --json includes scoping, unique reasons, and possible false-positive groups", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-json-"));
try {
const key = await workspaceKey(root);
const now = new Date().toISOString();
await writeRejectionRecords([
{ timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
{ timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] },
]);
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as {
workspaceScopedCount: number;
legacyUnscopedCount: number;
falsePositiveRisk: string;
uniqueReasonDistribution: Record<string, number>;
possibleFalsePositiveGroups: Record<string, { count: number }>;
};
assert.equal(parsed.workspaceScopedCount, 1);
assert.equal(parsed.legacyUnscopedCount, 1);
assert.equal(parsed.falsePositiveRisk, "high");
assert.equal(parsed.uniqueReasonDistribution.bad_decision, 1);
assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
} finally {
await rm(root, { recursive: true, force: true });
}
});
-210
View File
@@ -1,210 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as sessionStateModule from "../src/session-state.ts";
import type { HotSessionStateRenderAccounting } from "../src/session-state.ts";
import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "../src/types.ts";
import { HOT_STATE_LIMITS } from "../src/types.ts";
type AccountHotSessionStateRender = (state: SessionState, workspaceRoot: string) => HotSessionStateRenderAccounting;
const accountHotSessionStateRender = (
sessionStateModule as typeof sessionStateModule & { accountHotSessionStateRender: AccountHotSessionStateRender }
).accountHotSessionStateRender;
const { createEmptySessionState, renderHotSessionState } = 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: [],
...overrides,
};
}
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");
});