Compare commits

..

51 Commits

Author SHA1 Message Date
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
50 changed files with 1699 additions and 4146 deletions
-55
View File
@@ -5,61 +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/), 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). 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
- Published the read-only `memory-diag` package binary for package-qualified `npx --package opencode-working-memory memory-diag` usage.
- Added user-facing diagnostics commands for concise status, rejected memory review, missing memory review, and per-memory explanation.
### Changed
- Reworked the diagnostics CLI into focused command, model, formatter, and utility modules while preserving legacy aliases and maintainer diagnostics.
- Updated README and configuration docs to document the supported `memory-diag` CLI workflow and package-qualified `npx` usage.
- Raised the Node.js requirement to `>=22.6.0` for the TypeScript diagnostics CLI runtime.
### Fixed
- Avoided repeated workspace snapshot construction in status diagnostics.
- Replaced per-memory evidence summary lookups with grouped evidence processing to avoid N+1 diagnostics work.
- Added top-level CLI error handling for cleaner user-facing failures.
- Removed a diagnostics model circular dependency by extracting evidence grouping helpers to a neutral module.
## [1.5.1] - 2026-04-30 ## [1.5.1] - 2026-04-30
### Added ### Added
+23 -29
View File
@@ -85,13 +85,11 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls.
▼ ▼
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
│ ⚡ Prompt Context │ │ ⚡ Prompt Context │
│ system[1]*: frozen workspace memory │ │ system[1]: frozen workspace memory │
│ system[2+]*: hot session state │ │ 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. **Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted. **Cache-friendly layout:** durable workspace memory 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.
@@ -202,35 +200,30 @@ The goal is to remember durable facts, not every detail.
Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y". Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y".
### Memory Diagnostics CLI For local development cleanup, use:
Use the read-only diagnostics CLI when you want to understand what memory is doing for the current workspace.
| Question | Command |
|---|---|
| Is memory healthy? | `npx --package opencode-working-memory memory-diag` or `npx --package opencode-working-memory memory-diag status` |
| Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` |
| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` |
| Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain <memory-id>` |
Global options:
- `--workspace <path>` — inspect another workspace; defaults to the current directory.
- `--verbose` — show detailed diagnostics.
- `--json` — print machine-readable output where supported.
Examples:
```bash ```bash
npx --package opencode-working-memory memory-diag status npm run cleanup:workspaces -- --dry-run
npx --package opencode-working-memory memory-diag rejected --verbose npm run cleanup:workspaces -- --quarantine
npx --package opencode-working-memory memory-diag missing --workspace /path/to/project
npx --package opencode-working-memory memory-diag status --json
``` ```
The npm package is opencode-working-memory; the installed bin is memory-diag, so package-qualified npx avoids resolving a different package named memory-diag. The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
Maintainer-only diagnostics and cleanup commands are intentionally not documented here. Future work: move those internal commands to `docs/development.md`. ### Local Inspection CLI
Maintainers can run read-only inspection surfaces without telemetry or mutation. Human output redacts absolute paths and credentials by default; pass `--raw` only when you intentionally need local paths.
```bash
bun scripts/memory-diag.ts quality --workspace /path/to/repo
bun scripts/memory-diag.ts coverage --workspace /path/to/repo --include-historical
bun scripts/memory-diag.ts disappearances --workspace /path/to/repo --explain
bun scripts/memory-diag.ts rejections --quality --reason bad_decision --unique
```
- `quality` summarizes store caps, retention clocks, evidence coverage, disappearances, and rejection scoping.
- `coverage` classifies per-memory evidence lifecycle coverage, optionally including historical evidence-only memory IDs.
- `disappearances --explain` reports evidence memory IDs absent from the current store with terminal capacity, promotion, supersession, or render-omission context when available.
- `rejections --quality` groups rejection records by scope, reason distribution, and heuristic possible false-positive buckets.
## Configuration ## Configuration
@@ -268,12 +261,13 @@ cd opencode-working-memory
npm install npm install
npm test npm test
npm run typecheck npm run typecheck
npm run cleanup:workspaces -- --dry-run
``` ```
## Requirements ## Requirements
- OpenCode plugin API `>=1.2.0 <2.0.0` - OpenCode plugin API `>=1.2.0 <2.0.0`
- Node.js >= 22.6.0 (for `memory-diag` CLI, which runs TypeScript with `--experimental-strip-types`) - Node.js >= 18.0.0
## Limitations ## Limitations
-88
View File
@@ -1,93 +1,5 @@
# Release Notes # 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
This release makes the read-only workspace memory diagnostics CLI available as the package binary `memory-diag`, with user-facing commands for checking memory health and understanding why memories are rejected, missing, shown, or hidden.
> Good memory is selective memory — and diagnostics should explain the selection.
### What Changed
- **Published CLI bin**: run diagnostics with `npx --package opencode-working-memory memory-diag` or `memory-diag status`.
- **User-facing commands**: `status`, `rejected`, `missing`, and `explain <memory-id>` are documented as the supported public workflow.
- **Legacy compatibility**: existing maintainer and legacy commands remain accepted with deprecation notices where applicable.
- **Cleaner CLI architecture**: the former monolithic diagnostics script is now split into focused command, model, formatter, and utility modules.
- **Faster diagnostics**: evidence grouping avoids repeated per-memory evidence queries in snapshot diagnostics.
- **Cleaner failures**: top-level CLI error handling now reports usage and unexpected command errors without noisy stack traces.
- **Docs alignment**: README and configuration docs now use package-qualified `npx` commands to avoid resolving an unrelated package named `memory-diag`.
### Requirements
- Node.js `>=22.6.0` is now required because the published diagnostics binary runs TypeScript through Node's `--experimental-strip-types` support.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 358 tests passing, `TEST_PASS`
---
## 1.5.1 (2026-04-30) ## 1.5.1 (2026-04-30)
### Evidence Loop and Explainability ### Evidence Loop and Explainability
+9 -34
View File
@@ -87,33 +87,16 @@ const HOT_STATE_LIMITS = {
## Active File Scoring ## Active File Scoring
Files are ranked by action type plus repeated access count: Files are ranked by action type:
| Action | Weight | Description | | Action | Weight | Description |
|--------|--------|-------------| |--------|--------|-------------|
| `edit` | 50 | File modified | | `write` | 4 | File created/overwritten |
| `write` | 45 | File created/overwritten | | `edit` | 3 | File modified |
| `grep` | 30 | Grep searched in file | | `read` | 2 | File read |
| `read` | 20 | File read | | `grep` | 1 | Grep searched in file |
Score formula: `ACTION_WEIGHT[action] + count * 3` Score formula: `count * action_weight * recency_decay`
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.
## Error Categories ## Error Categories
@@ -221,21 +204,13 @@ cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
### Inspect Retention Health ### Inspect Retention Health
Use the diagnostics CLI to check memory health for the current workspace: From a source checkout, maintainers can inspect stored vs rendered memory behavior:
```bash ```bash
npx --package opencode-working-memory memory-diag status bun scripts/memory-diag.ts health
# or from a source checkout:
npm run diag -- status
``` ```
For detailed diagnostics, use `--verbose`: The health output includes stored active memories, rendered candidates, type caps, global cap overflow, dormancy status, retention monitoring alerts, and strength-ranked top/weakest entries.
```bash
npx --package opencode-working-memory memory-diag status --verbose
```
The status output includes overall health (OK/WARNING/DEGRADED), key metrics, and suggested next steps when attention is needed.
### Clear Workspace Memory ### Clear Workspace Memory
+3 -9
View File
@@ -1,29 +1,23 @@
{ {
"name": "opencode-working-memory", "name": "opencode-working-memory",
"version": "1.5.5", "version": "1.5.2",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module", "type": "module",
"main": "index.ts", "main": "index.ts",
"exports": { "exports": {
".": "./index.ts" ".": "./index.ts"
}, },
"bin": {
"memory-diag": "./scripts/memory-diag-bin.cjs"
},
"files": [ "files": [
"index.ts", "index.ts",
"src/", "src/",
"scripts/memory-diag.ts",
"scripts/memory-diag/",
"scripts/memory-diag-bin.cjs",
"README.md", "README.md",
"LICENSE" "LICENSE"
], ],
"scripts": { "scripts": {
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"", "build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
"diag": "node --experimental-strip-types scripts/memory-diag.ts",
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"", "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')\"", "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" "check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
}, },
"keywords": [ "keywords": [
@@ -53,6 +47,6 @@
"typescript": "^5.9.2" "typescript": "^5.9.2"
}, },
"engines": { "engines": {
"node": ">=22.6.0" "node": ">=18.0.0"
} }
} }
-368
View File
@@ -1,368 +0,0 @@
#!/usr/bin/env node
/**
* Read-only baseline report for workspace-memory exact/identity key collisions.
*
* This script reads workspace-memory.json files from the local workspace memory
* data directory. It never writes to workspace memory stores; the only optional
* write is the explicit --json report path.
*/
import { existsSync } from "node:fs";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../../src/workspace-memory.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType } from "../../src/types.ts";
type CliOptions = {
workspacesDir: string;
jsonPath?: string;
};
type ReinforcementCountStatistics = {
min: number;
max: number;
mean: number;
gtZero: number;
};
type MemorySummary = {
id: string;
type: LongTermType;
source: LongTermSource;
text: string;
reinforcementCount: number;
};
type WorkspaceSummary = {
dirName: string;
dirPath: string;
key: string;
root: string;
};
type CollisionGroup = {
kind: "exact" | "identity";
workspace: WorkspaceSummary;
key: string;
classification: "unclassified";
memories: MemorySummary[];
};
type ValidationReport = {
summary: {
workspaces: number;
activeMemories: number;
exactCollisionGroups: number;
identityCollisionGroups: number;
reinforcementCountStatistics: ReinforcementCountStatistics;
};
groups: CollisionGroup[];
};
type ScannedMemory = {
entry: LongTermMemoryEntry;
exactKey: string;
identityKey: string;
reinforcementCount: number;
};
const DEFAULT_WORKSPACES_DIR = join(homedir(), ".local", "share", "opencode-working-memory", "workspaces");
const WORKSPACES_DIR_ENV_KEYS = [
"OPENCODE_WORKING_MEMORY_WORKSPACES_DIR",
"WORKSPACE_MEMORY_WORKSPACES_DIR",
];
function usage(): string {
return `Usage:
node --experimental-strip-types scripts/dev/validate-identity-keys.ts [workspaces-dir] [--json <path>]
node --experimental-strip-types scripts/dev/validate-identity-keys.ts --data-path <workspaces-dir> [--json <path>]
Defaults:
workspaces-dir defaults to ${DEFAULT_WORKSPACES_DIR}
env override: ${WORKSPACES_DIR_ENV_KEYS.join(" or ")}
`;
}
function die(message: string): never {
console.error(message);
console.error(usage());
process.exit(1);
}
function envWorkspacesDir(): string | undefined {
for (const key of WORKSPACES_DIR_ENV_KEYS) {
const value = process.env[key];
if (value) return value;
}
return undefined;
}
function expandHome(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
return path;
}
function parseArgs(argv: string[]): CliOptions {
let workspacesDir: string | undefined;
let jsonPath: string | undefined;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
console.log(usage());
process.exit(0);
}
if (arg === "--json") {
const value = argv[++i];
if (!value) die("--json requires a path");
jsonPath = expandHome(value);
continue;
}
if (arg === "--data-path" || arg === "--workspaces-dir") {
const value = argv[++i];
if (!value) die(`${arg} requires a path`);
if (workspacesDir) die("Only one workspaces directory may be provided");
workspacesDir = expandHome(value);
continue;
}
if (arg.startsWith("--")) die(`Unknown option: ${arg}`);
if (workspacesDir) die("Only one workspaces directory may be provided");
workspacesDir = expandHome(arg);
}
return {
workspacesDir: resolve(workspacesDir ?? expandHome(envWorkspacesDir() ?? DEFAULT_WORKSPACES_DIR)),
jsonPath: jsonPath ? resolve(jsonPath) : undefined,
};
}
function isLongTermType(value: unknown): value is LongTermType {
return value === "feedback" || value === "project" || value === "decision" || value === "reference";
}
function isLongTermSource(value: unknown): value is LongTermSource {
return value === "explicit" || value === "compaction" || value === "manual";
}
function isMemoryEntry(value: unknown): value is LongTermMemoryEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<LongTermMemoryEntry>;
return typeof entry.id === "string"
&& isLongTermType(entry.type)
&& typeof entry.text === "string"
&& isLongTermSource(entry.source);
}
function reinforcementCount(entry: LongTermMemoryEntry): number {
const count = entry.reinforcementCount ?? 0;
return Number.isFinite(count) && count > 0 ? count : 0;
}
function summarizeMemory(memory: ScannedMemory): MemorySummary {
return {
id: memory.entry.id,
type: memory.entry.type,
source: memory.entry.source,
text: memory.entry.text,
reinforcementCount: memory.reinforcementCount,
};
}
function addToGroupMap(map: Map<string, ScannedMemory[]>, key: string, memory: ScannedMemory): void {
const group = map.get(key);
if (group) {
group.push(memory);
} else {
map.set(key, [memory]);
}
}
function computeReinforcementStatistics(counts: number[]): ReinforcementCountStatistics {
if (counts.length === 0) {
return { min: 0, max: 0, mean: 0, gtZero: 0 };
}
const sum = counts.reduce((total, count) => total + count, 0);
return {
min: Math.min(...counts),
max: Math.max(...counts),
mean: sum / counts.length,
gtZero: counts.filter(count => count > 0).length,
};
}
async function readWorkspaceMemory(workspaceDir: string): Promise<unknown | undefined> {
const path = join(workspaceDir, "workspace-memory.json");
if (!existsSync(path)) return undefined;
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw);
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
if (code === "ENOENT" || code === "EISDIR") return undefined;
throw error;
}
}
async function scanWorkspaces(workspacesDir: string): Promise<ValidationReport> {
const dirents = await readdir(workspacesDir, { withFileTypes: true });
const workspaceDirs = dirents
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
.sort((a, b) => a.localeCompare(b));
const collisionGroups: CollisionGroup[] = [];
const reinforcementCounts: number[] = [];
let workspaces = 0;
let activeMemories = 0;
for (const dirName of workspaceDirs) {
const dirPath = join(workspacesDir, dirName);
const store = await readWorkspaceMemory(dirPath);
if (!store || typeof store !== "object") continue;
const storeObject = store as {
workspace?: { key?: unknown; root?: unknown };
entries?: unknown;
};
const rawEntries = Array.isArray(storeObject.entries) ? storeObject.entries : [];
const workspace: WorkspaceSummary = {
dirName,
dirPath,
key: typeof storeObject.workspace?.key === "string" ? storeObject.workspace.key : dirName,
root: typeof storeObject.workspace?.root === "string" ? storeObject.workspace.root : "<unknown>",
};
workspaces += 1;
const exactGroups = new Map<string, ScannedMemory[]>();
const identityGroups = new Map<string, ScannedMemory[]>();
for (const rawEntry of rawEntries) {
if (!isMemoryEntry(rawEntry)) continue;
if (rawEntry.status === "superseded") continue;
const scanned: ScannedMemory = {
entry: rawEntry,
exactKey: workspaceMemoryExactKey(rawEntry),
identityKey: workspaceMemoryIdentityKey(rawEntry),
reinforcementCount: reinforcementCount(rawEntry),
};
activeMemories += 1;
reinforcementCounts.push(scanned.reinforcementCount);
addToGroupMap(exactGroups, scanned.exactKey, scanned);
addToGroupMap(identityGroups, scanned.identityKey, scanned);
}
for (const [key, memories] of exactGroups.entries()) {
if (memories.length < 2) continue;
collisionGroups.push({
kind: "exact",
workspace,
key,
classification: "unclassified",
memories: memories.map(summarizeMemory),
});
}
for (const [key, memories] of identityGroups.entries()) {
if (memories.length < 2) continue;
collisionGroups.push({
kind: "identity",
workspace,
key,
classification: "unclassified",
memories: memories.map(summarizeMemory),
});
}
}
const exactCollisionGroups = collisionGroups.filter(group => group.kind === "exact").length;
const identityCollisionGroups = collisionGroups.filter(group => group.kind === "identity").length;
return {
summary: {
workspaces,
activeMemories,
exactCollisionGroups,
identityCollisionGroups,
reinforcementCountStatistics: computeReinforcementStatistics(reinforcementCounts),
},
groups: collisionGroups,
};
}
function oneLine(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function renderHumanReport(report: ValidationReport, workspacesDir: string): string {
const stats = report.summary.reinforcementCountStatistics;
const lines: string[] = [];
lines.push("Workspace memory identity key validation baseline");
lines.push(`Data path: ${workspacesDir}`);
lines.push("");
lines.push("Summary:");
lines.push(`- Workspaces scanned: ${report.summary.workspaces}`);
lines.push(`- Active memories: ${report.summary.activeMemories}`);
lines.push(`- Exact collision groups: ${report.summary.exactCollisionGroups}`);
lines.push(`- Identity collision groups: ${report.summary.identityCollisionGroups}`);
lines.push(`- Reinforcement counts: min=${stats.min} max=${stats.max} mean=${stats.mean.toFixed(2)} gtZero=${stats.gtZero}`);
if (report.groups.length === 0) {
lines.push("");
lines.push("Collision breakdown: none");
return lines.join("\n");
}
const groupsByWorkspace = new Map<string, CollisionGroup[]>();
for (const group of report.groups) {
const key = group.workspace.key;
const existing = groupsByWorkspace.get(key);
if (existing) existing.push(group);
else groupsByWorkspace.set(key, [group]);
}
lines.push("");
lines.push("Collision breakdown:");
for (const [workspaceKey, groups] of [...groupsByWorkspace.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
const workspace = groups[0].workspace;
const exactCount = groups.filter(group => group.kind === "exact").length;
const identityCount = groups.filter(group => group.kind === "identity").length;
lines.push(`- Workspace ${workspaceKey}`);
lines.push(` root: ${workspace.root}`);
lines.push(` dir: ${workspace.dirPath}`);
lines.push(` exactCollisionGroups=${exactCount} identityCollisionGroups=${identityCount}`);
for (const group of groups.sort((a, b) => a.kind.localeCompare(b.kind) || a.key.localeCompare(b.key))) {
lines.push(` - ${group.kind} key: ${group.key}`);
lines.push(` classification: ${group.classification}`);
for (const memory of group.memories) {
lines.push(` - id=${memory.id} type=${memory.type} source=${memory.source} reinforcementCount=${memory.reinforcementCount}`);
lines.push(` text: ${oneLine(memory.text)}`);
}
}
}
return lines.join("\n");
}
async function writeJsonReport(path: string, report: ValidationReport): Promise<void> {
if (basename(path) === "workspace-memory.json") {
die("Refusing to write JSON output to a workspace-memory.json file");
}
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, "utf8");
}
const options = parseArgs(process.argv.slice(2));
const report = await scanWorkspaces(options.workspacesDir);
console.log(renderHumanReport(report, options.workspacesDir));
if (options.jsonPath) {
await writeJsonReport(options.jsonPath, report);
console.log(`\nJSON report written to: ${options.jsonPath}`);
}
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env node
const { execFileSync } = require("child_process");
const path = require("path");
function isSupportedNodeVersion(version) {
const [major = 0, minor = 0, patch = 0] = version.split(".").map(part => Number(part));
void patch;
return major > 22 || (major === 22 && minor >= 6);
}
if (!isSupportedNodeVersion(process.versions.node)) {
process.stderr.write(`memory-diag requires Node >=22.6.0 because it runs TypeScript with --experimental-strip-types. Current Node: v${process.versions.node}.\n`);
process.exit(1);
}
const binDir = __dirname;
const tsScript = path.join(binDir, "memory-diag.ts");
const args = ["--experimental-strip-types", tsScript, ...process.argv.slice(2)];
try {
execFileSync(process.execPath, args, { stdio: "inherit" });
process.exit(0);
} catch (e) {
process.exit(e.status || 1);
}
+1515 -35
View File
File diff suppressed because it is too large Load Diff
-124
View File
@@ -1,124 +0,0 @@
import { HIDDEN_COMMAND_NOTICES, isCommand } from "./command-metadata.ts";
import type { CliOptions, Command, ParsedArgs } from "./types.ts";
export type { ParsedArgs } from "./types.ts";
export function usage(): string {
return `Usage:
memory-diag [status] [--workspace <path>] [--verbose] [--json]
memory-diag rejected [--workspace <path>] [--verbose] [--json]
memory-diag missing [--workspace <path>] [--verbose] [--json]
memory-diag explain [memory-id] [--workspace <path>] [--raw]
Global options:
--workspace <path> Workspace path (default: current directory)
--verbose Show detailed diagnostics
--json Print machine-readable JSON where supported
--no-emoji Disable emoji in human output
`;
}
function error(message: string): ParsedArgs {
return { ok: false, message, usage: usage(), exitCode: 1 };
}
function isValidSince(rawSince: string): boolean {
if (/^(\d+)([dhm])$/i.test(rawSince)) return true;
return !Number.isNaN(new Date(rawSince).getTime());
}
export function parseArgs(argv: string[]): ParsedArgs {
const [first, ...tail] = argv;
if (first === "--help" || first === "-h") {
return { ok: true, help: true, usage: usage() };
}
let command: Command = "status";
let deprecationNotice: string | undefined;
let rest = argv;
if (first && !first.startsWith("--")) {
rest = tail;
if (isCommand(first)) {
command = first;
deprecationNotice = HIDDEN_COMMAND_NOTICES[first];
} else {
return error(`Unknown subcommand: ${first}`);
}
}
const options: CliOptions = { raw: false, positional: [] };
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (arg === "--raw") options.raw = true;
else if (arg === "--json") options.json = true;
else if (arg === "--verbose") options.verbose = true;
else if (arg === "--no-emoji") options.noEmoji = true;
else if (arg === "--all") options.all = true;
else if (arg === "--soft-only") options.softOnly = true;
else if (arg === "--trigger-only") options.triggerOnly = true;
else if (arg === "--include-historical") options.includeHistorical = true;
else if (arg === "--explain") options.explain = true;
else if (arg === "--workspace") {
const value = rest[++i];
if (!value) return error("--workspace requires a path");
options.workspace = value;
} else if (arg === "--since") {
const value = rest[++i];
if (!value) return error("--since requires a duration or ISO timestamp");
options.since = value;
} else if (arg === "--reason") {
const value = rest[++i];
if (!value) return error("--reason requires a reason code");
options.reason = value;
} else if (arg === "--migration") {
const value = rest[++i];
if (!value) return error("--migration requires an id");
options.migration = value;
} else if (arg === "--memory") {
const value = rest[++i];
if (!value) return error("--memory requires an id");
options.memory = value;
} else if (!arg.startsWith("--") && command === "explain") {
options.positional?.push(arg);
} else {
return error(`Unknown option: ${arg}`);
}
}
if (command === "explain") {
const positional = options.positional ?? [];
if (positional.length > 1) return error("explain accepts at most one memory id");
if (positional.length === 1 && options.memory) return error("Use either explain <memory-id> or --memory, not both");
if (positional.length === 1) options.memory = positional[0];
} else if ((options.positional ?? []).length > 0) {
return error(`Unknown option: ${options.positional?.[0]}`);
}
if (command === "status") {
if (options.all) return error(`${command} does not accept --all`);
} else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain") {
if (options.all) return error(`${command} does not accept --all`);
} else {
if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`);
}
if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage") {
return error(`${command} does not accept --json`);
}
if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) {
return error(`${command} does not accept rejection filters`);
}
if (command !== "coverage" && options.includeHistorical) return error(`${command} does not accept --include-historical`);
if (command !== "rejected" && options.reason) return error(`${command} does not accept rejection filters`);
if (command !== "missing" && options.explain) return error(`${command} does not accept --explain`);
if (command !== "audit" && options.migration) {
return error(`${command} does not accept --migration`);
}
if (command !== "explain" && options.memory) {
return error(`${command} does not accept --memory`);
}
if (command === "rejected" && options.since && !isValidSince(options.since)) {
return error(`Invalid --since value: ${options.since}`);
}
return { ok: true, command, options, deprecationNotice };
}
-16
View File
@@ -1,16 +0,0 @@
export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain"] 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);
}
-18
View File
@@ -1,18 +0,0 @@
import { runAudit } from "./commands/audit.ts";
import { runCoverage } from "./commands/coverage.ts";
import { runExplain } from "./commands/explain.ts";
import { runMissing } from "./commands/missing.ts";
import { runRejected } from "./commands/rejected.ts";
import { runStatus } from "./commands/status.ts";
import type { CliOptions, Command, CommandResult } from "./types.ts";
export async function dispatch(command: Command, options: CliOptions): Promise<CommandResult> {
switch (command) {
case "status": return runStatus(options);
case "rejected": return runRejected(options);
case "missing": return runMissing(options);
case "coverage": return runCoverage(options);
case "audit": return runAudit(options);
case "explain": return runExplain(options);
}
}
-24
View File
@@ -1,24 +0,0 @@
import { HARD_QUALITY_REASONS } from "../../../src/memory-quality.ts";
import { formatMigrationAudit, type MigrationAuditReport } from "../formatters/audit.ts";
import { readJSONLFile } from "../io.ts";
import { migrationIdFromPath, migrationLogPaths, riskySupersedeReasons } from "../migrations-model.ts";
import type { CliOptions, CommandResult, MigrationLogRecord } from "../types.ts";
export async function runAudit(options: CliOptions): Promise<CommandResult> {
const paths = await migrationLogPaths(options);
const reports: MigrationAuditReport[] = [];
for (const path of paths) {
const migrationId = options.migration ?? migrationIdFromPath(path);
const { records, invalidLines } = await readJSONLFile<MigrationLogRecord>(path);
const superseded = records.filter(record => !record.afterStatus || record.afterStatus === "superseded");
const hardReasons = superseded.flatMap(record => {
if (Array.isArray(record.hardReasons)) return record.hardReasons;
return Array.isArray(record.reasons) ? record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)) : [];
});
const risky = superseded
.map(record => ({ record, reasons: riskySupersedeReasons(record) }))
.filter(item => item.reasons.length > 0);
reports.push({ migrationId, path, invalidLines, superseded, hardReasons, risky });
}
return { stdout: formatMigrationAudit(reports, { raw: options.raw }) };
}
-14
View File
@@ -1,14 +0,0 @@
import { buildInspectionReadModel, coverageRows } from "../inspection-model.ts";
import { buildCoverageJSON, formatCoverage } from "../formatters/coverage.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runCoverage(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const rows = coverageRows(model, options.includeHistorical === true);
if (options.json) {
return { stdout: JSON.stringify(buildCoverageJSON(rows), null, 2) };
}
return { stdout: formatCoverage(rows) };
}
-19
View File
@@ -1,19 +0,0 @@
import { traceMemoryLifecycle } from "../../../src/evidence-log.ts";
import { formatExplain } from "../formatters/explain.ts";
import { formatTrace } from "../formatters/trace.ts";
import { snapshotForOptions } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runExplain(options: CliOptions): Promise<CommandResult> {
if (options.memory) {
const root = options.workspace ?? process.cwd();
const [snapshot, trace] = await Promise.all([
snapshotForOptions(options),
traceMemoryLifecycle(root, { memoryId: options.memory }),
]);
return { stdout: formatTrace(options.memory, snapshot, trace) };
}
const snapshot = await snapshotForOptions(options);
return { stdout: formatExplain(snapshot) };
}
-14
View File
@@ -1,14 +0,0 @@
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
import { buildMissingJSON, formatMissing } from "../formatters/missing.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runMissing(options: CliOptions): Promise<CommandResult> {
const model = await buildInspectionReadModel(options);
const rows = disappearanceRows(model);
if (options.json) {
return { stdout: JSON.stringify(buildMissingJSON(rows, { explain: options.explain }), null, 2) };
}
return { stdout: formatMissing(rows, { verbose: options.verbose, explain: options.explain }) };
}
-25
View File
@@ -1,25 +0,0 @@
import { buildRejectedJSON, formatRejected } from "../formatters/rejected.ts";
import { loadRejectionRecords, rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import type { CliOptions, CommandResult } from "../types.ts";
export async function runRejected(options: CliOptions): Promise<CommandResult> {
const { path, invalidLines, records } = await loadRejectionRecords(options);
const summary = rejectionQualitySummary(records);
const falsePositiveRisk = rejectionFalsePositiveRisk(summary);
if (options.json) {
return { stdout: JSON.stringify(buildRejectedJSON({ summary, falsePositiveRisk }), null, 2) };
}
return {
stdout: formatRejected({
path,
invalidLines,
records,
summary,
falsePositiveRisk,
raw: options.raw,
verbose: options.verbose,
}),
};
}
-112
View File
@@ -1,112 +0,0 @@
import { RETENTION_TYPE_MAX, WORKSPACE_DORMANT_AFTER_DAYS } from "../../../src/retention.ts";
import { TYPES } from "../constants.ts";
import { formatStatus, type StatusReadout } from "../formatters/status.ts";
import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts";
import { retentionCandidatesForDiag, retentionClockSummary, daysSinceIso } from "../retention-model.ts";
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import { isWithinDays, memoryDiagJSONFromSnapshot } from "../workspace-snapshot.ts";
import type { CliOptions, CommandResult, MemoryInspectionReadModel } from "../types.ts";
export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.now()): StatusReadout {
const active = model.store.entries.filter(entry => entry.status !== "superseded");
const retention = retentionCandidatesForDiag(model.store, now);
const clocks = retentionClockSummary(active);
const disappearances = disappearanceRows(model);
const evidenceCovered = active.filter(entry => (model.evidenceByMemoryId.get(entry.id) ?? []).length > 0).length;
const rejectionSummary = rejectionQualitySummary(model.rejectionRecords);
const falsePositiveRisk = rejectionFalsePositiveRisk(rejectionSummary);
const typeCounts = Object.fromEntries(TYPES.map(type => [type, active.filter(entry => entry.type === type).length]));
const capsFull = active.length >= model.store.limits.maxEntries || TYPES.some(type => (typeCounts[type] ?? 0) >= RETENTION_TYPE_MAX[type]);
const unknownDisappearances = disappearances.filter(row => row.classification === "historical_absent_unknown_reason").length;
const status = unknownDisappearances > 0 || clocks.invalid > 0
? "degraded"
: capsFull || rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high"
? "warning"
: "ok";
const rejectedLast7Days = model.evidenceEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7, now)).length;
const evidenceCoveragePercent = active.length === 0 ? 100 : Math.round((evidenceCovered / active.length) * 100);
const needsAttention: string[] = [];
if (clocks.invalid > 0) needsAttention.push(`${clocks.invalid} invalid retention clocks`);
if (unknownDisappearances > 0) needsAttention.push(`${unknownDisappearances} memories disappeared without terminal evidence`);
if (capsFull) needsAttention.push("memory store is at or over retention caps");
if (rejectionSummary.legacyUnscopedCount > 0) needsAttention.push(`${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records`);
if (falsePositiveRisk === "high") needsAttention.push("possible rejection false-positive risk is high");
const suggestedNextSteps: string[] = [];
if (clocks.invalid > 0 || unknownDisappearances > 0) suggestedNextSteps.push("Run memory-diag missing --verbose to inspect missing-memory evidence.");
if (capsFull) suggestedNextSteps.push("Run memory-diag explain to see which memories are hidden by caps.");
if (rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high") suggestedNextSteps.push("Run memory-diag rejected --verbose to inspect rejection quality.");
const wallDaysSinceActivity = daysSinceIso(model.store.lastActivityAt, now);
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS;
const dormantDaysPastGrace = wallDaysSinceActivity === null ? 0 : Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
return {
status,
summaryText: `Summary: Workspace memory quality is ${status}: ${active.length} active memories, ${evidenceCovered}/${active.length} with evidence, ${disappearances.length} evidence-only disappearances (${unknownDisappearances} unknown), ${clocks.invalid} invalid retention clocks, and ${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records.`,
active: active.length,
rendered: retention.rendered.length,
pending: model.pending.entries.length,
rejectedLast7Days,
evidenceCoveragePercent,
needsAttention,
suggestedNextSteps,
caps: {
active: active.length,
maxEntries: model.store.limits.maxEntries,
rendered: retention.rendered.length,
typeCapped: retention.typeCapped.length,
globalCapped: retention.globalCapped.length,
typeCounts,
capsFull,
},
retention: clocks,
evidence: {
currentWithEvidence: evidenceCovered,
currentWithoutEvidence: active.length - evidenceCovered,
evidenceMemoryIds: model.evidenceByMemoryId.size,
disappearances: disappearances.length,
unknownDisappearances,
withTerminalReason: disappearances.length - unknownDisappearances,
},
rejections: { ...rejectionSummary, falsePositiveRisk },
dormant: {
lastActivityAt: model.store.lastActivityAt,
wallDaysSinceActivity,
dormantDiscountActive,
dormantDaysPastGrace,
},
topRendered: retention.rendered.slice(0, 5),
weakestActive: retention.sorted.slice(-5).reverse(),
};
}
export async function runStatus(options: CliOptions): Promise<CommandResult> {
const now = Date.now();
const model = await buildInspectionReadModel(options);
const readout = buildStatusReadout(model, now);
if (options.json) {
const root = options.workspace ?? process.cwd();
const diag = memoryDiagJSONFromSnapshot(root, model.snapshot);
return {
stdout: JSON.stringify({
...diag,
summary: {
...diag.summary,
status: readout.status,
evidenceCoveragePercent: readout.evidenceCoveragePercent,
needsAttention: readout.needsAttention,
suggestedNextSteps: readout.suggestedNextSteps,
},
}, null, 2),
};
}
return {
stdout: formatStatus(readout, model, {
verbose: options.verbose,
noEmoji: options.noEmoji,
isTty: process.stdout.isTTY === true,
raw: options.raw,
}),
};
}
-21
View File
@@ -1,21 +0,0 @@
import type { LongTermType } from "../../src/types.ts";
import type { Origin } from "./types.ts";
export const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
export const SUSPICIOUS_REASONS = [
"progress_snapshot",
"active_file_snapshot",
"commit_or_ci_snapshot",
"temporary_status",
"raw_error",
"code_or_api_signature",
] as const;
export const ALLOWED_ORIGINS = new Set<Origin>([
"explicit_trigger",
"compaction_candidate",
"manual",
"migration_check",
"unknown",
]);
-21
View File
@@ -1,21 +0,0 @@
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import { uniqueStrings } from "./text.ts";
export function eventMemoryIds(event: EvidenceEventV1): string[] {
return uniqueStrings([
event.memory?.memoryId ?? "",
...(event.relations ?? []).map(relation => relation.memory?.memoryId ?? ""),
]);
}
export function groupEvidenceByMemoryId(events: EvidenceEventV1[]): Map<string, EvidenceEventV1[]> {
const grouped = new Map<string, EvidenceEventV1[]>();
for (const event of events) {
for (const id of eventMemoryIds(event)) {
const bucket = grouped.get(id) ?? [];
bucket.push(event);
grouped.set(id, bucket);
}
}
return grouped;
}
-48
View File
@@ -1,48 +0,0 @@
import { cleanPath, cleanText, countBy, formatWorkspaceIdentity, sortedCounts, truncate } from "../text.ts";
import type { MigrationLogRecord } from "../types.ts";
export type MigrationAuditReport = {
migrationId: string;
path: string;
invalidLines: number;
superseded: MigrationLogRecord[];
hardReasons: string[];
risky: Array<{ record: MigrationLogRecord; reasons: string[] }>;
};
export function formatMigrationAudit(reports: MigrationAuditReport[], options: { raw: boolean }): string {
const lines: string[] = [];
lines.push("Migration audit report");
lines.push("");
if (reports.length === 0) {
lines.push("No migration logs found.");
return lines.join("\n");
}
for (let i = 0; i < reports.length; i += 1) {
const report = reports[i];
if (i > 0) lines.push("");
lines.push(`Migration: ${report.migrationId}`);
lines.push(`logPath=${cleanPath(report.path, options.raw)}`);
if (report.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${report.invalidLines}`);
lines.push(`Superseded entries: ${report.superseded.length}`);
lines.push("");
lines.push("By hard reason:");
const byHardReason = sortedCounts(countBy(report.hardReasons));
if (byHardReason.length === 0) lines.push(" (none)");
else for (const [reason, count] of byHardReason) lines.push(` ${reason.padEnd(24)} ${count}`);
lines.push("");
lines.push("Potentially risky supersedes:");
lines.push(` ${report.risky.length}`);
for (const item of report.risky.slice(0, 10)) {
const record = item.record;
const hard = Array.isArray(record.hardReasons) ? record.hardReasons : [];
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
lines.push(` - [${record.type ?? "unknown"}] hardReasons=${JSON.stringify(hard)} risk=${item.reasons.join(",")} ${JSON.stringify(truncate(cleanText(record.text ?? "", options.raw)))}`);
if (identity) lines.push(` ${identity}`);
}
}
return lines.join("\n");
}
@@ -1,34 +0,0 @@
import type { coverageRows } from "../inspection-model.ts";
import { countBy, objectFromCounts } from "../text.ts";
import type { CoverageClass } from "../types.ts";
export type CoverageRows = ReturnType<typeof coverageRows>;
export function buildCoverageJSON(rows: CoverageRows, generatedAt = new Date().toISOString()): Record<string, unknown> {
const classCounts = objectFromCounts(countBy(rows.map(row => row.class)));
return {
version: 1,
generatedAt,
classCounts,
memories: rows,
};
}
export function formatCoverage(rows: CoverageRows): string {
const classCounts = objectFromCounts(countBy(rows.map(row => row.class)));
const lines: string[] = [];
lines.push("Memory evidence coverage");
lines.push("");
lines.push("Class counts:");
for (const cls of ["full_lifecycle", "render_only", "no_evidence", "historical_absent_with_reason", "historical_absent_unknown_reason"] as CoverageClass[]) {
lines.push(` ${cls}: ${classCounts[cls] ?? 0}`);
}
lines.push("");
lines.push("Per-memory rows:");
if (rows.length === 0) lines.push(" (none)");
for (const row of rows) {
const phases = row.eventCounts.byPhase;
lines.push(` ${row.id} ${row.class} current=${row.current ? "yes" : "no"} total=${row.eventCounts.total} extraction=${phases.extraction ?? 0} promotion=${phases.promotion ?? 0} render=${phases.render ?? 0} storage=${phases.storage ?? 0}`);
}
return lines.join("\n");
}
-42
View File
@@ -1,42 +0,0 @@
import type { EvidenceEventV1 } from "../../../src/evidence-log.ts";
import { formatStrength } from "../retention-model.ts";
import type { WorkspaceDiagSnapshot } from "../types.ts";
export function formatEvidenceRefs(eventIds: string[], allEvents: EvidenceEventV1[]): string {
if (eventIds.length === 0) return "(none)";
const byId = new Map(allEvents.map(event => [event.eventId, event]));
return eventIds
.map(id => {
const event = byId.get(id);
return event ? `${event.eventId} ${event.type}` : id;
})
.join(", ");
}
export function formatExplain(snapshot: WorkspaceDiagSnapshot): string {
const lines: string[] = [];
lines.push("Workspace memory explainability");
lines.push("");
if (snapshot.memories.length === 0) {
lines.push("No memories found.");
}
for (const memory of snapshot.memories) {
lines.push(`Memory ${memory.id}: ${memory.status}`);
const strength = typeof memory.strength === "number" ? formatStrength(memory.strength) : "n/a";
lines.push(`- strength=${strength}, type=${memory.type}, source=${memory.source}`);
lines.push(`- reasons: ${memory.reasonCodes.length > 0 ? memory.reasonCodes.join(", ") : "(none)"}`);
lines.push(`- evidence: ${formatEvidenceRefs(memory.evidenceEventIds, snapshot.allEvents)}`);
lines.push("");
}
const quarantines = snapshot.recentEvents.filter(event => event.type === "storage_corrupt_json_quarantined");
if (quarantines.length > 0) {
lines.push("Quarantined stores:");
for (const event of quarantines) {
lines.push(`- quarantined_corrupt_store: ${event.eventId} ${event.type}; reasons=${event.reasonCodes.join(",")}`);
}
}
return lines.join("\n");
}
-102
View File
@@ -1,102 +0,0 @@
import type { disappearanceRows } from "../inspection-model.ts";
import { eventCounts } from "../inspection-model.ts";
import { formatDetails } from "../text.ts";
export type DisappearanceRows = ReturnType<typeof disappearanceRows>;
export type MissingSummary = {
total: number;
explained: number;
needsReview: number;
};
export function missingSummary(rows: DisappearanceRows): MissingSummary {
return {
total: rows.length,
explained: rows.filter(row => row.classification === "historical_absent_with_reason").length,
needsReview: rows.filter(row => row.classification === "historical_absent_unknown_reason").length,
};
}
export function buildMissingJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
return {
...buildDisappearancesJSON(rows, options),
summary: missingSummary(rows),
};
}
function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record<string, unknown> {
return {
version: 1,
generatedAt: options.generatedAt ?? new Date().toISOString(),
disappearances: rows.map(row => ({
id: row.id,
classification: row.classification,
terminalType: row.terminalType,
reasonCodes: row.reasonCodes,
eventCounts: eventCounts(row.events),
details: options.explain ? row.event?.details : undefined,
})),
};
}
function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string {
const lines: string[] = [];
lines.push("Memory disappearances");
lines.push("");
if (rows.length === 0) {
lines.push("No evidence-only memories found.");
return lines.join("\n");
}
for (const row of rows) {
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`);
if (options.explain) {
lines.push(` events: ${row.events.map(event => event.type).join(", ")}`);
if (row.event?.type === "memory_removed_capacity") {
lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`);
}
const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"));
if (renderTypeCap) {
lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`);
}
}
}
return lines.join("\n");
}
export function formatMissing(rows: DisappearanceRows, options: { verbose?: boolean; explain?: boolean } = {}): string {
const summary = missingSummary(rows);
const lines: string[] = [];
lines.push("Missing memory summary");
lines.push("");
if (rows.length === 0) {
lines.push("No missing memories found.");
return lines.join("\n");
}
lines.push(`Total missing: ${summary.total}`);
lines.push(`Explained: ${summary.explained}`);
lines.push(`Needs review: ${summary.needsReview}`);
lines.push("");
const unknownRows = rows
.filter(row => row.classification === "historical_absent_unknown_reason")
.slice(0, 5);
lines.push("Unknown disappearance samples:");
if (unknownRows.length === 0) {
lines.push(" (none)");
} else {
for (const row of unknownRows) {
const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none";
lines.push(` - ${row.id} terminal=${row.terminalType} reasons=${reasons}`);
}
}
if (options.verbose || options.explain) {
lines.push("");
lines.push(formatDisappearances(rows, { explain: true }));
}
return lines.join("\n");
}
@@ -1,93 +0,0 @@
import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts";
import { cleanPath, cleanText, countBy, sortedCounts, truncate } from "../text.ts";
import type { NormalizedRejection } from "../types.ts";
export type RejectedSummary = ReturnType<typeof rejectionQualitySummary>;
export type RejectedFalsePositiveRisk = ReturnType<typeof rejectionFalsePositiveRisk>;
export function buildRejectedJSON(input: {
summary: RejectedSummary;
falsePositiveRisk: RejectedFalsePositiveRisk;
generatedAt?: string;
}): Record<string, unknown> {
return {
version: 1,
generatedAt: input.generatedAt ?? new Date().toISOString(),
...input.summary,
falsePositiveRisk: input.falsePositiveRisk,
};
}
export function formatRejected(input: {
path: string;
invalidLines: number;
records: NormalizedRejection[];
summary: RejectedSummary;
falsePositiveRisk: RejectedFalsePositiveRisk;
raw: boolean;
verbose?: boolean;
}): string {
const lines: string[] = [];
lines.push("Rejected memory summary");
lines.push("");
lines.push(`Total rejected: ${input.summary.totalRecords}`);
lines.push(`Unique texts: ${input.summary.uniqueTexts}`);
lines.push("");
lines.push("Top reasons:");
const byReason = sortedCounts(countBy(input.records.flatMap(record => record.reasons))).slice(0, 5);
if (byReason.length === 0) lines.push(" (none)");
else for (const [reason, count] of byReason) lines.push(` ${reason.padEnd(36)} ${count}`);
lines.push("");
lines.push(`False-positive risk: ${input.falsePositiveRisk}`);
if (input.falsePositiveRisk === "high") {
lines.push("⚠️ Possible false positives detected. Use --verbose to inspect samples.");
}
lines.push("");
lines.push("Recent samples:");
const samples = [...input.records]
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
.slice(0, 5);
if (samples.length === 0) {
lines.push(" (none)");
} else {
for (const record of samples) {
lines.push(` - [${record.type}] ${truncate(cleanText(record.text, input.raw))}`);
lines.push(` reasons: ${record.reasons.length > 0 ? record.reasons.join(",") : "none"}`);
}
}
if (!input.verbose) return lines.join("\n");
lines.push("");
lines.push("Possible false-positive grouping is heuristic, not deterministic truth.");
lines.push(`logPath=${cleanPath(input.path, input.raw)}`);
if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`);
lines.push("");
lines.push("Reason distribution (raw records):");
for (const [reason, count] of Object.entries(input.summary.reasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
if (Object.keys(input.summary.reasonDistribution).length === 0) lines.push(" (none)");
lines.push("");
lines.push("Reason distribution (unique text):");
for (const [reason, count] of Object.entries(input.summary.uniqueReasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`);
if (Object.keys(input.summary.uniqueReasonDistribution).length === 0) lines.push(" (none)");
lines.push("");
lines.push("By origin:");
const byOrigin = sortedCounts(countBy(input.records.map(record => record.origin)));
if (byOrigin.length === 0) lines.push(" (none)");
else for (const [origin, count] of byOrigin) lines.push(` ${origin.padEnd(36)} ${count}`);
lines.push("");
lines.push("Possible false-positive groups (heuristic, not deterministic):");
for (const [group, data] of Object.entries(input.summary.possibleFalsePositiveGroups)) {
lines.push(` ${group}: ${data.count}`);
for (const sample of data.samples) lines.push(` - ${JSON.stringify(cleanText(sample, input.raw))}`);
}
return lines.join("\n");
}
-155
View File
@@ -1,155 +0,0 @@
import {
DORMANT_DECAY_MULTIPLIER,
RETENTION_TYPE_MAX,
} from "../../../src/retention.ts";
import { TYPES } from "../constants.ts";
import { daysSinceIso, formatStrength } from "../retention-model.ts";
import { cleanText, truncate } from "../text.ts";
import type { MemoryInspectionReadModel, RetentionDiagItem } from "../types.ts";
export type MemoryStatusLevel = "ok" | "warning" | "degraded";
export type StatusReadout = {
status: MemoryStatusLevel;
summaryText: string;
active: number;
rendered: number;
pending: number;
rejectedLast7Days: number;
evidenceCoveragePercent: number;
needsAttention: string[];
suggestedNextSteps: string[];
caps: {
active: number;
maxEntries: number;
rendered: number;
typeCapped: number;
globalCapped: number;
typeCounts: Record<string, number>;
capsFull: boolean;
};
retention: { present: number; missing: number; invalid: number };
evidence: {
currentWithEvidence: number;
currentWithoutEvidence: number;
evidenceMemoryIds: number;
disappearances: number;
unknownDisappearances: number;
withTerminalReason: number;
};
rejections: {
totalRecords: number;
workspaceScopedCount: number;
legacyUnscopedCount: number;
falsePositiveRisk: string;
};
dormant: {
lastActivityAt?: string;
wallDaysSinceActivity: number | null;
dormantDiscountActive: boolean;
dormantDaysPastGrace: number;
};
topRendered: RetentionDiagItem[];
weakestActive: RetentionDiagItem[];
};
export function formatStatus(readout: StatusReadout, model: MemoryInspectionReadModel, options: { verbose?: boolean; noEmoji?: boolean; isTty?: boolean; raw?: boolean } = {}): string {
if (options.verbose) return formatStatusVerbose(readout, model, options);
const lines: string[] = [];
const label = statusLabel(readout.status, options);
lines.push(`${label} Memory status`);
lines.push("");
lines.push("Key metrics:");
lines.push(` active memories: ${readout.active}`);
lines.push(` rendered: ${readout.rendered}`);
lines.push(` pending: ${readout.pending}`);
lines.push(` rejected 7d: ${readout.rejectedLast7Days}`);
lines.push(` evidence coverage: ${readout.evidenceCoveragePercent}%`);
if (readout.needsAttention.length > 0) {
lines.push("");
lines.push("Needs attention:");
for (const item of readout.needsAttention) lines.push(` - ${item}`);
}
if (readout.status !== "ok" && readout.suggestedNextSteps.length > 0) {
lines.push("");
lines.push("Suggested next steps:");
for (const item of readout.suggestedNextSteps) lines.push(` - ${item}`);
}
return lines.join("\n");
}
function statusLabel(status: MemoryStatusLevel, options: { noEmoji?: boolean; isTty?: boolean }): string {
const text = status === "ok" ? "OK" : status === "warning" ? "WARNING" : "DEGRADED";
if (options.noEmoji || !options.isTty) return text;
const emoji = status === "ok" ? "🧠" : status === "warning" ? "⚠️" : "✖️";
return `${emoji} ${text}`;
}
function formatStatusVerbose(readout: StatusReadout, model: MemoryInspectionReadModel, options: { raw?: boolean } = {}): string {
const lines: string[] = [];
lines.push("Memory status inspection");
lines.push("");
lines.push(readout.summaryText);
lines.push("");
lines.push("Caps:");
lines.push(` active: ${readout.caps.active} / ${readout.caps.maxEntries}`);
for (const type of TYPES) {
const count = readout.caps.typeCounts[type] ?? 0;
const limit = RETENTION_TYPE_MAX[type];
const marker = count >= limit ? " FULL" : "";
lines.push(` ${type}: ${count} / ${limit}${marker}`);
}
lines.push(` rendered: ${readout.caps.rendered}`);
lines.push(` type-capped entries: ${readout.caps.typeCapped}`);
lines.push(` global-cap overflow: ${readout.caps.globalCapped}`);
lines.push(` caps full: ${readout.caps.capsFull ? "yes" : "no"}`);
lines.push("");
lines.push("Retention clocks:");
lines.push(` present: ${readout.retention.present}`);
lines.push(` missing: ${readout.retention.missing}`);
lines.push(` invalid: ${readout.retention.invalid}`);
lines.push("");
lines.push("Evidence:");
lines.push(` current with evidence: ${readout.evidence.currentWithEvidence}`);
lines.push(` current without evidence: ${readout.evidence.currentWithoutEvidence}`);
lines.push(` evidence memory ids: ${readout.evidence.evidenceMemoryIds}`);
lines.push(` disappearances: ${readout.evidence.disappearances}`);
lines.push(` unknown disappearances: ${readout.evidence.unknownDisappearances}`);
lines.push("");
lines.push("Rejection scoping:");
lines.push(` total records: ${readout.rejections.totalRecords}`);
lines.push(` workspace scoped: ${readout.rejections.workspaceScopedCount}`);
lines.push(` legacy unscoped: ${readout.rejections.legacyUnscopedCount}`);
lines.push(` false-positive risk: ${readout.rejections.falsePositiveRisk}`);
lines.push("");
lines.push("Dormancy:");
lines.push(` lastActivityAt: ${readout.dormant.lastActivityAt ?? "(missing)"}`);
lines.push(` wall days since activity: ${readout.dormant.wallDaysSinceActivity === null ? "unknown" : readout.dormant.wallDaysSinceActivity.toFixed(1)}`);
lines.push(` dormant discount active: ${readout.dormant.dormantDiscountActive ? "yes" : "no"}`);
lines.push(` dormant days past grace: ${readout.dormant.dormantDaysPastGrace.toFixed(1)}`);
lines.push(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`);
lines.push("");
lines.push("Top rendered candidates:");
pushMemoryItems(lines, readout.topRendered, options.raw === true);
lines.push("");
lines.push("Weakest active memories:");
pushMemoryItems(lines, readout.weakestActive, options.raw === true);
lines.push("");
lines.push("Store:");
lines.push(` superseded: ${model.store.entries.length - readout.active}`);
return lines.join("\n");
}
function pushMemoryItems(lines: string[], items: RetentionDiagItem[], raw: boolean): void {
if (items.length === 0) {
lines.push(" (none)");
return;
}
for (const item of items) {
lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, raw))}`);
}
}
-36
View File
@@ -1,36 +0,0 @@
import type { EvidenceEventV1 } from "../../../src/evidence-log.ts";
import { formatTraceEvent, relationMemoryIds, statusFromTraceEvent } from "../trace-model.ts";
import type { WorkspaceDiagSnapshot } from "../types.ts";
export function formatTrace(memoryId: string, snapshot: WorkspaceDiagSnapshot, trace: { events: EvidenceEventV1[] }): string {
const lines: string[] = [];
const memoryRow = snapshot.memories.find(memory => memory.id === memoryId);
const status = memoryRow?.status ?? statusFromTraceEvent(trace.events.at(-1));
lines.push(`Memory ${memoryId}: ${status}`);
lines.push("");
lines.push("Lifecycle:");
if (trace.events.length === 0) {
lines.push("(none)");
return lines.join("\n");
}
for (const event of trace.events) {
lines.push(formatTraceEvent(event));
}
const supersededBy = relationMemoryIds(trace.events, "superseded_by");
if (supersededBy.length > 0) {
lines.push("");
lines.push("Superseded by:");
for (const id of supersededBy) lines.push(`- ${id}`);
}
const reinforcedBy = relationMemoryIds(trace.events, "reinforced_by");
if (reinforcedBy.length > 0) {
lines.push("");
lines.push("Reinforced by:");
for (const id of reinforcedBy) lines.push(`- ${id}`);
}
return lines.join("\n");
}
-98
View File
@@ -1,98 +0,0 @@
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import type { LongTermType } from "../../src/types.ts";
import { countBy, objectFromCounts, uniqueStrings } from "./text.ts";
import { groupEvidenceByMemoryId } from "./evidence-model.ts";
import { loadRejectionRecords } from "./rejections-model.ts";
import { snapshotForOptions } from "./workspace-snapshot.ts";
import type { CliOptions, CoverageClass, DisappearanceReason, MemoryInspectionReadModel } from "./types.ts";
export async function buildInspectionReadModel(options: CliOptions): Promise<MemoryInspectionReadModel> {
const snapshot = await snapshotForOptions(options);
const rejections = await loadRejectionRecords(options);
return {
snapshot,
store: snapshot.store,
pending: snapshot.journal,
evidenceEvents: snapshot.allEvents,
rejectionRecords: rejections.records,
currentById: new Map(snapshot.store.entries.map(entry => [entry.id, entry])),
evidenceByMemoryId: groupEvidenceByMemoryId(snapshot.allEvents),
};
}
export function terminalDisappearanceReason(events: EvidenceEventV1[]): DisappearanceReason {
const terminal = [...events].reverse().find(event =>
event.type === "memory_removed_capacity"
|| event.type === "promotion_absorbed_exact"
|| event.type === "promotion_absorbed_identity"
|| event.type === "promotion_superseded"
);
const renderOmission = [...events].reverse().find(event => event.type === "render_omitted");
const event = terminal ?? renderOmission;
if (!event) {
return { classification: "historical_absent_unknown_reason", terminalType: "unknown", reasonCodes: [] };
}
return {
classification: "historical_absent_with_reason",
terminalType: event.type,
reasonCodes: event.reasonCodes,
event,
};
}
export function coverageClassForMemory(id: string, model: MemoryInspectionReadModel): CoverageClass {
const events = model.evidenceByMemoryId.get(id) ?? [];
if (!model.currentById.has(id)) return terminalDisappearanceReason(events).classification;
if (events.length === 0) return "no_evidence";
if (events.every(event => event.phase === "render")) return "render_only";
return "full_lifecycle";
}
export function eventCounts(events: EvidenceEventV1[]): { total: number; byType: Record<string, number>; byPhase: Record<string, number> } {
return {
total: events.length,
byType: objectFromCounts(countBy(events.map(event => event.type))),
byPhase: objectFromCounts(countBy(events.map(event => event.phase))),
};
}
export function coverageRows(model: MemoryInspectionReadModel, includeHistorical: boolean): Array<{
id: string;
class: CoverageClass;
current: boolean;
type?: LongTermType;
eventCounts: ReturnType<typeof eventCounts>;
}> {
const ids = new Set<string>(model.currentById.keys());
if (includeHistorical) {
for (const id of model.evidenceByMemoryId.keys()) ids.add(id);
}
return [...ids].sort().map(id => {
const entry = model.currentById.get(id);
const events = model.evidenceByMemoryId.get(id) ?? [];
return {
id,
class: coverageClassForMemory(id, model),
current: Boolean(entry),
type: entry?.type ?? events.find(event => event.memory?.memoryId === id)?.memory?.type,
eventCounts: eventCounts(events),
};
});
}
export function disappearanceRows(model: MemoryInspectionReadModel): Array<{
id: string;
classification: DisappearanceReason["classification"];
terminalType: DisappearanceReason["terminalType"];
reasonCodes: string[];
event?: EvidenceEventV1;
events: EvidenceEventV1[];
}> {
const rows = [...model.evidenceByMemoryId.entries()]
.filter(([id]) => !model.currentById.has(id))
.map(([id, events]) => {
const reason = terminalDisappearanceReason(events);
return { id, ...reason, events };
});
return rows.sort((a, b) => a.classification.localeCompare(b.classification) || a.id.localeCompare(b.id));
}
-36
View File
@@ -1,36 +0,0 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
export async function readJSONFile<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch {
return null;
}
}
export async function readJSONLFile<T>(path: string): Promise<{ records: T[]; invalidLines: number }> {
let content = "";
try {
content = await readFile(path, "utf8");
} catch {
return { records: [], invalidLines: 0 };
}
const records: T[] = [];
let invalidLines = 0;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
records.push(JSON.parse(trimmed) as T);
} catch {
invalidLines += 1;
}
}
return { records, invalidLines };
}
export function pathExists(path: string): boolean {
return existsSync(path);
}
-42
View File
@@ -1,42 +0,0 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { dataHome, migrationLogPath } from "../../src/paths.ts";
import type { CliOptions, MigrationLogRecord } from "./types.ts";
export function migrationLogsRoot(): string {
return join(dataHome(), "opencode-working-memory", "migration-logs");
}
export async function migrationLogPaths(options: CliOptions): Promise<string[]> {
if (options.migration) return [migrationLogPath(options.migration)];
const root = migrationLogsRoot();
let entries: string[] = [];
try {
entries = await readdir(root);
} catch {
return [];
}
return entries.filter(entry => entry.endsWith(".jsonl")).sort().map(entry => join(root, entry));
}
export function migrationIdFromPath(path: string): string {
return path.split("/").pop()?.replace(/\.jsonl$/, "") ?? "unknown";
}
export function riskySupersedeReasons(record: MigrationLogRecord): string[] {
const reasons: string[] = [];
const hardReasonsMissing = !Array.isArray(record.hardReasons);
const hardReasons = Array.isArray(record.hardReasons) ? record.hardReasons : [];
const qualityReasons = Array.isArray(record.reasons) ? record.reasons : [];
const text = record.text ?? "";
if (hardReasonsMissing || hardReasons.length === 0) reasons.push("missing_or_empty_hardReasons");
if (qualityReasons.length > 0 && hardReasons.length === 0) reasons.push("soft_reasons_without_hardReasons");
if (/\b(?:User|user|prefers|requires|wants|insists)\b|用戶|使用者|偏好|要求|不要|不刪除/u.test(text)) reasons.push("user_preference_marker");
if (/\b(?:must|should|do not|never|is|are|follows)\b|必須|應該|採用|維持|需支援/iu.test(text)) reasons.push("durable_rule_marker");
if ((record.type === "feedback" || record.type === "decision") && hardReasons.length === 1 && hardReasons[0] === "path_heavy") {
reasons.push("feedback_or_decision_path_heavy_only");
}
return reasons;
}
-136
View File
@@ -1,136 +0,0 @@
import { extractionRejectionLogPath, workspaceKey } from "../../src/paths.ts";
import { HARD_QUALITY_REASONS, isArchitectureLikeDecision } from "../../src/memory-quality.ts";
import { ALLOWED_ORIGINS } from "./constants.ts";
import { readJSONLFile } from "./io.ts";
import { canonicalMemoryText, cleanText, countBy, objectFromCounts, truncate } from "./text.ts";
import { CliInputError, type CliOptions, type NormalizedRejection, type Origin, type RejectionLogRecord } from "./types.ts";
export { CliInputError } from "./types.ts";
export function inferOrigin(record: RejectionLogRecord): Origin {
if (record.origin && ALLOWED_ORIGINS.has(record.origin as Origin)) return record.origin as Origin;
if (record.source === "compaction") return "compaction_candidate";
if (record.source === "explicit") return "explicit_trigger";
if (record.source === "manual") return "manual";
return "unknown";
}
export function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | null {
if (!record.text || !Array.isArray(record.reasons)) return null;
const origin = inferOrigin(record);
return {
timestamp: record.timestamp ?? "",
workspaceKey: record.workspaceKey,
workspaceRoot: record.workspaceRoot,
workspaceRootHash: record.workspaceRootHash,
type: record.type ?? "project",
source: record.source,
origin,
fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger",
text: record.text,
reasons: record.reasons,
};
}
export function sinceCutoff(rawSince: string | undefined, now = Date.now()): number | null {
if (!rawSince) return null;
const relative = rawSince.match(/^(\d+)([dhm])$/i);
if (relative) {
const amount = Number(relative[1]);
const unit = relative[2].toLowerCase();
const multiplier = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : 60_000;
return now - amount * multiplier;
}
const timestamp = new Date(rawSince).getTime();
if (Number.isNaN(timestamp)) throw new CliInputError(`Invalid --since value: ${rawSince}`);
return timestamp;
}
export function hasSoftReason(record: NormalizedRejection): boolean {
return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason));
}
export function hasWorkspaceScope(record: NormalizedRejection): boolean {
return Boolean(record.workspaceKey || record.workspaceRoot || record.workspaceRootHash);
}
export async function workspaceKeyForOption(options: CliOptions): Promise<string | undefined> {
return options.workspace ? workspaceKey(options.workspace) : undefined;
}
export async function loadRejectionRecords(options: CliOptions): Promise<{ path: string; invalidLines: number; records: NormalizedRejection[] }> {
const path = extractionRejectionLogPath();
const { records, invalidLines } = await readJSONLFile<RejectionLogRecord>(path);
const cutoff = sinceCutoff(options.since);
const requestedWorkspaceKey = await workspaceKeyForOption(options);
let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null);
if (requestedWorkspaceKey) {
normalized = normalized.filter(record => !record.workspaceKey || record.workspaceKey === requestedWorkspaceKey);
}
if (cutoff !== null) {
normalized = normalized.filter(record => {
const timestamp = new Date(record.timestamp).getTime();
return !Number.isNaN(timestamp) && timestamp >= cutoff;
});
}
if (options.softOnly) normalized = normalized.filter(hasSoftReason);
if (options.triggerOnly) normalized = normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger");
if (options.reason) normalized = normalized.filter(record => record.reasons.includes(options.reason ?? ""));
return { path, invalidLines, records: normalized };
}
export function uniqueByCanonicalText(records: NormalizedRejection[]): NormalizedRejection[] {
const byText = new Map<string, NormalizedRejection>();
for (const record of records) {
const key = `${record.type}:${canonicalMemoryText(record.text)}`;
if (!byText.has(key)) byText.set(key, record);
}
return [...byText.values()];
}
export function rejectionQualitySummary(records: NormalizedRejection[]): {
totalRecords: number;
uniqueTexts: number;
workspaceScopedCount: number;
legacyUnscopedCount: number;
reasonDistribution: Record<string, number>;
uniqueReasonDistribution: Record<string, number>;
possibleFalsePositiveGroups: Record<string, { count: number; samples: string[] }>;
} {
const uniqueRecords = uniqueByCanonicalText(records);
const badDecisionUnique = uniqueRecords.filter(record => record.reasons.includes("bad_decision"));
const groups: Record<string, { count: number; samples: string[] }> = {
architecture_like_possible_false_positive: { count: 0, samples: [] },
clearly_garbage: { count: 0, samples: [] },
ambiguous: { count: 0, samples: [] },
};
for (const record of badDecisionUnique) {
const hardReasons = record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason));
const statusLike = /\b(?:implemented|added|updated|fixed|completed|reviewed|tests?|CI|commit|wave|phase|task|session)\b/i.test(record.text);
const group = isArchitectureLikeDecision(record.text) && hardReasons.length === 0 && !statusLike
? "architecture_like_possible_false_positive"
: hardReasons.length > 0 || statusLike
? "clearly_garbage"
: "ambiguous";
groups[group].count += 1;
if (groups[group].samples.length < 5) groups[group].samples.push(truncate(cleanText(record.text, false), 120));
}
return {
totalRecords: records.length,
uniqueTexts: uniqueRecords.length,
workspaceScopedCount: records.filter(hasWorkspaceScope).length,
legacyUnscopedCount: records.filter(record => !hasWorkspaceScope(record)).length,
reasonDistribution: objectFromCounts(countBy(records.flatMap(record => record.reasons))),
uniqueReasonDistribution: objectFromCounts(countBy(uniqueRecords.flatMap(record => record.reasons))),
possibleFalsePositiveGroups: groups,
};
}
export function rejectionFalsePositiveRisk(summary: ReturnType<typeof rejectionQualitySummary>): "low" | "high" {
const possible = summary.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count;
return possible >= 3 || (summary.uniqueTexts > 0 && possible / summary.uniqueTexts >= 0.5) ? "high" : "low";
}
-84
View File
@@ -1,84 +0,0 @@
import {
RETENTION_TYPE_MAX,
calculateRetentionStrength,
} from "../../src/retention.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, WorkspaceMemoryStore } from "../../src/types.ts";
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../../src/types.ts";
import type { RetentionDiagItem } from "./types.ts";
export function ageDays(entry: LongTermMemoryEntry, now = Date.now()): number | null {
const time = new Date(entry.createdAt).getTime();
if (Number.isNaN(time)) return null;
return Math.floor((now - time) / 86_400_000);
}
export function formatStrength(value: number): string {
return Number.isFinite(value) ? value.toFixed(3) : "0.000";
}
export function daysSinceIso(value: string | undefined, now = Date.now()): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
if (!Number.isFinite(ms)) return null;
return Math.max(0, (now - ms) / 86_400_000);
}
export function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean {
return entry.safetyCritical === true;
}
export function retentionCandidatesForDiag(store: WorkspaceMemoryStore, now = Date.now()): {
sorted: RetentionDiagItem[];
rendered: RetentionDiagItem[];
typeCapped: RetentionDiagItem[];
globalCapped: RetentionDiagItem[];
} {
const active = store.entries.filter(entry => entry.status !== "superseded");
const sorted = active
.map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) }))
.sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id));
const rendered: RetentionDiagItem[] = [];
const typeCapped: RetentionDiagItem[] = [];
const globalCapped: RetentionDiagItem[] = [];
const typeCounts: Partial<Record<LongTermType, number>> = {};
for (const item of sorted) {
const count = typeCounts[item.entry.type] ?? 0;
const max = RETENTION_TYPE_MAX[item.entry.type] ?? Infinity;
if (count >= max) {
typeCapped.push(item);
continue;
}
typeCounts[item.entry.type] = count + 1;
if (rendered.length < LONG_TERM_LIMITS.maxEntries) {
rendered.push(item);
} else {
globalCapped.push(item);
}
}
return { sorted, rendered, typeCapped, globalCapped };
}
export function promotionLimit(source: LongTermSource): number {
if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts;
return PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
}
export function retentionClockSummary(entries: LongTermMemoryEntry[]): { present: number; missing: number; invalid: number } {
let present = 0;
let missing = 0;
let invalid = 0;
for (const entry of entries) {
if (entry.retentionClock === undefined) {
missing += 1;
} else if (!Number.isFinite(entry.retentionClock) || entry.retentionClock <= 0) {
invalid += 1;
} else {
present += 1;
}
}
return { present, missing, invalid };
}
-69
View File
@@ -1,69 +0,0 @@
import { createHash } from "node:crypto";
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import { redactCredentials } from "../../src/redaction.ts";
export function countBy<T extends string>(items: T[]): Map<T, number> {
const counts = new Map<T, number>();
for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1);
return counts;
}
export function sortedCounts<T extends string>(counts: Map<T, number>): Array<[T, number]> {
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
export function workspaceRootHash(root: string): string {
return createHash("sha256").update(root).digest("hex").slice(0, 16);
}
export function redactAbsolutePaths(text: string): string {
return text.replace(/(?:^|[\s"'`(=:\[])(\/(?:Users|home|private|tmp|var|opt|Volumes|[^\s"'`)\],;:]+)\/[^\s"'`)\],;:]*)/g, (match, path) => match.replace(path, "<path>"));
}
export function cleanText(text: string, raw: boolean): string {
if (raw) return text;
return redactAbsolutePaths(redactCredentials(text));
}
export function cleanPath(path: string, raw: boolean): string {
return raw ? path : "<path>";
}
export function formatWorkspaceIdentity(workspaceKeyValue: string | undefined, workspaceRoot: string | undefined, raw: boolean): string {
const parts: string[] = [];
if (workspaceKeyValue) parts.push(`workspaceKey=${workspaceKeyValue}`);
if (workspaceRoot) {
parts.push(raw ? `workspaceRoot=${workspaceRoot}` : `workspaceRootHash=${workspaceRootHash(workspaceRoot)}`);
}
return parts.join(" ");
}
export function truncate(text: string, max = 120): string {
const collapsed = text.replace(/\s+/g, " ").trim();
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
}
export function canonicalMemoryText(text: string): string {
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[\s\p{P}]+/gu, " ")
.trim();
}
export function formatPercent(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
export function uniqueStrings(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
export function objectFromCounts<T extends string>(counts: Map<T, number>): Record<string, number> {
return Object.fromEntries(sortedCounts(counts));
}
export function formatDetails(details: EvidenceEventV1["details"]): string {
if (!details || Object.keys(details).length === 0) return "none";
return Object.entries(details).map(([key, value]) => `${key}=${Array.isArray(value) ? value.join("|") : String(value)}`).join(" ");
}
-30
View File
@@ -1,30 +0,0 @@
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import { uniqueStrings } from "./text.ts";
import { statusFromOmissionReason } from "./workspace-snapshot.ts";
export function statusFromTraceEvent(event: EvidenceEventV1 | undefined): string {
if (!event) return "unknown";
if (event.type === "render_selected") return "rendered";
if (event.type === "render_omitted") return statusFromOmissionReason(event.reasonCodes[0]);
if (event.type === "promotion_absorbed_exact" || event.type === "promotion_absorbed_identity") return "omitted_absorbed_duplicate";
if (event.type === "promotion_retry_scheduled") return "pending_retry";
if (event.type === "promotion_rejected_capacity" || event.type === "promotion_retry_exhausted") return "pending_rejected_capacity";
if (event.type === "storage_corrupt_json_quarantined") return "quarantined_corrupt_store";
if (event.outcome === "superseded") return "omitted_superseded";
return event.outcome;
}
export function formatTraceEvent(event: EvidenceEventV1): string {
const reasons = event.reasonCodes.length > 0 ? event.reasonCodes.join(",") : "none";
const relations = (event.relations ?? [])
.map(relation => relation.memory?.memoryId ? `${relation.role}=${relation.memory.memoryId}` : undefined)
.filter((value): value is string => Boolean(value));
const relationText = relations.length > 0 ? `; ${relations.join(", ")}` : "";
return `- ${event.eventId} ${event.type}: ${event.outcome}; reasons=${reasons}${relationText}`;
}
export function relationMemoryIds(events: EvidenceEventV1[], role: string): string[] {
return uniqueStrings(events.flatMap(event => (event.relations ?? [])
.filter(relation => relation.role === role)
.map(relation => relation.memory?.memoryId ?? "")));
}
-157
View File
@@ -1,157 +0,0 @@
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome } from "../../src/evidence-log.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts";
import type { Command } from "./command-metadata.ts";
export type { Command, HiddenCommand, VisibleCommand } from "./command-metadata.ts";
export type MemoryRenderStatus =
| "rendered"
| "omitted_superseded"
| "omitted_type_cap"
| "omitted_global_cap"
| "omitted_char_budget"
| "omitted_absorbed_duplicate"
| "pending_retry"
| "pending_rejected_capacity"
| "quarantined_corrupt_store";
export type MemoryDiagJSON = {
version: 1;
workspace: { rootHash: string; key: string };
generatedAt: string;
summary: {
storedActive: number;
rendered: number;
pending: number;
rejectedLast7Days: number;
corruptStoresQuarantinedLast30Days: number;
status?: "ok" | "warning" | "degraded";
evidenceCoveragePercent?: number;
needsAttention?: string[];
suggestedNextSteps?: string[];
};
memories: Array<{
id: string;
type: "feedback" | "project" | "decision" | "reference";
source: "explicit" | "compaction" | "manual";
status: MemoryRenderStatus;
strength?: number;
reasonCodes: string[];
textPreview?: string;
evidenceEventIds: string[];
}>;
recentEvents: Array<{
eventId: string;
type: EvidenceEventType;
outcome: EvidenceOutcome;
createdAt: string;
memoryId?: string;
reasonCodes: string[];
}>;
};
export type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
export type CliOptions = {
raw: boolean;
json?: boolean;
verbose?: boolean;
noEmoji?: boolean;
workspace?: string;
all?: boolean;
softOnly?: boolean;
triggerOnly?: boolean;
includeHistorical?: boolean;
reason?: string;
explain?: boolean;
since?: string;
migration?: string;
memory?: string;
positional?: string[];
auditMode?: "coverage" | "migrations";
};
export type ParsedArgs =
| { ok: true; command: Command; options: CliOptions; deprecationNotice?: string }
| { ok: true; help: true; usage: string }
| { ok: false; message: string; usage: string; exitCode: number };
export type CommandResult = { stdout: string; stderr?: string; exitCode?: number };
export class CliInputError extends Error {}
export type RejectionLogRecord = {
timestamp?: string;
workspaceKey?: string;
workspaceRoot?: string;
workspaceRootHash?: string;
type?: LongTermType;
source?: LongTermSource | string;
origin?: string;
fromTrigger?: boolean;
text?: string;
reasons?: string[];
};
export type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
workspaceKey?: string;
workspaceRoot?: string;
workspaceRootHash?: string;
source?: string;
origin: Origin;
fromTrigger: boolean;
};
export type MigrationLogRecord = {
migrationId?: string;
timestamp?: string;
workspaceKey?: string;
workspaceRoot?: string;
entryId?: string;
type?: LongTermType;
source?: LongTermSource | string;
text?: string;
reasons?: string[];
hardReasons?: string[];
beforeStatus?: string;
afterStatus?: string;
};
export type RetentionDiagItem = {
entry: LongTermMemoryEntry;
strength: number;
};
export type WorkspaceDiagSnapshot = {
store: WorkspaceMemoryStore;
journal: PendingMemoryJournalStore;
retention: {
sorted: RetentionDiagItem[];
rendered: RetentionDiagItem[];
typeCapped: RetentionDiagItem[];
globalCapped: RetentionDiagItem[];
};
memories: MemoryDiagJSON["memories"];
recentEvents: MemoryDiagJSON["recentEvents"];
allEvents: EvidenceEventV1[];
summary: MemoryDiagJSON["summary"];
};
export type MemoryInspectionReadModel = {
snapshot: WorkspaceDiagSnapshot;
store: WorkspaceMemoryStore;
pending: PendingMemoryJournalStore;
evidenceEvents: EvidenceEventV1[];
rejectionRecords: NormalizedRejection[];
currentById: Map<string, LongTermMemoryEntry>;
evidenceByMemoryId: Map<string, EvidenceEventV1[]>;
};
export type CoverageClass = "full_lifecycle" | "render_only" | "no_evidence" | "historical_absent_with_reason" | "historical_absent_unknown_reason";
export type DisappearanceReason = {
classification: "historical_absent_with_reason" | "historical_absent_unknown_reason";
terminalType: EvidenceEventType | "unknown";
reasonCodes: string[];
event?: EvidenceEventV1;
};
-236
View File
@@ -1,236 +0,0 @@
import { calculateRetentionStrength } from "../../src/retention.ts";
import {
queryEvidenceEvents,
type EvidenceEventV1,
} from "../../src/evidence-log.ts";
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../../src/paths.ts";
import { accountWorkspaceMemoryRender } from "../../src/workspace-memory.ts";
import type { LongTermMemoryEntry, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts";
import { LONG_TERM_LIMITS } from "../../src/types.ts";
import { readJSONFile } from "./io.ts";
import { groupEvidenceByMemoryId } from "./evidence-model.ts";
import { promotionLimit, retentionCandidatesForDiag } from "./retention-model.ts";
import { cleanText, truncate, uniqueStrings, workspaceRootHash } from "./text.ts";
import type { CliOptions, MemoryDiagJSON, MemoryRenderStatus, WorkspaceDiagSnapshot } from "./types.ts";
export function emptyStore(root: string, key: string): WorkspaceMemoryStore {
return {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [],
migrations: [],
updatedAt: new Date(0).toISOString(),
};
}
export function normalizedStore(store: WorkspaceMemoryStore | null, root: string, key: string): WorkspaceMemoryStore {
const fallback = emptyStore(root, key);
return {
...fallback,
...(store ?? {}),
workspace: store?.workspace ?? fallback.workspace,
limits: {
maxRenderedChars: store?.limits?.maxRenderedChars ?? fallback.limits.maxRenderedChars,
maxEntries: store?.limits?.maxEntries ?? fallback.limits.maxEntries,
},
entries: Array.isArray(store?.entries) ? store.entries : [],
migrations: Array.isArray(store?.migrations) ? store.migrations : [],
};
}
export function normalizedJournal(journal: PendingMemoryJournalStore | null): PendingMemoryJournalStore {
return {
version: 1,
workspace: journal?.workspace ?? { root: "", key: "" },
entries: Array.isArray(journal?.entries) ? journal.entries : [],
updatedAt: journal?.updatedAt ?? new Date(0).toISOString(),
};
}
export function eventMemoryId(event: EvidenceEventV1): string | undefined {
return event.memory?.memoryId
?? event.relations?.map(relation => relation.memory?.memoryId).find((id): id is string => Boolean(id));
}
export function isWithinDays(iso: string, days: number, now = Date.now()): boolean {
const ms = new Date(iso).getTime();
return Number.isFinite(ms) && ms >= now - days * 86_400_000;
}
export function renderStatusReason(status: MemoryRenderStatus, fallback?: string): string[] {
switch (status) {
case "rendered": return ["within_caps", "within_char_budget"];
case "omitted_superseded": return ["superseded"];
case "omitted_type_cap": return ["type_cap"];
case "omitted_global_cap": return ["global_cap"];
case "omitted_char_budget": return [fallback === "empty_render_budget" ? "empty_render_budget" : "char_budget"];
case "omitted_absorbed_duplicate": return ["absorbed_duplicate"];
case "pending_retry": return ["retryable_capacity_rejection"];
case "pending_rejected_capacity": return ["capacity_rejected", "max_attempts_reached"];
case "quarantined_corrupt_store": return ["invalid_json"];
}
}
export function statusFromOmissionReason(reason: string | undefined): MemoryRenderStatus {
if (reason === "superseded") return "omitted_superseded";
if (reason === "type_cap") return "omitted_type_cap";
if (reason === "global_cap") return "omitted_global_cap";
return "omitted_char_budget";
}
export function pendingStatus(entry: LongTermMemoryEntry): MemoryRenderStatus {
const attempts = entry.promotionAttempts ?? 0;
return attempts >= promotionLimit(entry.source) ? "pending_rejected_capacity" : "pending_retry";
}
export function safeTextPreview(text: string): string {
return truncate(cleanText(text, false), 120);
}
function evidenceSummaryForMemory(grouped: Map<string, EvidenceEventV1[]>, memoryId: string): { eventIds: string[]; reasonCodes: string[] } {
const events = grouped.get(memoryId) ?? [];
const reasonCodes = new Set<string>();
for (const event of events) {
for (const reason of event.reasonCodes) reasonCodes.add(reason);
}
return {
eventIds: events.map(event => event.eventId),
reasonCodes: [...reasonCodes],
};
}
export async function buildWorkspaceDiagSnapshot(input: {
root: string;
key: string;
memoryPath: string;
pendingPath: string;
}, now = Date.now()): Promise<WorkspaceDiagSnapshot> {
const rawStore = await readJSONFile<WorkspaceMemoryStore>(input.memoryPath);
const storeRoot = rawStore?.workspace?.root ?? input.root;
const storeKey = rawStore?.workspace?.key ?? input.key;
const store = normalizedStore(rawStore, storeRoot, storeKey);
const journal = normalizedJournal(await readJSONFile<PendingMemoryJournalStore>(input.pendingPath));
const retention = retentionCandidatesForDiag(store, now);
const renderAccounting = accountWorkspaceMemoryRender(store);
const renderedIds = new Set(renderAccounting.rendered.map(memory => memory.id));
const omittedById = new Map(renderAccounting.omitted.map(item => [item.memory.id, item.reason]));
const allEvents = await queryEvidenceEvents(input.root);
const evidenceByMemoryId = groupEvidenceByMemoryId(allEvents);
const recentEvidence = await queryEvidenceEvents(input.root, { newestFirst: true, limit: 50 });
const memoryRows: MemoryDiagJSON["memories"] = [];
const seenIds = new Set<string>();
for (const entry of store.entries) {
const omissionReason = omittedById.get(entry.id);
const status: MemoryRenderStatus = renderedIds.has(entry.id)
? "rendered"
: statusFromOmissionReason(omissionReason ?? (entry.status === "superseded" ? "superseded" : undefined));
const summary = evidenceSummaryForMemory(evidenceByMemoryId, entry.id);
memoryRows.push({
id: entry.id,
type: entry.type,
source: entry.source,
status,
strength: calculateRetentionStrength(entry, now, store.lastActivityAt),
reasonCodes: uniqueStrings([...renderStatusReason(status, omissionReason), ...summary.reasonCodes]),
textPreview: safeTextPreview(entry.text),
evidenceEventIds: summary.eventIds,
});
seenIds.add(entry.id);
}
for (const entry of journal.entries) {
const status = pendingStatus(entry);
const summary = evidenceSummaryForMemory(evidenceByMemoryId, entry.id);
memoryRows.push({
id: entry.id,
type: entry.type,
source: entry.source,
status,
strength: calculateRetentionStrength(entry, now, store.lastActivityAt),
reasonCodes: uniqueStrings([...renderStatusReason(status), entry.lastPromotionFailureReason ?? "", ...summary.reasonCodes]),
textPreview: safeTextPreview(entry.text),
evidenceEventIds: summary.eventIds,
});
seenIds.add(entry.id);
}
for (const event of allEvents) {
if (event.outcome !== "absorbed") continue;
const memory = event.memory;
if (!memory?.memoryId || !memory.type || !memory.source || seenIds.has(memory.memoryId)) continue;
const summary = evidenceSummaryForMemory(evidenceByMemoryId, memory.memoryId);
memoryRows.push({
id: memory.memoryId,
type: memory.type,
source: memory.source,
status: "omitted_absorbed_duplicate",
reasonCodes: uniqueStrings([...renderStatusReason("omitted_absorbed_duplicate"), ...summary.reasonCodes]),
evidenceEventIds: summary.eventIds.length > 0 ? summary.eventIds : [event.eventId],
});
seenIds.add(memory.memoryId);
}
const recentEvents = recentEvidence.map(event => ({
eventId: event.eventId,
type: event.type,
outcome: event.outcome,
createdAt: event.createdAt,
memoryId: eventMemoryId(event),
reasonCodes: uniqueStrings([
...event.reasonCodes,
...(event.type === "storage_corrupt_json_quarantined" ? ["quarantined_corrupt_store"] : []),
]),
}));
return {
store,
journal,
retention,
memories: memoryRows,
recentEvents,
allEvents,
summary: {
storedActive: store.entries.filter(entry => entry.status !== "superseded").length,
rendered: retention.rendered.length,
pending: journal.entries.length,
rejectedLast7Days: allEvents.filter(event => event.outcome === "rejected" && isWithinDays(event.createdAt, 7, now)).length,
corruptStoresQuarantinedLast30Days: allEvents.filter(event => event.type === "storage_corrupt_json_quarantined" && isWithinDays(event.createdAt, 30, now)).length,
},
};
}
export async function buildMemoryDiagJSON(root: string): Promise<MemoryDiagJSON> {
const key = await workspaceKey(root);
const snapshot = await buildWorkspaceDiagSnapshot({
root,
key,
memoryPath: await workspaceMemoryPath(root),
pendingPath: await workspacePendingJournalPath(root),
});
return memoryDiagJSONFromSnapshot(root, snapshot);
}
export function memoryDiagJSONFromSnapshot(root: string, snapshot: WorkspaceDiagSnapshot, generatedAt = new Date().toISOString()): MemoryDiagJSON {
return {
version: 1,
workspace: { rootHash: workspaceRootHash(snapshot.store.workspace.root || root), key: snapshot.store.workspace.key },
generatedAt,
summary: snapshot.summary,
memories: snapshot.memories,
recentEvents: snapshot.recentEvents,
};
}
export async function snapshotForOptions(options: CliOptions): Promise<WorkspaceDiagSnapshot> {
const root = options.workspace ?? process.cwd();
const key = await workspaceKey(root);
return buildWorkspaceDiagSnapshot({
root,
key,
memoryPath: await workspaceMemoryPath(root),
pendingPath: await workspacePendingJournalPath(root),
});
}
-1
View File
@@ -20,7 +20,6 @@ export type EvidenceEventType =
| "promotion_retry_scheduled" | "promotion_retry_scheduled"
| "promotion_retry_exhausted" | "promotion_retry_exhausted"
| "memory_reinforced" | "memory_reinforced"
| "memory_migration_superseded"
| "render_selected" | "render_selected"
| "render_omitted" | "render_omitted"
| "memory_removed_capacity" | "memory_removed_capacity"
-3
View File
@@ -116,9 +116,6 @@ function buildCompactionPrompt(privateContext: string): string {
"", "",
"CRITICAL MEMORY RULES:", "CRITICAL MEMORY RULES:",
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.", "- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
"- Existing workspace memory may already contain durable facts. If a fact is already present and still accurate, do not create a rephrased duplicate.",
"- If the same durable fact truly needs to be emitted again, reuse the existing memory wording exactly whenever possible.",
"- Only emit a new memory when the fact is new, materially corrected, or materially more specific than the existing memory.",
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.", "- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.", "- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.", "- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
+34 -135
View File
@@ -189,150 +189,49 @@ export function clearErrorsForSuccessfulCommand(state: SessionState, command: st
state.updatedAt = new Date().toISOString(); 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 = { const lines: string[] = ["Hot session state (current session):"];
section: HotStateSection;
reason: HotStateOmissionReason;
text: string;
memoryId?: string;
};
export type HotSessionStateRenderAccounting = { if (activeFiles.length > 0) {
prompt: string; lines.push("active_files:");
omitted: HotStateOmittedItem[]; for (const item of activeFiles) {
maxRenderedChars: number; const viewPath = displayPath(workspaceRoot, item.path);
}; lines.push(`- ${viewPath} (${item.action}, ${item.count}x)`);
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 (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 { if (pendingMemories.length > 0) {
return accountHotSessionStateRender(state, workspaceRoot).prompt; lines.push("pending_memories:");
} for (const memory of pendingMemories) {
lines.push(`- [${memory.type}] ${memory.text}`);
}
}
function buildHotStateRenderSections( return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
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;
} }
function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] { function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] {
+15 -90
View File
@@ -12,7 +12,6 @@ import {
reinforceMemory, reinforceMemory,
} from "./retention.ts"; } from "./retention.ts";
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts"; import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
import { appendEvidenceEvents } from "./evidence-log.ts";
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory> // Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
const MIN_ENVELOPE_LENGTH = 80; const MIN_ENVELOPE_LENGTH = 80;
@@ -74,17 +73,6 @@ export type QualityCleanupMigrationLogEntry = {
afterStatus: "superseded"; afterStatus: "superseded";
}; };
export type P0CleanupMigrationResult = {
store: WorkspaceMemoryStore;
events: EvidenceEventInput[];
};
export type QualityCleanupMigrationResult = {
store: WorkspaceMemoryStore;
events: QualityCleanupMigrationLogEntry[];
evidence: EvidenceEventInput[];
};
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> { export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
return { return {
@@ -126,14 +114,6 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
// writes for ordering/capacity/timestamp-only normalization. // writes for ordering/capacity/timestamp-only normalization.
if (hasSecurityOrMigrationChange(store, normalized.store)) { if (hasSecurityOrMigrationChange(store, normalized.store)) {
await atomicWriteJSON(path, normalized.store); await atomicWriteJSON(path, normalized.store);
// loadWorkspaceMemory has a narrow migration side effect: when a first-load
// migration actually supersedes entries, append evidence for those storage
// changes. Migration IDs keep this idempotent on repeated loads.
const migrationEvidence = normalized.evidence.filter(event => event.type === "memory_migration_superseded");
if (migrationEvidence.length > 0) {
await appendEvidenceEvents(root, migrationEvidence);
}
} }
return normalized.store; return normalized.store;
@@ -179,15 +159,8 @@ export async function updateWorkspaceMemoryWithAccounting(
const fallback = await emptyWorkspaceMemory(root); const fallback = await emptyWorkspaceMemory(root);
let finalResult: WorkspaceMemoryNormalizationResult | undefined; let finalResult: WorkspaceMemoryNormalizationResult | undefined;
const store = await updateJSON(path, () => fallback, async current => { const store = await updateJSON(path, () => fallback, async current => {
const currentNormalization = await normalizeWorkspaceMemoryWithAccounting(root, current); const normalized = await normalizeWorkspaceMemory(root, current);
const currentMigrationEvidence = currentNormalization.evidence.filter(event => event.type === "memory_migration_superseded"); finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized));
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(currentNormalization.store));
if (currentMigrationEvidence.length > 0) {
finalResult = {
...finalResult,
evidence: [...currentMigrationEvidence, ...finalResult.evidence],
};
}
return finalResult.store; return finalResult.store;
}); });
@@ -251,12 +224,10 @@ export async function normalizeWorkspaceMemoryWithAccounting(
const beforeQualityCleanup = result; const beforeQualityCleanup = result;
const qualityCleanup = runMigrationQualityCleanup(result, nowIso); const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
result = qualityCleanup.store; result = qualityCleanup.store;
let migrationEvidence: EvidenceEventInput[] = [];
let skipRemainingMigrations = false; let skipRemainingMigrations = false;
if (qualityCleanup.events.length > 0) { if (qualityCleanup.events.length > 0) {
try { try {
await appendQualityCleanupMigrationLog(qualityCleanup.events); await appendQualityCleanupMigrationLog(qualityCleanup.events);
migrationEvidence = [...migrationEvidence, ...qualityCleanup.evidence];
} catch (error) { } catch (error) {
console.error("[memory] failed to write quality cleanup migration log:", error); console.error("[memory] failed to write quality cleanup migration log:", error);
console.error("[memory] aborting migration to maintain audit trail integrity"); console.error("[memory] aborting migration to maintain audit trail integrity");
@@ -265,9 +236,7 @@ export async function normalizeWorkspaceMemoryWithAccounting(
} }
} }
if (!skipRemainingMigrations) { if (!skipRemainingMigrations) {
const p0Cleanup = runMigrationP0Cleanup(result, nowIso); result = runMigrationP0Cleanup(result, nowIso);
result = p0Cleanup.store;
migrationEvidence = [...migrationEvidence, ...p0Cleanup.events];
} }
result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs)); result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs));
@@ -300,7 +269,7 @@ export async function normalizeWorkspaceMemoryWithAccounting(
dropped: accounting.dropped, dropped: accounting.dropped,
absorbed: accounting.absorbed, absorbed: accounting.absorbed,
superseded: accounting.superseded, superseded: accounting.superseded,
evidence: [...migrationEvidence, ...accounting.evidence], evidence: accounting.evidence,
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded], events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
}; };
} }
@@ -326,38 +295,31 @@ function backfillRetentionClock(entry: LongTermMemoryEntry, nowMs: number): Long
export function runMigrationP0Cleanup( export function runMigrationP0Cleanup(
store: WorkspaceMemoryStore, store: WorkspaceMemoryStore,
nowIso: string, nowIso: string,
): P0CleanupMigrationResult { ): WorkspaceMemoryStore {
if (store.migrations?.includes(MIGRATION_ID)) { if (store.migrations?.includes(MIGRATION_ID)) {
return { store, events: [] }; return store;
} }
const events: EvidenceEventInput[] = [];
const entries = store.entries.map(entry => { const entries = store.entries.map(entry => {
if (entry.source !== "compaction") return entry; if (entry.source !== "compaction") return entry;
if (entry.type !== "project") return entry; if (entry.type !== "project") return entry;
if (entry.status === "superseded") return entry;
if (isProgressSnapshotViolation(entry.text)) { if (isProgressSnapshotViolation(entry.text)) {
const superseded = { return {
...entry, ...entry,
status: "superseded" as const, status: "superseded" as const,
updatedAt: nowIso, updatedAt: nowIso,
}; };
events.push(migrationSupersededEvidence(superseded, ["migration:p0_cleanup"], MIGRATION_ID));
return superseded;
} }
return entry; return entry;
}); });
return { return {
store: { ...store,
...store, entries,
entries, migrations: [...(store.migrations || []), MIGRATION_ID],
migrations: [...(store.migrations || []), MIGRATION_ID], updatedAt: nowIso,
updatedAt: nowIso,
},
events,
}; };
} }
@@ -371,13 +333,12 @@ async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationL
export function runMigrationQualityCleanup( export function runMigrationQualityCleanup(
store: WorkspaceMemoryStore, store: WorkspaceMemoryStore,
nowIso: string, nowIso: string,
): QualityCleanupMigrationResult { ): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) { if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
return { store, events: [], evidence: [] }; return { store, events: [] };
} }
const events: QualityCleanupMigrationLogEntry[] = []; const events: QualityCleanupMigrationLogEntry[] = [];
const evidence: EvidenceEventInput[] = [];
let changed = false; let changed = false;
const entries = store.entries.map(entry => { const entries = store.entries.map(entry => {
if (entry.source !== "compaction") return entry; if (entry.source !== "compaction") return entry;
@@ -411,19 +372,12 @@ export function runMigrationQualityCleanup(
...hardReasons.map(reason => `quality:${reason}`), ...hardReasons.map(reason => `quality:${reason}`),
]); ]);
const superseded = { return {
...entry, ...entry,
status: "superseded" as const, status: "superseded" as const,
updatedAt: nowIso, updatedAt: nowIso,
tags: [...tags], tags: [...tags],
}; };
evidence.push(migrationSupersededEvidence(
superseded,
["migration:quality_cleanup", ...hardReasons.map(reason => `quality:${reason}`)],
QUALITY_CLEANUP_MIGRATION_ID,
{ hardReasons },
));
return superseded;
}); });
return { return {
@@ -434,7 +388,6 @@ export function runMigrationQualityCleanup(
updatedAt: changed ? nowIso : store.updatedAt, updatedAt: changed ? nowIso : store.updatedAt,
}, },
events, events,
evidence,
}; };
} }
@@ -572,29 +525,6 @@ function capacityRemovalEvidence(
}; };
} }
function migrationSupersededEvidence(
memory: LongTermMemoryEntry,
reasonCodes: string[],
migrationId: string,
details: EvidenceEventInput["details"] = {},
): EvidenceEventInput {
return {
type: "memory_migration_superseded",
phase: "storage",
outcome: "superseded",
memory: memoryEvidenceRef(memory),
relations: [{ role: "superseded", memory: memoryEvidenceRef(memory) }],
reasonCodes,
details: {
migrationId,
type: memory.type,
source: memory.source,
...details,
},
textPreview: memory.text,
};
}
/** Choose better memory when identity/topic keys conflict */ /** Choose better memory when identity/topic keys conflict */
function chooseBetterMemory( function chooseBetterMemory(
a: LongTermMemoryEntry, a: LongTermMemoryEntry,
@@ -701,11 +631,6 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const evidence: EvidenceEventInput[] = []; const evidence: EvidenceEventInput[] = [];
// For project/reference/feedback: dedupe by concrete identity or exact canonical text. // For project/reference/feedback: dedupe by concrete identity or exact canonical text.
// Feedback is grouped with project/reference for entity dedupe, but
// workspaceMemoryIdentityKey() returns exact key for feedback (no concrete
// identity extraction). This means feedback absorbed_identity is currently
// impossible. When identity key extraction is extended to all types, feedback
// with matching concrete identifiers will correctly produce absorbed_identity.
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback"); const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
// Build identity key dedup for project/reference/feedback. // Build identity key dedup for project/reference/feedback.
@@ -749,7 +674,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const dropped = retained === entry ? existing : entry; const dropped = retained === entry ? existing : entry;
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing) const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const ? "absorbed_exact" as const
: "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs : "superseded_existing" as const;
const reinforced = reinforceMemory( const reinforced = reinforceMemory(
retained, retained,
reinforcementSessionId(retained, dropped), reinforcementSessionId(retained, dropped),
-35
View File
@@ -174,41 +174,6 @@ test("memory_removed_capacity event round-trips through append and query", async
} }
}); });
test("memory_migration_superseded event round-trips through append and query", async () => {
const root = await tempRoot();
try {
await appendEvidenceEvent(root, eventInput({
type: "memory_migration_superseded",
phase: "storage",
outcome: "superseded",
reasonCodes: ["migration:quality_cleanup", "quality:progress_snapshot"],
memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" },
relations: [{ role: "superseded", memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" } }],
details: {
migrationId: "2026-04-28-quality-cleanup",
type: "project",
source: "compaction",
},
}));
const result = await queryEvidenceEvents(root, {
types: ["memory_migration_superseded"],
phases: ["storage"],
outcomes: ["superseded"],
memoryId: "migrated-memory",
});
assert.equal(result.length, 1);
assert.equal(result[0].type, "memory_migration_superseded");
assert.equal(result[0].phase, "storage");
assert.equal(result[0].outcome, "superseded");
assert.ok(result[0].reasonCodes.includes("migration:quality_cleanup"));
assert.equal(result[0].memory?.status, "superseded");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("queryEvidenceEvents supports newestFirst and limit", async () => { test("queryEvidenceEvents supports newestFirst and limit", async () => {
const root = await tempRoot(); const root = await tempRoot();
try { try {
-135
View File
@@ -1,135 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
import type { LongTermMemoryEntry } from "../src/types.ts";
import type { MemoryInspectionReadModel } from "../scripts/memory-diag/types.ts";
import { CliInputError, normalizeRejection, rejectionQualitySummary, sinceCutoff } from "../scripts/memory-diag/rejections-model.ts";
import { coverageRows, disappearanceRows } from "../scripts/memory-diag/inspection-model.ts";
import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts";
import { statusFromTraceEvent } from "../scripts/memory-diag/trace-model.ts";
function entry(id: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
const now = new Date("2026-01-01T00:00:00.000Z").toISOString();
return {
id,
type,
text: `${id} text`,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
};
}
function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 {
return {
version: 1,
eventId: `evt-${overrides.type}-${Math.random()}`,
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
workspaceKey: "workspace-key",
workspaceRootHash: "workspace-root-hash",
reasonCodes: [],
...overrides,
};
}
function model(entries: LongTermMemoryEntry[], events: EvidenceEventV1[]): MemoryInspectionReadModel {
return {
store: {
version: 1,
workspace: { root: "/tmp/workspace", key: "workspace-key" },
limits: { maxRenderedChars: 24_000, maxEntries: 28 },
entries,
migrations: [],
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
},
pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() },
evidenceEvents: events,
rejectionRecords: [],
currentById: new Map(entries.map(memory => [memory.id, memory])),
evidenceByMemoryId: groupEvidenceByMemoryId(events),
};
}
test("normalizeRejection infers origins from source", () => {
assert.equal(normalizeRejection({ source: "compaction", text: "a", reasons: ["bad_decision"] })?.origin, "compaction_candidate");
assert.equal(normalizeRejection({ source: "explicit", text: "a", reasons: ["bad_feedback"] })?.origin, "explicit_trigger");
assert.equal(normalizeRejection({ source: "manual", text: "a", reasons: ["bad_feedback"] })?.origin, "manual");
assert.equal(normalizeRejection({ source: "unknown-source", text: "a", reasons: ["bad_feedback"] })?.origin, "unknown");
});
test("sinceCutoff accepts relative durations and ISO timestamps", () => {
const now = new Date("2026-01-15T12:00:00.000Z").getTime();
assert.equal(sinceCutoff("14d", now), now - 14 * 86_400_000);
assert.equal(sinceCutoff("3h", now), now - 3 * 3_600_000);
assert.equal(sinceCutoff("30m", now), now - 30 * 60_000);
assert.equal(sinceCutoff("2026-01-01T00:00:00.000Z", now), new Date("2026-01-01T00:00:00.000Z").getTime());
assert.throws(() => sinceCutoff("forever", now), (error: unknown) => {
assert.ok(error instanceof CliInputError);
assert.equal((error as Error).message, "Invalid --since value: forever");
return true;
});
});
test("rejectionQualitySummary keeps architecture-like false-positive grouping", () => {
const records = [
normalizeRejection({ type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] }),
normalizeRejection({ type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] }),
normalizeRejection({ type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] }),
].filter(record => record !== null);
const summary = rejectionQualitySummary(records);
assert.equal(summary.totalRecords, 3);
assert.equal(summary.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
assert.equal(summary.possibleFalsePositiveGroups.clearly_garbage.count, 1);
assert.equal(summary.possibleFalsePositiveGroups.ambiguous.count, 1);
});
test("coverageRows classifies current and historical memory evidence", () => {
const entries = [entry("mem-full", "feedback"), entry("mem-render-only", "decision"), entry("mem-no-evidence", "project")];
const events = [
event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }),
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" } }),
event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-render-only", type: "decision", source: "compaction" } }),
event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "historical-cap", type: "project", source: "compaction" }, reasonCodes: ["global_cap"] }),
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "reference", source: "compaction" } }),
];
const rows = coverageRows(model(entries, events), true);
const byId = new Map(rows.map(row => [row.id, row.class]));
assert.equal(byId.get("mem-full"), "full_lifecycle");
assert.equal(byId.get("mem-render-only"), "render_only");
assert.equal(byId.get("mem-no-evidence"), "no_evidence");
assert.equal(byId.get("historical-cap"), "historical_absent_with_reason");
assert.equal(byId.get("historical-unknown"), "historical_absent_unknown_reason");
});
test("disappearanceRows surfaces terminal capacity evidence", () => {
const events = [
event({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" } }),
event({ type: "memory_removed_capacity", phase: "storage", outcome: "removed", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction" }, reasonCodes: ["type_cap"] }),
];
const rows = disappearanceRows(model([], events));
assert.equal(rows.length, 1);
assert.equal(rows[0].id, "capacity-loser");
assert.equal(rows[0].classification, "historical_absent_with_reason");
assert.equal(rows[0].terminalType, "memory_removed_capacity");
assert.deepEqual(rows[0].reasonCodes, ["type_cap"]);
});
test("statusFromTraceEvent maps lifecycle events", () => {
assert.equal(statusFromTraceEvent(undefined), "unknown");
assert.equal(statusFromTraceEvent(event({ type: "render_selected", phase: "render", outcome: "rendered" })), "rendered");
assert.equal(statusFromTraceEvent(event({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["type_cap"] })), "omitted_type_cap");
assert.equal(statusFromTraceEvent(event({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed" })), "omitted_absorbed_duplicate");
assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_scheduled", phase: "promotion", outcome: "retried" })), "pending_retry");
assert.equal(statusFromTraceEvent(event({ type: "promotion_retry_exhausted", phase: "promotion", outcome: "exhausted" })), "pending_rejected_capacity");
assert.equal(statusFromTraceEvent(event({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined" })), "quarantined_corrupt_store");
assert.equal(statusFromTraceEvent(event({ type: "promotion_superseded", phase: "promotion", outcome: "superseded" })), "omitted_superseded");
});
-105
View File
@@ -1,105 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseArgs } from "../scripts/memory-diag/cli.ts";
test("help returns usage without exposing hidden or removed commands", () => {
const parsed = parseArgs(["--help"]);
assert.equal(parsed.ok, true);
assert.equal("help" in parsed && parsed.help, true);
assert.match(parsed.usage, /Usage:/);
assert.match(parsed.usage, /memory-diag \[status\]/);
for (const command of ["health", "quality", "rejections", "disappearances", "trace", "coverage", "audit"]) {
assert.doesNotMatch(parsed.usage, new RegExp(command));
}
});
test("status defaults when no subcommand", () => {
const parsed = parseArgs([]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "status");
assert.deepEqual("options" in parsed && parsed.options, { raw: false, positional: [] });
});
test("unknown command returns usage error", () => {
const parsed = parseArgs(["unknown"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
assert.equal(parsed.message, "Unknown subcommand: unknown");
assert.equal(parsed.exitCode, 1);
assert.match(parsed.usage, /Usage:/);
});
test("removed legacy aliases are ordinary unknown subcommands", () => {
for (const command of ["health", "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: ["coverage", "--all"], message: "coverage does not accept --all" },
{ args: ["missing", "--all"], message: "missing does not accept --all" },
{ args: ["rejected", "--all"], message: "rejected does not accept --all" },
{ args: ["audit", "--workspace", "/tmp/workspace"], message: "audit does not accept --all or --workspace" },
{ args: ["explain", "--all"], message: "explain does not accept --all" },
{ args: ["status", "--since", "forever"], message: "status does not accept rejection filters" },
{ args: ["status", "--reason", "bad_decision"], message: "status does not accept rejection filters" },
{ args: ["status", "--quality"], message: "Unknown option: --quality" },
{ args: ["rejected", "--unique"], message: "Unknown option: --unique" },
];
for (const item of cases) {
const parsed = parseArgs(item.args);
assert.equal(parsed.ok, false, item.args.join(" "));
if (parsed.ok) continue;
assert.equal(parsed.message, item.message);
assert.match(parsed.usage, /Usage:/);
}
});
test("rejected invalid since value returns current error", () => {
const parsed = parseArgs(["rejected", "--since", "forever"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
assert.equal(parsed.message, "Invalid --since value: forever");
assert.match(parsed.usage, /Usage:/);
});
test("explain accepts positional memory id", () => {
const parsed = parseArgs(["explain", "mem-1", "--workspace", "/tmp/workspace"]);
assert.equal(parsed.ok, true);
assert.equal("command" in parsed && parsed.command, "explain");
assert.equal("options" in parsed && parsed.options.memory, "mem-1");
assert.deepEqual("options" in parsed && parsed.options.positional, ["mem-1"]);
});
test("explain with both positional and memory flag errors", () => {
const parsed = parseArgs(["explain", "mem-1", "--memory", "mem-2"]);
assert.equal(parsed.ok, false);
if (parsed.ok) return;
assert.equal(parsed.message, "Use either explain <memory-id> or --memory, not both");
});
-110
View File
@@ -1,110 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts";
import type { MemoryInspectionReadModel, WorkspaceDiagSnapshot } from "../scripts/memory-diag/types.ts";
import { formatCoverage } from "../scripts/memory-diag/formatters/coverage.ts";
import { formatMigrationAudit } from "../scripts/memory-diag/formatters/audit.ts";
import { formatExplain } from "../scripts/memory-diag/formatters/explain.ts";
import { buildMissingJSON, formatMissing } from "../scripts/memory-diag/formatters/missing.ts";
import { formatTrace } from "../scripts/memory-diag/formatters/trace.ts";
function emptyInspectionModel(): MemoryInspectionReadModel {
return {
store: {
version: 1,
workspace: { root: "/tmp/workspace", key: "workspace-key" },
limits: { maxRenderedChars: 24_000, maxEntries: 28 },
entries: [],
migrations: [],
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
},
pending: { version: 1, workspace: { root: "", key: "" }, entries: [], updatedAt: new Date(0).toISOString() },
evidenceEvents: [],
rejectionRecords: [],
currentById: new Map(),
evidenceByMemoryId: new Map(),
};
}
function emptySnapshot(): WorkspaceDiagSnapshot {
return {
store: emptyInspectionModel().store,
journal: emptyInspectionModel().pending,
retention: { sorted: [], rendered: [], typeCapped: [], globalCapped: [] },
memories: [],
recentEvents: [],
allEvents: [],
summary: {
storedActive: 0,
rendered: 0,
pending: 0,
rejectedLast7Days: 0,
corruptStoresQuarantinedLast30Days: 0,
},
};
}
function event(overrides: Partial<EvidenceEventV1> & { type: EvidenceEventType; phase: EvidencePhase; outcome: EvidenceOutcome }): EvidenceEventV1 {
return {
version: 1,
eventId: `evt-${overrides.type}`,
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
workspaceKey: "workspace-key",
workspaceRootHash: "workspace-root-hash",
reasonCodes: [],
...overrides,
};
}
test("coverage formatter includes class counts section", () => {
const output = formatCoverage([]);
assert.match(output, /Class counts:/);
assert.match(output, /Per-memory rows:\n \(none\)/);
});
test("missing formatter verbose output preserves disappearance details", () => {
const output = formatMissing([], { verbose: true });
assert.match(output, /No missing memories found\./);
});
test("missing JSON includes disappearance rows and summary", () => {
const rows = [{
id: "historical-1",
classification: "historical_absent_unknown_reason" as const,
terminalType: "unknown" as const,
reasonCodes: [],
events: [event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted" })],
event: undefined,
}];
const output = buildMissingJSON(rows, { generatedAt: "2026-01-01T00:00:00.000Z" });
assert.equal(output.version, 1);
assert.deepEqual(output.summary, { total: 1, explained: 0, needsReview: 1 });
assert.deepEqual((output.disappearances as Array<{ id: string }>)[0]?.id, "historical-1");
});
test("trace formatter includes lifecycle section", () => {
const output = formatTrace("mem-1", emptySnapshot(), {
events: [event({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-1", type: "feedback", source: "explicit" } })],
});
assert.match(output, /Lifecycle:/);
assert.match(output, /evt-render_selected render_selected/);
});
test("audit formatter preserves no-log output", () => {
const output = formatMigrationAudit([], { raw: false });
assert.match(output, /Migration audit report/);
assert.match(output, /No migration logs found\./);
});
test("explain formatter preserves no-memory output", () => {
const output = formatExplain(emptySnapshot());
assert.match(output, /Workspace memory explainability/);
assert.match(output, /No memories found\./);
});
-54
View File
@@ -1,54 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { canonicalMemoryText, cleanText, countBy, sortedCounts, truncate } from "../scripts/memory-diag/text.ts";
import { readJSONLFile } from "../scripts/memory-diag/io.ts";
test("cleanText redacts credentials and absolute paths unless raw", () => {
const text = "Use password: sushi and api_key=abc123 in /Users/alice/project/config.json";
const cleaned = cleanText(text, false);
assert.match(cleaned, /password: \[REDACTED\]/);
assert.match(cleaned, /api_key=\[REDACTED\]/);
assert.match(cleaned, /<path>/);
assert.doesNotMatch(cleaned, /sushi/);
assert.doesNotMatch(cleaned, /\/Users\/alice/);
assert.equal(cleanText(text, true), text);
});
test("truncate collapses whitespace and applies ellipsis at max length", () => {
assert.equal(truncate(" hello\n\tworld "), "hello world");
assert.equal(truncate("abcdef", 5), "abcd…");
});
test("canonicalMemoryText normalizes punctuation and case", () => {
assert.equal(canonicalMemoryText("Hello, WORLD!!! Path?"), "hello world path");
});
test("sortedCounts sorts by count descending then key ascending", () => {
const counts = new Map<string, number>([["b", 2], ["a", 2], ["c", 3]]);
assert.deepEqual(sortedCounts(counts), [["c", 3], ["a", 2], ["b", 2]]);
});
test("countBy counts string items", () => {
assert.deepEqual([...countBy(["beta", "alpha", "beta"]).entries()], [["beta", 2], ["alpha", 1]]);
});
test("readJSONLFile returns valid records and invalid line count", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-utils-"));
try {
const path = join(root, "records.jsonl");
await writeFile(path, '{"id":"one"}\nnot-json\n\n{"id":"two"}\n', "utf8");
const result = await readJSONLFile<{ id: string }>(path);
assert.deepEqual(result.records, [{ id: "one" }, { id: "two" }]);
assert.equal(result.invalidLines, 1);
} finally {
await rm(root, { recursive: true, force: true });
}
});
-224
View File
@@ -1,224 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { appendEvidenceEvents, queryEvidenceEvents, summarizeMemoryEvidence, type EvidenceEventInput, type EvidenceEventV1 } from "../src/evidence-log.ts";
import { groupEvidenceByMemoryId } from "../scripts/memory-diag/evidence-model.ts";
import { retentionCandidatesForDiag } from "../scripts/memory-diag/retention-model.ts";
import { buildMemoryDiagJSON, memoryDiagJSONFromSnapshot, normalizedJournal, normalizedStore, snapshotForOptions } from "../scripts/memory-diag/workspace-snapshot.ts";
import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { LONG_TERM_LIMITS, type LongTermMemoryEntry, type PendingMemoryJournalStore, type WorkspaceMemoryStore } from "../src/types.ts";
function entry(id: string, text: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
const now = new Date("2026-01-01T00:00:00.000Z").toISOString();
return {
id,
type,
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: new Date(now).getTime(),
};
}
function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
return {
type: "promotion_promoted",
phase: "promotion",
outcome: "promoted",
memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" },
reasonCodes: ["new_workspace_entry"],
...overrides,
};
}
function groupedEvidenceSummary(grouped: Map<string, EvidenceEventV1[]>, memoryId: string): { eventIds: string[]; reasonCodes: string[] } {
const events = grouped.get(memoryId) ?? [];
const reasonCodes = new Set<string>();
for (const event of events) {
for (const reason of event.reasonCodes) reasonCodes.add(reason);
}
return {
eventIds: events.map(event => event.eventId),
reasonCodes: [...reasonCodes],
};
}
async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
const key = await workspaceKey(root);
const path = await workspaceMemoryPath(root);
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries,
migrations: [],
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
};
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
const key = await workspaceKey(root);
const path = await workspacePendingJournalPath(root);
const store: PendingMemoryJournalStore = {
version: 1,
workspace: { root, key },
entries,
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
};
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
test("normalizedStore returns an empty store with limits and empty arrays", () => {
const store = normalizedStore(null, "/tmp/example-workspace", "workspace-key");
assert.equal(store.version, 1);
assert.deepEqual(store.workspace, { root: "/tmp/example-workspace", key: "workspace-key" });
assert.deepEqual(store.limits, { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries });
assert.deepEqual(store.entries, []);
assert.deepEqual(store.migrations, []);
});
test("normalizedJournal returns an empty journal", () => {
const journal = normalizedJournal(null);
assert.equal(journal.version, 1);
assert.deepEqual(journal.workspace, { root: "", key: "" });
assert.deepEqual(journal.entries, []);
assert.equal(journal.updatedAt, new Date(0).toISOString());
});
test("retentionCandidatesForDiag separates rendered, type-capped, and global-capped entries", () => {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 11 }, (_, i) => entry(`feedback-${String(i).padStart(2, "0")}`, `Feedback memory ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`decision-${String(i).padStart(2, "0")}`, `Decision memory ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`project-${String(i).padStart(2, "0")}`, `Project memory ${i}`, "project")),
...Array.from({ length: 6 }, (_, i) => entry(`reference-${String(i).padStart(2, "0")}`, `Reference memory ${i}`, "reference")),
];
const store = normalizedStore({ entries, workspace: { root: "/tmp/root", key: "key" } } as WorkspaceMemoryStore, "/tmp/root", "key");
const candidates = retentionCandidatesForDiag(store, new Date("2026-01-02T00:00:00.000Z").getTime());
assert.equal(candidates.rendered.length, LONG_TERM_LIMITS.maxEntries);
assert.equal(candidates.typeCapped.length, 1);
assert.equal(candidates.globalCapped.length, 6);
assert.equal(candidates.typeCapped[0].entry.type, "feedback");
});
test("buildMemoryDiagJSON redacts previews, includes pending entries, and preserves summary fields", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-workspace-model-"));
try {
const active = entry("mem-active", "Remember password: sushi and file /Users/alice/private.txt", "decision");
const pending = { ...entry("mem-pending", "Pending api_key=secret-value", "project"), promotionAttempts: 1 };
await writeWorkspaceStore(root, [active]);
await writePendingJournal(root, [pending]);
const diag = await buildMemoryDiagJSON(root);
assert.equal(diag.version, 1);
assert.equal(diag.summary.storedActive, 1);
assert.equal(diag.summary.rendered, 1);
assert.equal(diag.summary.pending, 1);
assert.equal(diag.summary.rejectedLast7Days, 0);
assert.equal(diag.summary.corruptStoresQuarantinedLast30Days, 0);
assert.equal(diag.memories.length, 2);
assert.equal(diag.memories.find(memory => memory.id === "mem-pending")?.status, "pending_retry");
assert.ok(diag.memories.some(memory => memory.textPreview?.includes("[REDACTED]")));
assert.ok(diag.memories.some(memory => memory.textPreview?.includes("<path>")));
assert.ok(!JSON.stringify(diag).includes("sushi"));
assert.ok(!JSON.stringify(diag).includes("secret-value"));
assert.equal(diag.workspace.key, await workspaceKey(root));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memoryDiagJSONFromSnapshot serializes an existing snapshot with fixed generatedAt", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-snapshot-json-"));
try {
const active = entry("mem-active", "Stable decision memory", "decision");
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
await writeWorkspaceStore(root, [active]);
await writePendingJournal(root, [pending]);
const snapshot = await snapshotForOptions({ raw: false, workspace: root });
const generatedAt = "2026-05-02T00:00:00.000Z";
const diag = memoryDiagJSONFromSnapshot(root, snapshot, generatedAt);
assert.equal(diag.version, 1);
assert.equal(diag.generatedAt, generatedAt);
assert.equal(diag.memories, snapshot.memories);
assert.equal(diag.recentEvents, snapshot.recentEvents);
assert.equal(diag.summary, snapshot.summary);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("grouped evidence summaries match per-memory summaries for stored pending and absorbed memories", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-equivalence-"));
try {
const active = entry("mem-active", "Stable decision memory", "decision");
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
await writeWorkspaceStore(root, [active]);
await writePendingJournal(root, [pending]);
await appendEvidenceEvents(root, [
evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }),
evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }),
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }),
]);
const grouped = groupEvidenceByMemoryId(await queryEvidenceEvents(root));
for (const id of ["mem-active", "mem-pending", "mem-absorbed"]) {
const oldSummary = await summarizeMemoryEvidence(root, { memoryId: id });
const groupedSummary = groupedEvidenceSummary(grouped, id);
assert.deepEqual(groupedSummary.eventIds, oldSummary.eventIds);
assert.deepEqual(groupedSummary.reasonCodes, oldSummary.reasonCodes);
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("buildMemoryDiagJSON preserves evidence ids and reason codes for stored pending and absorbed memories", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-evidence-rows-"));
try {
const active = entry("mem-active", "Stable decision memory", "decision");
const pending = { ...entry("mem-pending", "Pending project memory", "project"), promotionAttempts: 1 };
await writeWorkspaceStore(root, [active]);
await writePendingJournal(root, [pending]);
const events = await appendEvidenceEvents(root, [
evidence({ memory: { memoryId: "mem-active", type: "decision", source: "compaction", status: "active" }, reasonCodes: ["stored_reason"] }),
evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-pending", type: "project", source: "compaction" }, reasonCodes: ["pending_reason"] }),
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "compaction" }, reasonCodes: ["same_exact_key"] }),
]);
const diag = await buildMemoryDiagJSON(root);
const activeRow = diag.memories.find(memory => memory.id === "mem-active");
const pendingRow = diag.memories.find(memory => memory.id === "mem-pending");
const absorbedRow = diag.memories.find(memory => memory.id === "mem-absorbed");
assert.ok(activeRow);
assert.ok(pendingRow);
assert.ok(absorbedRow);
assert.deepEqual(activeRow.evidenceEventIds, [events[0].eventId]);
assert.ok(activeRow.reasonCodes.includes("stored_reason"));
assert.deepEqual(pendingRow.evidenceEventIds, [events[1].eventId]);
assert.ok(pendingRow.reasonCodes.includes("pending_reason"));
assert.deepEqual(absorbedRow.evidenceEventIds, [events[2].eventId]);
assert.ok(absorbedRow.reasonCodes.includes("same_exact_key"));
assert.ok(absorbedRow.reasonCodes.includes("absorbed_duplicate"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
+91 -347
View File
@@ -1,7 +1,6 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -10,7 +9,7 @@ import { promisify } from "node:util";
import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts"; import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } from "../src/types.ts"; import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } from "../src/types.ts";
import { extractionRejectionLogPath, workspaceEvidenceLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; import { extractionRejectionLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -47,23 +46,18 @@ async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[],
await writeFile(path, JSON.stringify(store, null, 2), "utf8"); await writeFile(path, JSON.stringify(store, null, 2), "utf8");
} }
async function runMemoryDiagStatusVerbose(root: string): Promise<string> { async function runMemoryDiagHealth(root: string): Promise<string> {
return runMemoryDiag(["status", "--workspace", root, "--verbose"]); return runMemoryDiag(["health", "--workspace", root]);
} }
async function runMemoryDiag(args: string[]): Promise<string> { async function runMemoryDiag(args: string[]): Promise<string> {
const { stdout } = await runMemoryDiagResult(args); const { stdout } = await execFileAsync(process.execPath, [
return stdout;
}
async function runMemoryDiagResult(args: string[], options: { cwd?: string } = {}): Promise<{ stdout: string; stderr: string }> {
const { stdout, stderr } = await execFileAsync(process.execPath, [
"--experimental-strip-types", "--experimental-strip-types",
"scripts/memory-diag.ts", "scripts/memory-diag.ts",
...args, ...args,
], { cwd: options.cwd ?? repoRoot }); ], { cwd: repoRoot });
return { stdout: stdout.trim(), stderr: stderr.trim() }; return stdout;
} }
async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> { async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
@@ -96,114 +90,6 @@ function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
}; };
} }
test("status 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]);
assert.match(stdout, /Memory status/);
assert.match(stdout, /active memories: 0/);
assert.match(stdout, /pending: 0/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("status verbose 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"]);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /Caps:/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("coverage and disappearances handle missing workspace store as empty", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-inspection-"));
try {
const coverageStdout = await runMemoryDiag(["coverage", "--workspace", root]);
assert.match(coverageStdout, /no_evidence: 0/);
assert.match(coverageStdout, /Per-memory rows:\n\s+\(none\)/);
const missingStdout = await runMemoryDiag(["missing", "--workspace", root]);
assert.match(missingStdout, /No missing memories found\./);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("status with unsupported all flag shows usage error", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-conflicting-flags-"));
try {
await assert.rejects(
runMemoryDiagResult(["status", "--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 ?? "", /Usage:/);
return true;
},
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag reports unexpected command errors without stack traces", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-error-boundary-"));
try {
await writeWorkspaceStore(root, []);
// Invalid rejection filter values are parse-caught before dispatch, so this
// fixture exercises the same top-level boundary with a command-thrown Error.
await mkdir(await workspaceEvidenceLogPath(root), { recursive: true });
await assert.rejects(
runMemoryDiagResult(["status", "--workspace", root]),
(error: unknown) => {
const err = error as { code?: number; stderr?: string };
assert.notEqual(err.code, 0);
assert.match(err.stderr ?? "", /memory-diag failed:/);
assert.doesNotMatch(err.stderr ?? "", /\n\s+at\s|Error:/);
return true;
},
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag defaults to status when no subcommand is supplied", async () => {
const result = await runMemoryDiagResult([]);
assert.match(result.stdout, /Memory status/);
assert.match(result.stdout, /Key metrics:/);
assert.equal(result.stderr, "");
});
test("removed legacy aliases return unknown subcommand", async () => {
const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-"));
try {
for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) {
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;
},
);
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports stored vs rendered retention counts", async () => { test("memory health reports stored vs rendered retention counts", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-")); const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try { try {
@@ -213,12 +99,11 @@ test("memory health reports stored vs rendered retention counts", async () => {
]; ];
await writeWorkspaceStore(root, entries); await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagStatusVerbose(root); const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Memory status inspection/); assert.match(stdout, /Stored active memories:/);
assert.match(stdout, /active: 28 \/ 28/); assert.match(stdout, /Rendered candidates:/);
assert.match(stdout, /rendered: 20/); assert.match(stdout, /feedback\s+stored=17\s+rendered=10/);
assert.match(stdout, /feedback: 17 \/ 10 FULL/);
assert.match(stdout, /Top rendered candidates:\n\s+- strength=/); assert.match(stdout, /Top rendered candidates:\n\s+- strength=/);
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
@@ -237,14 +122,15 @@ test("memory health reports dormancy and retention monitoring deprecations", asy
})); }));
await writeWorkspaceStore(root, entries, { lastActivityAt }); await writeWorkspaceStore(root, entries, { lastActivityAt });
const stdout = await runMemoryDiagStatusVerbose(root); const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Dormancy:/); assert.match(stdout, /Dormancy:/);
assert.match(stdout, /wall days since activity: 19\.0/); assert.match(stdout, /wall days since activity: 19\.0/);
assert.match(stdout, /dormant discount active: yes/); assert.match(stdout, /dormant discount active: yes/);
assert.match(stdout, /dormant days past grace: 5\.0/); assert.match(stdout, /dormant days past grace: 5\.0/);
assert.match(stdout, /Top rendered candidates:/); assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
assert.match(stdout, /Weakest active memories:/); assert.match(stdout, /safety_critical_count: 6 .*deprecated.* WARNING/);
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
@@ -261,9 +147,9 @@ test("memory health reports global cap overflow separately from type caps", asyn
]; ];
await writeWorkspaceStore(root, entries); 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, /type-capped entries: 0/);
assert.match(stdout, /global-cap overflow: 6/); assert.match(stdout, /global-cap overflow: 6/);
} finally { } finally {
@@ -276,13 +162,14 @@ test("memory health reports missing dormancy and non-alert monitoring defaults",
try { try {
await writeWorkspaceStore(root, [], { omitLastActivityAt: true }); await writeWorkspaceStore(root, [], { omitLastActivityAt: true });
const stdout = await runMemoryDiagStatusVerbose(root); const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /lastActivityAt: \(missing\)/); assert.match(stdout, /lastActivityAt: \(missing\)/);
assert.match(stdout, /wall days since activity: unknown/); assert.match(stdout, /wall days since activity: unknown/);
assert.match(stdout, /dormant discount active: no/); assert.match(stdout, /dormant discount active: no/);
assert.match(stdout, /Top rendered candidates:\n\s+\(none\)/); assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
assert.match(stdout, /Weakest active memories:\n\s+\(none\)/); assert.match(stdout, /safety_critical_count: 0 \(deprecated field\)\n/);
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
@@ -304,8 +191,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"] }), 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 human = await runMemoryDiagHealth(root);
const jsonText = await runMemoryDiag(["status", "--workspace", root, "--json"]); const jsonText = await runMemoryDiag(["health", "--workspace", root, "--json"]);
const parsed = JSON.parse(jsonText) as { const parsed = JSON.parse(jsonText) as {
version: 1; version: 1;
summary: { storedActive: number; rendered: number; pending: number; rejectedLast7Days: number; corruptStoresQuarantinedLast30Days: number }; summary: { storedActive: number; rendered: number; pending: number; rejectedLast7Days: number; corruptStoresQuarantinedLast30Days: number };
@@ -314,9 +201,9 @@ test("memory health --json prints parseable privacy-safe diagnostics matching hu
}; };
assert.equal(parsed.version, 1); assert.equal(parsed.version, 1);
assert.equal(parsed.summary.storedActive, Number(human.match(/active: (\d+) \/ 28/)?.[1])); assert.equal(parsed.summary.storedActive, Number(human.match(/Stored active memories: (\d+)/)?.[1]));
assert.equal(parsed.summary.rendered, Number(human.match(/rendered: (\d+)/)?.[1])); assert.equal(parsed.summary.rendered, Number(human.match(/Rendered candidates: (\d+)/)?.[1]));
assert.equal(parsed.summary.pending, 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.rejectedLast7Days, 1);
assert.equal(parsed.summary.corruptStoresQuarantinedLast30Days, 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"))); assert.ok(parsed.recentEvents.some(event => event.eventId && event.type === "render_selected" && event.outcome === "rendered" && event.createdAt && event.reasonCodes.includes("within_caps")));
@@ -376,7 +263,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-")); const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-"));
try { try {
await writeWorkspaceStore(root, [ await writeWorkspaceStore(root, [
@@ -392,7 +279,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"] }), 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, /Memory mem-life: omitted_superseded/);
assert.match(stdout, /Lifecycle:/); assert.match(stdout, /Lifecycle:/);
@@ -408,34 +295,21 @@ test("memory-diag explain --memory prints lifecycle relations and redacts secret
} }
}); });
test("memory-diag explain positional memory id prints lifecycle", async () => { test("memory-diag trace requires --memory and reports unknown IDs", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-explain-positional-"));
try {
await writeWorkspaceStore(root, [
{ ...entry("mem-life", "Old token password: sushi should be redacted", "decision"), status: "superseded" as const },
entry("mem-new", "Replacement memory", "decision"),
]);
await appendEvidenceEvents(root, [
evidence({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, reasonCodes: ["quality_gate_passed"], textPreview: "password: sushi" }),
evidence({ type: "promotion_superseded", phase: "promotion", outcome: "superseded", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "superseded_by", memory: { memoryId: "mem-new" } }], reasonCodes: ["superseded_existing"] }),
]);
const stdout = await runMemoryDiag(["explain", "mem-life", "--workspace", root]);
assert.match(stdout, /Memory mem-life: omitted_superseded/);
assert.match(stdout, /Lifecycle:/);
assert.match(stdout, /promotion_superseded: superseded; reasons=superseded_existing; .*superseded_by=mem-new/);
assert.match(stdout, /Superseded by:\n- mem-new/);
assert.ok(!stdout.includes("sushi"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag explain --memory reports unknown IDs", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-")); const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-"));
try { 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, /Memory missing-memory: unknown/);
assert.match(stdout, /Lifecycle:\n\(none\)/); assert.match(stdout, /Lifecycle:\n\(none\)/);
} finally { } finally {
@@ -463,12 +337,12 @@ async function setupQualityFixture(): Promise<string> {
return root; 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(); const root = await setupQualityFixture();
try { 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, /Summary: Workspace memory quality is degraded:/);
assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/); assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/);
assert.match(stdout, /decision: 14 \/ 10 FULL/); assert.match(stdout, /decision: 14 \/ 10 FULL/);
@@ -481,106 +355,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(); const root = await setupQualityFixture();
try { try {
const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]); const stdout = await runMemoryDiag(["quality", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as { 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.match(parsed.summaryText, /Workspace memory quality is degraded/);
assert.equal(parsed.summary.status, "degraded"); assert.equal(parsed.caps.active, LONG_TERM_LIMITS.maxEntries);
assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number"); assert.equal(parsed.caps.capsFull, true);
assert.ok(parsed.summary.needsAttention.length > 0); assert.equal(parsed.retention.invalid, 1);
} finally { assert.equal(parsed.evidence.currentWithEvidence, 1);
await rm(root, { recursive: true, force: true }); assert.equal(parsed.evidence.unknownDisappearances, 1);
} assert.equal(parsed.rejections.workspaceScopedCount, 1);
}); assert.equal(parsed.rejections.legacyUnscopedCount, 1);
test("status default output is concise and actionable", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["status", "--workspace", root]);
assert.match(stdout, /OK Memory status|WARNING Memory status|DEGRADED Memory status/);
assert.match(stdout, /Key metrics:/);
assert.match(stdout, /active memories:/);
assert.match(stdout, /rendered:/);
assert.match(stdout, /pending:/);
assert.match(stdout, /rejected 7d:/);
assert.match(stdout, /evidence coverage:/);
assert.match(stdout, /Needs attention:/);
assert.match(stdout, /Suggested next steps:/);
assert.doesNotMatch(stdout, /Caps:|Retention clocks:|Rejection scoping:|Dormancy:/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("status --verbose includes detailed diagnostics", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /Caps:/);
assert.match(stdout, /Retention clocks:/);
assert.match(stdout, /Evidence:/);
assert.match(stdout, /Rejection scoping:/);
assert.match(stdout, /Dormancy:/);
assert.match(stdout, /Top rendered candidates:/);
assert.match(stdout, /Weakest active memories:/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("status --json includes additive summary fields", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as {
version: 1;
summary: {
storedActive: number;
rendered: number;
pending: number;
rejectedLast7Days: number;
corruptStoresQuarantinedLast30Days: number;
status?: string;
evidenceCoveragePercent?: number;
needsAttention?: string[];
suggestedNextSteps?: string[];
};
memories: unknown[];
recentEvents: unknown[];
};
assert.equal(parsed.version, 1);
assert.equal(typeof parsed.summary.storedActive, "number");
assert.equal(typeof parsed.summary.rendered, "number");
assert.equal(typeof parsed.summary.pending, "number");
assert.equal(typeof parsed.summary.rejectedLast7Days, "number");
assert.equal(typeof parsed.summary.corruptStoresQuarantinedLast30Days, "number");
assert.match(parsed.summary.status ?? "", /ok|warning|degraded/);
assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number");
assert.ok(Array.isArray(parsed.summary.needsAttention));
assert.ok(Array.isArray(parsed.summary.suggestedNextSteps));
assert.ok(Array.isArray(parsed.memories));
assert.ok(Array.isArray(parsed.recentEvents));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("status non-tty and --no-emoji use text labels", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["status", "--workspace", root, "--no-emoji"]);
assert.match(stdout, /^DEGRADED Memory status/);
assert.doesNotMatch(stdout, /🧠|⚠️|✖️/);
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
@@ -634,7 +428,7 @@ test("coverage --json includes event counts", async () => {
} }
}); });
test("missing default output summarizes unknown disappearances", async () => { test("disappearances labels historical evidence-only memory unknown without terminal event", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-")); const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-"));
try { try {
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]); await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
@@ -642,21 +436,15 @@ test("missing default output summarizes unknown disappearances", async () => {
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
]); ]);
const stdout = await runMemoryDiag(["missing", "--workspace", root]); const stdout = await runMemoryDiag(["disappearances", "--workspace", root]);
assert.match(stdout, /Missing memory summary/); assert.match(stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/);
assert.match(stdout, /Total missing: 1/);
assert.match(stdout, /Explained: 0/);
assert.match(stdout, /Needs review: 1/);
assert.match(stdout, /Unknown disappearance samples:/);
assert.match(stdout, /- historical-unknown terminal=unknown reasons=none/);
assert.doesNotMatch(stdout, /Memory historical-unknown:/);
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test("missing --verbose shows capacity details and render omitted type-cap observations", async () => { test("disappearances --explain shows capacity details and render omitted type-cap observations", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-explain-")); const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-explain-"));
try { try {
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]); await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
@@ -673,11 +461,9 @@ test("missing --verbose shows capacity details and render omitted type-cap obser
evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "render-loser", type: "feedback", source: "compaction" }, reasonCodes: ["type_cap"] }), evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "render-loser", type: "feedback", source: "compaction" }, reasonCodes: ["type_cap"] }),
]); ]);
const stdout = await runMemoryDiag(["missing", "--workspace", root, "--verbose"]); const stdout = await runMemoryDiag(["disappearances", "--workspace", root, "--explain"]);
assert.match(stdout, /Missing memory summary/);
assert.match(stdout, /capacity-loser: historical_absent_with_reason terminal=memory_removed_capacity reasons=type_cap/); assert.match(stdout, /capacity-loser: historical_absent_with_reason terminal=memory_removed_capacity reasons=type_cap/);
assert.match(stdout, /events:/);
assert.match(stdout, /memory_removed_capacity details: .*globalCap=28 .*typeCap=10/); assert.match(stdout, /memory_removed_capacity details: .*globalCap=28 .*typeCap=10/);
assert.match(stdout, /render-loser: historical_absent_with_reason terminal=render_omitted reasons=type_cap/); assert.match(stdout, /render-loser: historical_absent_with_reason terminal=render_omitted reasons=type_cap/);
assert.match(stdout, /render_omitted type-cap observation: reasons=type_cap/); assert.match(stdout, /render_omitted type-cap observation: reasons=type_cap/);
@@ -686,92 +472,50 @@ test("missing --verbose shows capacity details and render omitted type-cap obser
} }
}); });
test("missing --json includes disappearances and additive summary", async () => { test("rejections --quality --reason bad_decision --unique groups architecture-like samples heuristically", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-missing-json-")); const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-quality-"));
try { try {
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]); const key = await workspaceKey(root);
await appendEvidenceEvents(root, [ const now = new Date().toISOString();
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), 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(["missing", "--workspace", root, "--json"]); const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--reason", "bad_decision", "--unique"]);
const parsed = JSON.parse(stdout) as { disappearances: Array<{ id: string }>; summary: { total: number; explained: number; needsReview: number } };
assert.equal(parsed.summary.total, 1); assert.match(stdout, /Possible false-positive grouping is heuristic, not deterministic truth/);
assert.equal(parsed.summary.explained, 0); assert.match(stdout, /architecture_like_possible_false_positive: 1/);
assert.equal(parsed.summary.needsReview, 1); assert.match(stdout, /clearly_garbage: 1/);
assert.equal(parsed.disappearances[0]?.id, "historical-unknown"); assert.match(stdout, /ambiguous: 1/);
assert.doesNotMatch(stdout, /deterministic truth\s*:/);
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test("rejected default output is concise with top reasons and samples", async () => { test("rejections --quality --json includes scoping, unique reasons, and possible false-positive groups", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-")); const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-json-"));
try { try {
const key = await workspaceKey(root); const key = await workspaceKey(root);
const now = new Date().toISOString(); const now = new Date().toISOString();
await writeRejectionRecords([ await writeRejectionRecords([
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Keep memory system boundary stable", reasons: ["bad_decision"] }, { timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
{ timestamp: now, workspaceKey: key, type: "feedback", source: "explicit", text: "Remember password: sushi from rejected sample", reasons: ["raw_secret", "bad_feedback"] }, { timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] },
{ timestamp: "2020-01-01T00:00:00.000Z", workspaceKey: key, type: "project", source: "manual", text: "Old rejected sample", reasons: ["bad_project"] },
]); ]);
const stdout = await runMemoryDiag(["rejected", "--workspace", root]); const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as {
workspaceScopedCount: number;
legacyUnscopedCount: number;
uniqueReasonDistribution: Record<string, number>;
possibleFalsePositiveGroups: Record<string, { count: number }>;
};
assert.match(stdout, /Rejected memory summary/); assert.equal(parsed.workspaceScopedCount, 1);
assert.match(stdout, /Total rejected: 3/); assert.equal(parsed.legacyUnscopedCount, 1);
assert.match(stdout, /Unique texts: 3/); assert.equal(parsed.uniqueReasonDistribution.bad_decision, 1);
assert.match(stdout, /Top reasons:/);
assert.match(stdout, /bad_decision\s+1/);
assert.match(stdout, /False-positive risk: low/);
assert.match(stdout, /Recent samples:/);
assert.match(stdout, /\[decision\] Keep memory system boundary stable/);
assert.ok(!stdout.includes("sushi"));
assert.doesNotMatch(stdout, /By origin:|Reason distribution \(raw records\):|Possible false-positive groups/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("rejected --verbose includes detailed distributions", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-verbose-"));
try {
const key = await workspaceKey(root);
const now = new Date().toISOString();
await writeRejectionRecords([
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
{ timestamp: now, workspaceKey: key, type: "feedback", source: "explicit", text: "Status update completed", reasons: ["bad_feedback"] },
]);
const stdout = await runMemoryDiag(["rejected", "--workspace", root, "--verbose"]);
assert.match(stdout, /Rejected memory summary/);
assert.match(stdout, /Reason distribution \(raw records\):/);
assert.match(stdout, /Reason distribution \(unique text\):/);
assert.match(stdout, /By origin:/);
assert.match(stdout, /Possible false-positive groups \(heuristic, not deterministic\):/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("rejected --json includes quality summary and false-positive risk", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-json-"));
try {
const key = await workspaceKey(root);
const now = new Date().toISOString();
await writeRejectionRecords([
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
]);
const stdout = await runMemoryDiag(["rejected", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as { totalRecords: number; uniqueTexts: number; falsePositiveRisk: string; possibleFalsePositiveGroups: Record<string, { count: number }> };
assert.equal(parsed.totalRecords, 2);
assert.equal(parsed.uniqueTexts, 1);
assert.equal(parsed.falsePositiveRisk, "high");
assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1); assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
-63
View File
@@ -339,69 +339,6 @@ test("compaction prompt forbids progress and session-internal memory candidates"
} }
}); });
test("compaction prompt includes existing-memory wording reuse guidance", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
try {
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const output = { prompt: "", context: [] as string[] };
await (plugin as Record<string, Function>)["experimental.session.compacting"](
{ sessionID: "prompt-wording-reuse-session", model: {} },
output,
);
assert.match(output.prompt, /Existing workspace memory may already contain durable facts/);
assert.match(output.prompt, /do not create a rephrased duplicate/);
assert.match(output.prompt, /reuse the existing memory wording exactly whenever possible/);
assert.match(output.prompt, /new, materially corrected, or materially more specific/);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("compaction prompt does not introduce CRUD memory directives", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
try {
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const output = { prompt: "", context: [] as string[] };
await (plugin as Record<string, Function>)["experimental.session.compacting"](
{ sessionID: "prompt-no-crud-session", model: {} },
output,
);
assert.doesNotMatch(output.prompt, /\bREPLACE\b/);
assert.doesNotMatch(output.prompt, /\bDROP\b/);
assert.doesNotMatch(output.prompt, /\bREINFORCE\s+\[M/);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("compaction prompt preserves Memory candidates output format", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
try {
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const output = { prompt: "", context: [] as string[] };
await (plugin as Record<string, Function>)["experimental.session.compacting"](
{ sessionID: "prompt-format-session", model: {} },
output,
);
assert.match(
output.prompt,
/Format when there ARE durable memories:\nMemory candidates:\n- \[feedback\|decision\|project\|reference\] future-facing durable fact/,
);
assert.match(
output.prompt,
/Format when there are NO durable memories:\nMemory candidates:\n\(none\)/,
);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("compaction hook merges existing output.context from other plugins", async () => { test("compaction hook merges existing output.context from other plugins", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
-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");
});
+8 -185
View File
@@ -6,7 +6,6 @@ import { tmpdir } from "node:os";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts"; import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts"; import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
import { queryEvidenceEvents } from "../src/evidence-log.ts";
import { import {
renderWorkspaceMemory, renderWorkspaceMemory,
accountWorkspaceMemoryRender, accountWorkspaceMemoryRender,
@@ -563,23 +562,6 @@ test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates",
)); ));
}); });
test("dedupeLongTermEntriesWithAccounting decision same-identity variants absorb exact only", () => {
const retained = entry("decision-a", "Use pnpm for package management!!!", "decision");
const duplicate = entry("decision-b", "use pnpm for package management", "decision");
assert.notEqual(retained.text, duplicate.text);
assert.equal(workspaceMemoryIdentityKey(retained), workspaceMemoryIdentityKey(duplicate));
assert.equal(workspaceMemoryExactKey(retained), workspaceMemoryExactKey(duplicate));
const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]);
assert.equal(result.kept.length, 1);
assert.equal(result.absorbed.length, 1);
assert.equal(result.absorbed[0].reason, "absorbed_exact");
assert.equal(result.superseded.length, 0);
assert.equal(result.absorbed.some(event => event.reason === "superseded_existing"), false);
});
test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => { test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => {
const now = Date.now(); const now = Date.now();
const retained: LongTermMemoryEntry = { const retained: LongTermMemoryEntry = {
@@ -1017,16 +999,6 @@ test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () =
assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading"); assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading");
}); });
test("workspaceMemoryIdentityKey returns exact key for feedback entries", () => {
const feedback = entry(
"feedback-exact-identity",
"User prefers references to mention `.opencode/opencode.json` explicitly.",
"feedback",
);
assert.equal(workspaceMemoryIdentityKey(feedback), workspaceMemoryExactKey(feedback));
});
test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => { test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => {
const root = "/repo"; const root = "/repo";
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -1280,50 +1252,6 @@ test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted
} }
}); });
test("updateWorkspaceMemoryWithAccounting includes migration evidence from pre-update normalization", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-migration-update-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-26T00:00:00.000Z";
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify({
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "update_p0_progress",
type: "project",
text: "Phase 1-4 completed successfully",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
}],
migrations: ["2026-04-28-quality-cleanup"],
updatedAt: now,
}, null, 2), "utf8");
const result = await updateWorkspaceMemoryWithAccounting(root, store => store);
const migrationEvidence = result.evidence.filter(event => event.type === "memory_migration_superseded");
assert.equal(result.store.entries.find(memory => memory.id === "update_p0_progress")?.status, "superseded");
assert.equal(migrationEvidence.length, 1);
assert.deepEqual(migrationEvidence[0].reasonCodes, ["migration:p0_cleanup"]);
assert.equal(migrationEvidence[0].memory?.memoryId, "update_p0_progress");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(sandbox, { recursive: true, force: true });
}
});
// ============================================ // ============================================
// P0d: identity-key dedup, supersession, staleness // P0d: identity-key dedup, supersession, staleness
// ============================================ // ============================================
@@ -1676,7 +1604,7 @@ test("redactCredentials is idempotent and also redacts rationale text", () => {
})), })),
}, },
now, now,
).store; );
assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]"); assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]");
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]"); assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
}); });
@@ -1735,69 +1663,14 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o
}; };
const once = runMigrationP0Cleanup(store, now); const once = runMigrationP0Cleanup(store, now);
assert.deepEqual(once.store.migrations, ["2026-04-26-p0-cleanup"]); assert.deepEqual(once.migrations, ["2026-04-26-p0-cleanup"]);
assert.equal(once.store.entries.find(e => e.id === "project-snapshot")?.status, "superseded"); assert.equal(once.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
assert.equal(once.store.entries.find(e => e.id === "project-explicit")?.status, "active"); assert.equal(once.entries.find(e => e.id === "project-explicit")?.status, "active");
assert.equal(once.store.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active"); assert.equal(once.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
assert.equal(once.events.length, 1);
assert.equal(once.events[0].type, "memory_migration_superseded");
assert.equal(once.events[0].phase, "storage");
assert.equal(once.events[0].outcome, "superseded");
assert.ok(once.events[0].reasonCodes.includes("migration:p0_cleanup"));
assert.equal(once.events[0].memory?.memoryId, "project-snapshot");
const twice = runMigrationP0Cleanup(once.store, later); const twice = runMigrationP0Cleanup(once, later);
assert.deepEqual(twice.store.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate"); assert.deepEqual(twice.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
assert.equal(twice.store.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.store.entries.find(e => e.id === "project-snapshot")?.updatedAt); assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
assert.equal(twice.events.length, 0);
});
test("loadWorkspaceMemory appends P0 migration evidence once", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-p0-evidence-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-26T00:00:00.000Z";
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify({
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "p0_progress",
type: "project",
text: "Phase 1-4 completed successfully",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
}],
migrations: ["2026-04-28-quality-cleanup"],
updatedAt: now,
}, null, 2), "utf8");
const firstLoad = await loadWorkspaceMemory(root);
await loadWorkspaceMemory(root);
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
assert.equal(firstLoad.entries.find(memory => memory.id === "p0_progress")?.status, "superseded");
assert.equal(events.length, 1);
assert.equal(events[0].type, "memory_migration_superseded");
assert.equal(events[0].phase, "storage");
assert.equal(events[0].outcome, "superseded");
assert.deepEqual(events[0].reasonCodes, ["migration:p0_cleanup"]);
assert.equal(events[0].memory?.memoryId, "p0_progress");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(sandbox, { recursive: true, force: true });
}
}); });
test("quality cleanup migration preserves soft-only feedback and decision violations", async () => { test("quality cleanup migration preserves soft-only feedback and decision violations", async () => {
@@ -1924,56 +1797,6 @@ test("quality cleanup migration writes audit log for hard supersedes", async ()
} }
}); });
test("quality cleanup migration appends superseded evidence with hard reasons", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-evidence-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-28T00:00:00.000Z";
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify({
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "quality_progress",
type: "project",
text: "Test suite: 1237 tests pass, 226 suites",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
staleAfterDays: 60,
}],
migrations: [],
updatedAt: now,
}, null, 2), "utf8");
const loaded = await loadWorkspaceMemory(root);
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
assert.equal(loaded.entries.find(memory => memory.id === "quality_progress")?.status, "superseded");
assert.equal(events.length, 1);
assert.equal(events[0].type, "memory_migration_superseded");
assert.equal(events[0].phase, "storage");
assert.equal(events[0].outcome, "superseded");
assert.ok(events[0].reasonCodes.includes("migration:quality_cleanup"));
assert.ok(events[0].reasonCodes.includes("quality:progress_snapshot"));
assert.equal(events[0].memory?.memoryId, "quality_progress");
assert.equal(events[0].memory?.status, "superseded");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(sandbox, { recursive: true, force: true });
}
});
test("quality cleanup migration aborts supersede when audit log cannot be written", async () => { test("quality cleanup migration aborts supersede when audit log cannot be written", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-")); const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-"));
const dataHome = join(sandbox, "xdg-data-home"); const dataHome = join(sandbox, "xdg-data-home");
+1 -1
View File
@@ -25,6 +25,6 @@
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"noEmit": true "noEmit": true
}, },
"include": ["index.ts", "src/**/*.ts", "scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts"], "include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }