Compare commits

..

55 Commits

Author SHA1 Message Date
Ralph Chang cc9656ed59 refactor(memory-diag): remove legacy aliases, centralize command metadata, prepare v1.5.4
- Remove legacy CLI aliases (health, quality, rejections, disappearances, trace)
- Centralize command metadata in command-metadata.ts
- Move trace lifecycle into explain command
- Move disappearance helpers into missing formatter
- Remove cleanup:workspaces from package scripts (dev tool preserved)
- Bump version to 1.5.4
2026-05-02 21:57:13 +08:00
Ralph Chang f9bdcdd785 chore(release): prepare v1.5.3 2026-05-02 20:49:23 +08:00
Ralph Chang cf05b9fa69 feat(memory-diag): publish diagnostics CLI 2026-05-02 20:36:58 +08:00
Ralph Chang aaa4016ae8 feat(reinforcement): compaction prompt wording reuse, migration evidence, and validation baseline
Wave 1 — Compaction prompt improvement:
- Add three wording-reuse bullets to buildCompactionPrompt() under
  CRITICAL MEMORY RULES: do not create rephrased duplicates, reuse
  existing wording exactly when re-emitting, only emit new memories
  when the fact is new, materially corrected, or more specific.
- This attacks the root cause of zero reinforcement: compaction
  generating variant text for the same durable fact.

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

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

Identity extension is gated on measurement: if the prompt change
produces measurable reinforcement (reinforcementCount > 0), identity
extension may be unnecessary. Decision dedupe stays exact-only
(Wave 4 deferred).
2026-05-02 15:03:34 +08:00
Ralph Chang ff5c568cb7 chore(release): prepare v1.5.2 2026-05-01 16:00:44 +08:00
Ralph Chang 0ed61d1ba2 Merge branch 'feat/deprecate-safety-critical' 2026-04-30 19:54:50 +08:00
Ralph Chang 2dbfb50080 chore(release): prepare v1.5.1 2026-04-30 19:54:24 +08:00
Ralph Chang d569297c30 fix(retention): add UTC calendar-day diversity gate to reinforceMemory
Implement OQ-2 decision: allow at most one reinforcement per memory
identity per UTC calendar day. Same-day reinforcement is blocked
regardless of session or interval. This prevents repetitive-task
gaming where a daily recurring task could reach MAX_COUNT=6 in hours.

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

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

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

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

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

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

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

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

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

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

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

A1 - Retention clock reset on reinforcement

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses architecture review feedback:
- Distinguish promoted/absorbed/rejected pending memories
- Add clearableKeys.size > 0 guard to prevent journal wipe
- Add regression tests for all edge cases
2026-04-27 14:16:46 +08:00
sdwolf4103 14bbb76cf1 Fix formatting in README.md 2026-04-27 13:00:26 +08:00
60 changed files with 6794 additions and 1108 deletions
+2
View File
@@ -51,6 +51,8 @@ pnpm-lock.yaml
# Superpowers local planning artifacts
docs/superpowers/plans/
docs/plans/
docs/disruptions/
# Local dev/admin script inputs
scripts/dev/run-migration-roots.local.txt
+49 -1
View File
@@ -5,13 +5,60 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.4] - 2026-05-02
### Changed
- Centralized the supported `memory-diag` command metadata for the official public commands: `status`, `rejected`, `missing`, and `explain`.
- Removed pre-public legacy aliases so unsupported spellings now use the standard unknown-subcommand path instead of carrying v2.0 compatibility debt.
- Kept `coverage` and `audit` as hidden maintainer diagnostics with neutral maintainer-only notices.
- Removed public npm script exposure for the internal workspace cleanup tool while keeping the development tool in the repository.
### Fixed
- Moved missing-memory disappearance detail formatting into the official `missing` formatter before deleting legacy disappearance formatter code.
## [1.5.3] - 2026-05-02
### Added
- Published the read-only `memory-diag` package binary for package-qualified `npx --package opencode-working-memory memory-diag` usage.
- Added user-facing diagnostics commands for concise status, rejected memory review, missing memory review, and per-memory explanation.
### Changed
- Reworked the diagnostics CLI into focused command, model, formatter, and utility modules while preserving legacy aliases and maintainer diagnostics.
- Updated README and configuration docs to document the supported `memory-diag` CLI workflow and package-qualified `npx` usage.
- Raised the Node.js requirement to `>=22.6.0` for the TypeScript diagnostics CLI runtime.
### Fixed
- Avoided repeated workspace snapshot construction in status diagnostics.
- Replaced per-memory evidence summary lookups with grouped evidence processing to avoid N+1 diagnostics work.
- Added top-level CLI error handling for cleaner user-facing failures.
- Removed a diagnostics model circular dependency by extracting evidence grouping helpers to a neutral module.
## [1.5.1] - 2026-04-30
### Added
- Per-workspace evidence log for extraction, promotion, reinforcement, render, storage, and hook lifecycle events.
- `memory-diag health --json` for machine-readable diagnostics.
- `memory-diag explain` for per-memory render status, strength, reasons, and evidence event IDs.
- `memory-diag trace --memory <id>` for memory lifecycle history.
- UTC calendar-day reinforcement gate so repeated matches cannot inflate a memory multiple times in the same day.
### Changed
- Retention constants and calculations moved to `src/retention.ts`.
- `safetyCritical` is now fully inert: no retention multiplier and no type-cap bypass, while remaining JSON-compatible.
## [1.5.0] - 2026-04-29
### Added
- Strength-based workspace memory retention using exponential decay instead of additive priority scoring.
- Per-type rendered caps for workspace memory candidates: feedback 10, decision 10, project 8, and reference 6.
- Safety-critical memory weighting and type-cap exemption so important entries survive type floods while still competing under the global rendered cap.
- Dormant-workspace effective age: after 14 days without activity, additional dormant time counts at 0.25x for retention decay.
- Reinforcement tracking for repeated memories, with same-session and one-hour guards to prevent accidental reinforcement spam.
- Memory health diagnostics for stored vs rendered counts, type caps, global cap overflow, dormancy, retention monitoring, and strength-ranked top/weakest entries.
@@ -21,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Workspace memory rendering now ranks entries by retention strength, not the previous priority/penalty model.
- Confidence is retained for compatibility but no longer affects retention scoring.
- Deprecated `safetyCritical` is retained for JSON compatibility but no longer affects retention strength or type-cap behavior.
- Old or stale-marked memories are no longer hard-pruned; they remain stored and only fall out of rendered context through strength and cap competition.
- Existing duplicate promotion and dedupe paths now reinforce the surviving memory instead of only absorbing the duplicate.
- Health output now separates stored active memories from rendered candidates to make cap behavior easier to understand.
+26 -6
View File
@@ -200,14 +200,35 @@ 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".
For local development cleanup, use:
### Memory Diagnostics CLI
Use the read-only diagnostics CLI when you want to understand what memory is doing for the current workspace.
| Question | Command |
|---|---|
| Is memory healthy? | `npx --package opencode-working-memory memory-diag` or `npx --package opencode-working-memory memory-diag status` |
| Why was something rejected? | `npx --package opencode-working-memory memory-diag rejected` |
| Where did my memory go? | `npx --package opencode-working-memory memory-diag missing` |
| Why is this memory shown or hidden? | `npx --package opencode-working-memory memory-diag explain <memory-id>` |
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
npm run cleanup:workspaces -- --dry-run
npm run cleanup:workspaces -- --quarantine
npx --package opencode-working-memory memory-diag status
npx --package opencode-working-memory memory-diag rejected --verbose
npx --package opencode-working-memory memory-diag missing --workspace /path/to/project
npx --package opencode-working-memory memory-diag status --json
```
The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
The npm package is opencode-working-memory; the installed bin is memory-diag, so package-qualified npx avoids resolving a different package named memory-diag.
Maintainer-only diagnostics and cleanup commands are intentionally not documented here. Future work: move those internal commands to `docs/development.md`.
## Configuration
@@ -245,13 +266,12 @@ cd opencode-working-memory
npm install
npm test
npm run typecheck
npm run cleanup:workspaces -- --dry-run
```
## Requirements
- OpenCode plugin API `>=1.2.0 <2.0.0`
- Node.js >= 18.0.0
- Node.js >= 22.6.0 (for `memory-diag` CLI, which runs TypeScript with `--experimental-strip-types`)
## Limitations
+89 -4
View File
@@ -1,12 +1,97 @@
# Release Notes
## 1.5.4 (2026-05-02)
### Memory Diagnostics Surface Cleanup
This cleanup release keeps the newly published `memory-diag` CLI small before legacy spellings become compatibility debt.
### What Changed
- **Official commands only**: the public CLI surface is `status`, `rejected`, `missing`, and `explain`.
- **Pre-public aliases removed**: old spellings such as `health`, `quality`, `rejections`, `disappearances`, and `trace` are no longer recognized.
- **Maintainer diagnostics clarified**: hidden `coverage` and `audit` commands remain available as maintainer-only diagnostics and stay out of public usage output.
- **Cleaner internals**: current command metadata now has one source of truth, and legacy command/formatter wrappers were removed.
- **Internal cleanup script hidden**: the workspace cleanup helper is no longer exposed as an npm script; the underlying development tool remains in `scripts/dev/`.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 347 tests passing, `TEST_PASS`
---
## 1.5.3 (2026-05-02)
### Published Memory Diagnostics CLI
This release makes the read-only workspace memory diagnostics CLI available as the package binary `memory-diag`, with user-facing commands for checking memory health and understanding why memories are rejected, missing, shown, or hidden.
> Good memory is selective memory — and diagnostics should explain the selection.
### What Changed
- **Published CLI bin**: run diagnostics with `npx --package opencode-working-memory memory-diag` or `memory-diag status`.
- **User-facing commands**: `status`, `rejected`, `missing`, and `explain <memory-id>` are documented as the supported public workflow.
- **Legacy compatibility**: existing maintainer and legacy commands remain accepted with deprecation notices where applicable.
- **Cleaner CLI architecture**: the former monolithic diagnostics script is now split into focused command, model, formatter, and utility modules.
- **Faster diagnostics**: evidence grouping avoids repeated per-memory evidence queries in snapshot diagnostics.
- **Cleaner failures**: top-level CLI error handling now reports usage and unexpected command errors without noisy stack traces.
- **Docs alignment**: README and configuration docs now use package-qualified `npx` commands to avoid resolving an unrelated package named `memory-diag`.
### Requirements
- Node.js `>=22.6.0` is now required because the published diagnostics binary runs TypeScript through Node's `--experimental-strip-types` support.
### Validation
- `npm run typecheck` — `TYPECHECK_PASS`
- `npm test` — 358 tests passing, `TEST_PASS`
---
## 1.5.1 (2026-04-30)
### Evidence Loop and Explainability
This release adds an evidence-based audit trail for memory lifecycle events and user-facing diagnostics for understanding why memories are rendered, promoted, or rejected.
> **Evidence before sublimation.** Every memory decision can be traced.
### What Changed
- **Evidence log**: extraction, promotion, reinforcement, render, and storage events are now recorded in a per-workspace `events.jsonl` with 90-day retention and 5000-event cap.
- **User explainability**: `memory-diag explain` shows per-memory render status with strength, reasons, and evidence. `memory-diag trace --memory <id>` shows the full lifecycle history.
- **Machine-readable diagnostics**: `memory-diag health --json` outputs structured `MemoryDiagJSON` for scripting.
- **Calendar-day reinforcement gate**: reinforcement now requires distinct UTC calendar days, preventing repetitive-task gaming that could inflate a memory's strength within a single day.
- **SafetyCritical deprecation complete**: the `safetyCritical` field no longer affects retention strength or type-cap bypass. All memories fade by the same rules.
- **Retention module extraction**: retention constants and calculations moved to `src/retention.ts` for cleaner separation.
### Privacy
- Evidence text previews are credential-redacted. Memory content is stored as truncated hashes, never in full.
- Diagnostics default to redacted output. `--raw` is available for maintainers.
### Upgrade Notes
- No configuration changes required.
- Existing workspace memory files remain compatible.
- Evidence logs are created automatically; no migration needed.
### Validation
- `npm run typecheck`
- `npm test` — 271 tests passing
---
## 1.5.0 (2026-04-29)
### Retention Decay Model
This release changes workspace memory retention from hard stale pruning and additive priority scoring to a strength-based decay model.
Think of it like a forgetting curve: memories fade over time, but important, reinforced, and safety-critical memories decay slower. Weak entries fall out of rendered prompt context by cap competition, not hard deletion.
Think of it like a forgetting curve: memories fade over time, but important and reinforced memories decay slower. Weak entries fall out of rendered prompt context by cap competition, not hard deletion.
> **Memory should fade, so the agent can keep learning.**
> Important memories decay slower, but every memory must leave room for newer project reality and avoid long-term memory pollution.
@@ -27,10 +112,10 @@ Think of it like a forgetting curve: memories fade over time, but important, rei
### What Changed
- **Strength-based retention**: workspace memory now uses exponential decay: initial strength × age decay.
- **Better initial strength**: type, source, user importance, and safety-critical status now determine how strong a memory starts.
- **Better initial strength**: type, source, and user importance now determine how strong a memory starts.
- **No confidence scoring**: confidence remains in stored data for compatibility, but it no longer affects retention ranking.
- **Type caps**: rendered workspace memory now caps feedback, decisions, project facts, and references separately so one type cannot monopolize all 28 slots.
- **Safety-critical protection**: safety-critical entries get stronger retention and are exempt from per-type caps, while still competing under the global rendered cap.
- **Deprecation:** `safetyCritical` field no longer affects retention strength or type-cap bypass. All system memories now fade according to the same rules. Safety rules belong in user-controlled `agent.md` files, not in system memory.
- **Dormant-aware age**: after 14 inactive days, additional dormant workspace time counts at 0.25x so paused projects do not forget too aggressively.
- **Reinforcement**: repeated matching memories reinforce the survivor and slow future decay, with same-session and one-hour guards to avoid accidental spam.
- **No hard stale pruning**: old or stale-marked memories are no longer automatically dropped by age; they lose rendered space only through cap competition.
@@ -96,7 +181,7 @@ Retention monitoring:
### Validation
- `npm run typecheck`
- `npm test` — 237 tests passing
- `npm test` — 242 tests passing
- `bun scripts/memory-diag.ts health`
---
+10 -2
View File
@@ -109,14 +109,14 @@ Retention then decides which active memories are rendered into prompt context. I
strength = initialStrength * 2 ** (-effectiveAgeDays / effectiveHalfLifeDays)
```
Initial strength is based on memory type, source, optional user importance, and safety-critical status. Confidence remains stored for compatibility but is not part of retention scoring.
Initial strength is based on memory type, source, and optional user importance. Confidence remains stored for compatibility but is not part of retention scoring.
Rendered candidates are selected in this order:
1. Exclude `status: "superseded"` entries.
2. Compute current retention strength.
3. Sort by strength descending.
4. Apply per-type caps, with safety-critical entries exempt from type caps.
4. Apply per-type caps.
5. Keep the top 28 rendered entries under the workspace memory character budget.
Default type caps:
@@ -132,6 +132,10 @@ The type-cap total is 34, intentionally above the global 28-entry cap. These are
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement.
### Safety-Critical Deprecation
The `safetyCritical` field on `LongTermMemoryEntry` is deprecated as of the retention v1.5.1 model update. It no longer affects retention strength or type-cap bypass. The field is preserved in the type definition for backward compatibility with existing workspace memory JSON files, but has no active behavior. Safety rules should be maintained in user-controlled files such as `agent.md` rather than in system memory.
### System Prompt Injection
Workspace memory is injected at the top of every message:
@@ -343,6 +347,10 @@ canonical("Use npm cache for plugins") === "use npm cache for plugins"
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
```
### Storage Safety
All read-modify-write updates go through `updateJSON()`, which combines an in-process promise queue with an on-disk `.lock` file. The lock file uses exclusive creation, heartbeat refreshes while held, stale-lock recovery after 30 seconds, and a 5 second wait timeout for live contention. Direct `readJSON()` is fallback-oriented and does not mutate data except when corrupt JSON is quarantined.
## Performance Considerations
### Memory Budgets
+13 -5
View File
@@ -34,7 +34,7 @@ const WORKSPACE_DORMANT_AFTER_DAYS = 14;
const DORMANT_DECAY_MULTIPLIER = 0.25;
```
Initial strength uses type, source, user importance, and safety-critical factors. Confidence is stored for compatibility but is not used for retention scoring.
Initial strength uses type, source, and user importance factors. Confidence is stored for compatibility but is not used for retention scoring.
Rendered type caps prevent one type from filling all workspace memory slots:
@@ -45,7 +45,7 @@ Rendered type caps prevent one type from filling all workspace memory slots:
| `project` | 8 |
| `reference` | 6 |
Safety-critical memories are exempt from type caps but still compete under the global `maxEntries` limit. Old or stale-marked memories are not hard-pruned by age; they lose rendered space through strength and cap competition.
Old or stale-marked memories are not hard-pruned by age; they lose rendered space through strength and cap competition. The deprecated `safetyCritical` field is preserved for compatibility but no longer affects strength or type caps.
## Hot Session State Limits
@@ -204,13 +204,21 @@ cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
### Inspect Retention Health
From a source checkout, maintainers can inspect stored vs rendered memory behavior:
Use the diagnostics CLI to check memory health for the current workspace:
```bash
bun scripts/memory-diag.ts health
npx --package opencode-working-memory memory-diag status
# or from a source checkout:
npm run diag -- status
```
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.
For detailed diagnostics, use `--verbose`:
```bash
npx --package opencode-working-memory memory-diag status --verbose
```
The status output includes overall health (OK/WARNING/DEGRADED), key metrics, and suggested next steps when attention is needed.
### Clear Workspace Memory
+11 -5
View File
@@ -1,23 +1,29 @@
{
"name": "opencode-working-memory",
"version": "1.5.0",
"version": "1.5.4",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
"exports": {
".": "./index.ts"
},
"bin": {
"memory-diag": "./scripts/memory-diag-bin.cjs"
},
"files": [
"index.ts",
"src/",
"scripts/memory-diag.ts",
"scripts/memory-diag/",
"scripts/memory-diag-bin.cjs",
"README.md",
"LICENSE"
],
"scripts": {
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
"typecheck": "tsc --noEmit",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts",
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
"diag": "node --experimental-strip-types scripts/memory-diag.ts",
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
},
"keywords": [
@@ -47,6 +53,6 @@
"typescript": "^5.9.2"
},
"engines": {
"node": ">=18.0.0"
"node": ">=22.6.0"
}
}
+368
View File
@@ -0,0 +1,368 @@
#!/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
@@ -0,0 +1,24 @@
#!/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);
}
+33 -697
View File
@@ -4,715 +4,51 @@
* Does not send telemetry, make API calls, or affect plugin runtime behavior.
*/
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { assessMemoryQuality, HARD_QUALITY_REASONS } from "../src/memory-quality.ts";
import { redactCredentials } from "../src/redaction.ts";
import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts";
import { calculateRetentionStrength, renderWorkspaceMemory } from "../src/workspace-memory.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts";
import { parseArgs, type ParsedArgs } from "./memory-diag/cli.ts";
import { dispatch } from "./memory-diag/command-registry.ts";
import { CliInputError } from "./memory-diag/types.ts";
type Command = "health" | "rejections" | "audit";
type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
type ParsedError = Extract<ParsedArgs, { ok: false }>;
type ParsedHelp = Extract<ParsedArgs, { ok: true; help: true }>;
type CliOptions = {
raw: boolean;
workspace?: string;
all?: boolean;
softOnly?: boolean;
triggerOnly?: boolean;
since?: string;
migration?: string;
};
type RejectionLogRecord = {
timestamp?: string;
workspaceKey?: string;
workspaceRoot?: string;
type?: LongTermType;
source?: LongTermSource | string;
origin?: string;
fromTrigger?: boolean;
text?: string;
reasons?: string[];
};
type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
workspaceKey?: string;
workspaceRoot?: string;
source?: string;
origin: Origin;
fromTrigger: boolean;
};
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;
};
const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
const TYPE_MAX_FOR_DIAG: Record<LongTermType, number> = {
feedback: 10,
decision: 10,
project: 8,
reference: 6,
};
const WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG = 14;
const DORMANT_DECAY_MULTIPLIER_FOR_DIAG = 0.25;
const SUSPICIOUS_REASONS = [
"progress_snapshot",
"active_file_snapshot",
"commit_or_ci_snapshot",
"temporary_status",
"raw_error",
"code_or_api_signature",
] as const;
const ALLOWED_ORIGINS = new Set<Origin>([
"explicit_trigger",
"compaction_candidate",
"manual",
"migration_check",
"unknown",
]);
function usage(): string {
return `Usage:
bun scripts/memory-diag.ts health [--workspace <path>] [--all] [--raw]
bun scripts/memory-diag.ts rejections [--soft-only] [--trigger-only] [--since 14d] [--raw]
bun scripts/memory-diag.ts audit [--migration <id>] [--raw]
`;
function isParsedError(parsed: ParsedArgs): parsed is ParsedError {
return parsed.ok === false;
}
function die(message: string): never {
console.error(message);
console.error(usage());
process.exit(1);
function isParsedHelp(parsed: ParsedArgs): parsed is ParsedHelp {
return parsed.ok === true && "help" in parsed;
}
function parseArgs(argv: string[]): { command: Command; options: CliOptions } {
const [command, ...rest] = argv;
if (!command || command === "--help" || command === "-h") {
console.log(usage());
process.exit(0);
async function main(): Promise<number> {
const parsed = parseArgs(process.argv.slice(2));
if (isParsedError(parsed)) {
console.error(parsed.message);
console.error(parsed.usage);
return parsed.exitCode;
}
if (command !== "health" && command !== "rejections" && command !== "audit") {
die(`Unknown subcommand: ${command}`);
if (isParsedHelp(parsed)) {
console.log(parsed.usage);
return 0;
}
const options: CliOptions = { raw: false };
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (arg === "--raw") options.raw = 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 === "--workspace") {
const value = rest[++i];
if (!value) die("--workspace requires a path");
options.workspace = value;
} else if (arg === "--since") {
const value = rest[++i];
if (!value) die("--since requires a duration or ISO timestamp");
options.since = value;
} else if (arg === "--migration") {
const value = rest[++i];
if (!value) die("--migration requires an id");
options.migration = value;
} else {
die(`Unknown option: ${arg}`);
}
}
if (command === "health") {
if (options.all && options.workspace) die("Use either --all or --workspace, not both");
} else {
if (options.all || options.workspace) die(`${command} does not accept --all or --workspace`);
}
if (command !== "rejections" && (options.softOnly || options.triggerOnly || options.since)) {
die(`${command} does not accept rejection filters`);
}
if (command !== "audit" && options.migration) {
die(`${command} does not accept --migration`);
}
return { command, options };
}
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;
}
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]));
}
function workspaceRootHash(root: string): string {
return createHash("sha256").update(root).digest("hex").slice(0, 16);
}
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>"));
}
function cleanText(text: string, raw: boolean): string {
if (raw) return text;
return redactAbsolutePaths(redactCredentials(text));
}
function cleanPath(path: string, raw: boolean): string {
return raw ? path : "<path>";
}
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(" ");
}
function truncate(text: string, max = 120): string {
const collapsed = text.replace(/\s+/g, " ").trim();
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
}
async function readJSONFile<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch {
return null;
}
}
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;
const result = await dispatch(parsed.command, parsed.options);
const stderr = [parsed.deprecationNotice, result.stderr].filter(Boolean).join("\n");
if (stderr) process.stderr.write(stderr.endsWith("\n") ? stderr : `${stderr}\n`);
if (result.stdout) process.stdout.write(result.stdout.endsWith("\n") ? result.stdout : `${result.stdout}\n`);
return result.exitCode ?? 0;
} catch (error) {
if (error instanceof CliInputError) {
process.stderr.write(`${error.message}\n`);
return 1;
}
}
return { records, invalidLines };
}
function canonicalMemoryText(text: string): string {
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[\s\p{P}]+/gu, " ")
.trim();
}
function ageDays(entry: LongTermMemoryEntry): number | null {
const time = new Date(entry.createdAt).getTime();
if (Number.isNaN(time)) return null;
return Math.floor((Date.now() - time) / 86_400_000);
}
function formatStrength(value: number): string {
return Number.isFinite(value) ? value.toFixed(3) : "0.000";
}
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);
}
function formatPercent(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
type RetentionDiagItem = {
entry: LongTermMemoryEntry;
strength: number;
};
function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean {
return entry.safetyCritical === true;
}
function retentionCandidatesForDiag(store: WorkspaceMemoryStore): {
sorted: RetentionDiagItem[];
rendered: RetentionDiagItem[];
typeCapped: RetentionDiagItem[];
globalCapped: RetentionDiagItem[];
} {
const now = Date.now();
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) {
if (!isSafetyCriticalForDiag(item.entry)) {
const count = typeCounts[item.entry.type] ?? 0;
const max = TYPE_MAX_FOR_DIAG[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 };
}
function promotionLimit(source: LongTermSource): number {
if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts;
return PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
}
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(),
};
}
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 : [],
};
}
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(),
};
}
async function runHealth(options: CliOptions): Promise<void> {
if (options.all) {
const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 });
console.log("Workspace memory health");
console.log("");
if (scan.results.length === 0) {
console.log("No workspace stores found.");
return;
}
for (let i = 0; i < scan.results.length; i += 1) {
const result = scan.results[i];
if (i > 0) console.log("");
await printWorkspaceHealth({
root: result.root,
key: result.workspaceKey,
memoryPath: join(result.workspaceDir, "workspace-memory.json"),
pendingPath: join(result.workspaceDir, "workspace-pending-journal.json"),
raw: options.raw,
});
}
return;
}
const root = options.workspace ?? process.cwd();
const key = await workspaceKey(root);
await printWorkspaceHealth({
root,
key,
memoryPath: await workspaceMemoryPath(root),
pendingPath: await workspacePendingJournalPath(root),
raw: options.raw,
includeTitle: true,
});
}
async function printWorkspaceHealth(input: {
root?: string;
key: string;
memoryPath: string;
pendingPath: string;
raw: boolean;
includeTitle?: boolean;
}): Promise<void> {
if (input.includeTitle) {
console.log("Workspace memory health");
console.log("");
}
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 identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw);
if (identity) console.log(identity);
console.log(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`);
console.log(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`);
if (!rawStore) console.log("memory store: missing or unreadable (treated as empty)");
if (!existsSync(input.pendingPath)) console.log("pending journal: missing (treated as empty)");
console.log("");
const active = store.entries.filter(entry => entry.status !== "superseded");
const superseded = store.entries.filter(entry => entry.status === "superseded");
const retention = retentionCandidatesForDiag(store);
const renderedEntries = retention.rendered.map(item => item.entry);
const renderedEstimate = renderWorkspaceMemory(store).length;
console.log(`Stored active memories: ${active.length}`);
console.log(`Superseded memories: ${superseded.length}`);
console.log(`Rendered candidates: ${renderedEntries.length}`);
console.log(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
console.log("");
const pendingEntries = journal.entries;
const retryable = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) < promotionLimit(entry.source)).length;
const nearRetryLimit = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) >= promotionLimit(entry.source) - 1).length;
const pendingBySource = countBy(pendingEntries.map(entry => entry.source));
console.log("Pending journal:");
console.log(` total: ${pendingEntries.length}`);
console.log(` retryable: ${retryable}`);
console.log(` near retry limit: ${nearRetryLimit}`);
console.log(" by source:");
for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) {
console.log(` ${source}: ${pendingBySource.get(source) ?? 0}`);
}
console.log("");
console.log("By type:");
for (const type of TYPES) {
const storedCount = active.filter(entry => entry.type === type).length;
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
const supersededCount = superseded.filter(entry => entry.type === type).length;
console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${TYPE_MAX_FOR_DIAG[type]} superseded=${supersededCount}`);
}
console.log("");
console.log("Retention caps:");
console.log(` type-capped entries: ${retention.typeCapped.length}`);
console.log(` global-cap overflow: ${retention.globalCapped.length}`);
console.log("");
const olderThan30 = active.filter(entry => (ageDays(entry) ?? 0) > 30).length;
const olderThan90 = active.filter(entry => (ageDays(entry) ?? 0) > 90).length;
const staleMarked = active.filter(entry => {
const days = ageDays(entry);
return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays);
}).length;
console.log("Age:");
console.log(` stale-marked: ${staleMarked}`);
console.log(` older than 30d: ${olderThan30}`);
console.log(` older than 90d: ${olderThan90}`);
console.log("");
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt);
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG;
const dormantDaysPastGrace = wallDaysSinceActivity === null
? 0
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG);
console.log("Dormancy:");
console.log(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
console.log(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
console.log(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
console.log(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER_FOR_DIAG}`);
console.log("");
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
const highImportanceAlert = highImportanceRatio > 0.3;
const safetyCriticalAlert = safetyCriticalCount > 5;
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
console.log("Retention monitoring:");
console.log(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
console.log(` safety_critical_count: ${safetyCriticalCount} (alert > 5)${safetyCriticalAlert ? " ALERT" : ""}`);
console.log(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
console.log("");
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
console.log("Quality warnings:");
console.log(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`);
console.log(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`);
console.log(` duplicate-ish exact canonical text: ${duplicateExtras}`);
console.log(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`);
console.log("");
console.log("Suspicious active memories:");
for (const reason of SUSPICIOUS_REASONS) {
console.log(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`);
}
const failingQuality = qualityByEntry.filter(item => !item.quality.accepted);
if (failingQuality.length > 0) {
console.log("");
console.log("Active memories failing offline quality checks:");
for (const item of failingQuality.slice(0, 8)) {
console.log(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`);
}
}
console.log("");
console.log("Top rendered candidates:");
const top = retention.rendered.slice(0, 5);
if (top.length === 0) {
console.log(" (none)");
} else {
for (const item of top) {
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
console.log("");
console.log("Weakest active memories:");
const weakest = retention.sorted.slice(-5).reverse();
if (weakest.length === 0) {
console.log(" (none)");
} else {
for (const item of weakest) {
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
if (error instanceof Error) {
process.stderr.write(`memory-diag failed: ${error.message}\n`);
return 1;
}
process.stderr.write(`memory-diag failed: ${String(error)}\n`);
return 1;
}
}
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";
}
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,
type: record.type ?? "project",
source: record.source,
origin,
fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger",
text: record.text,
reasons: record.reasons,
};
}
function sinceCutoff(rawSince: string | undefined): 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 Date.now() - amount * multiplier;
}
const timestamp = new Date(rawSince).getTime();
if (Number.isNaN(timestamp)) die(`Invalid --since value: ${rawSince}`);
return timestamp;
}
function hasSoftReason(record: NormalizedRejection): boolean {
return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason));
}
async function runRejections(options: CliOptions): Promise<void> {
const path = extractionRejectionLogPath();
const { records, invalidLines } = await readJSONLFile<RejectionLogRecord>(path);
const cutoff = sinceCutoff(options.since);
let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null);
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");
console.log("Extraction rejection summary");
console.log("");
console.log(`logPath=${cleanPath(path, options.raw)}`);
if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`);
console.log("");
console.log(`Total rejected: ${normalized.length}`);
console.log("");
console.log("By reason:");
const byReason = sortedCounts(countBy(normalized.flatMap(record => record.reasons)));
if (byReason.length === 0) console.log(" (none)");
else for (const [reason, count] of byReason) console.log(` ${reason.padEnd(24)} ${count}`);
console.log("");
console.log("By origin:");
const byOrigin = sortedCounts(countBy(normalized.map(record => record.origin)));
if (byOrigin.length === 0) console.log(" (none)");
else for (const [origin, count] of byOrigin) console.log(` ${origin.padEnd(24)} ${count}`);
console.log("");
console.log("Trigger-origin rejections (high priority for v1.5):");
const triggerReasons = sortedCounts(countBy(normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons)));
if (triggerReasons.length === 0) console.log(" (none)");
else for (const [reason, count] of triggerReasons) console.log(` ${reason.padEnd(24)} ${count}`);
console.log("");
console.log("Recent suspicious soft rejects:");
const suspicious = normalized
.filter(hasSoftReason)
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
.slice(0, 8);
if (suspicious.length === 0) {
console.log(" (none)");
} else {
for (const record of suspicious) {
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
console.log(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, options.raw)))}`);
console.log(` reasons: ${record.reasons.join(",")}`);
console.log(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`);
}
}
}
function migrationLogsRoot(): string {
return join(dataHome(), "opencode-working-memory", "migration-logs");
}
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));
}
function migrationIdFromPath(path: string): string {
return path.split("/").pop()?.replace(/\.jsonl$/, "") ?? "unknown";
}
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;
}
async function runAudit(options: CliOptions): Promise<void> {
const paths = await migrationLogPaths(options);
console.log("Migration audit report");
console.log("");
if (paths.length === 0) {
console.log("No migration logs found.");
return;
}
for (let i = 0; i < paths.length; i += 1) {
const path = paths[i];
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);
if (i > 0) console.log("");
console.log(`Migration: ${migrationId}`);
console.log(`logPath=${cleanPath(path, options.raw)}`);
if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`);
console.log(`Superseded entries: ${superseded.length}`);
console.log("");
console.log("By hard reason:");
const byHardReason = sortedCounts(countBy(hardReasons));
if (byHardReason.length === 0) console.log(" (none)");
else for (const [reason, count] of byHardReason) console.log(` ${reason.padEnd(24)} ${count}`);
console.log("");
console.log("Potentially risky supersedes:");
console.log(` ${risky.length}`);
for (const item of 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);
console.log(` - [${record.type ?? "unknown"}] hardReasons=${JSON.stringify(hard)} risk=${item.reasons.join(",")} ${JSON.stringify(truncate(cleanText(record.text ?? "", options.raw)))}`);
if (identity) console.log(` ${identity}`);
}
}
}
const { command, options } = parseArgs(process.argv.slice(2));
if (command === "health") await runHealth(options);
else if (command === "rejections") await runRejections(options);
else await runAudit(options);
process.exitCode = await main();
+124
View File
@@ -0,0 +1,124 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,112 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,48 @@
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");
}
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,42 @@
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
@@ -0,0 +1,102 @@
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");
}
@@ -0,0 +1,93 @@
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
@@ -0,0 +1,155 @@
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
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,98 @@
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
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,42 @@
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
@@ -0,0 +1,136 @@
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
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,69 @@
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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,157 @@
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
@@ -0,0 +1,236 @@
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),
});
}
+530
View File
@@ -0,0 +1,530 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
import { redactCredentials } from "./redaction.ts";
export type EvidenceEventType =
| "extraction_candidate_accepted"
| "extraction_candidate_rejected"
| "explicit_memory_detected"
| "explicit_memory_ignored"
| "pending_memory_appended"
| "pending_memory_cleared"
| "promotion_promoted"
| "promotion_absorbed_exact"
| "promotion_absorbed_identity"
| "promotion_superseded"
| "promotion_rejected_capacity"
| "promotion_retry_scheduled"
| "promotion_retry_exhausted"
| "memory_reinforced"
| "memory_migration_superseded"
| "render_selected"
| "render_omitted"
| "memory_removed_capacity"
| "storage_corrupt_json_quarantined"
| "storage_stale_lock_recovered"
| "storage_lock_timeout"
| "hook_failed";
export type EvidencePhase =
| "extraction"
| "explicit"
| "pending_journal"
| "promotion"
| "reinforcement"
| "render"
| "storage"
| "hook";
export type EvidenceOutcome =
| "accepted"
| "rejected"
| "promoted"
| "absorbed"
| "superseded"
| "rendered"
| "omitted"
| "removed"
| "retried"
| "exhausted"
| "reinforced"
| "quarantined"
| "failed"
| "recovered";
export type MemoryEvidenceRef = {
memoryId?: string;
memoryKeyHash?: string;
identityKeyHash?: string;
type?: "feedback" | "project" | "decision" | "reference";
source?: "explicit" | "compaction" | "manual";
status?: "active" | "superseded";
};
export type EvidenceRelation = {
role:
| "candidate"
| "pending"
| "promoted"
| "retained"
| "absorbed"
| "superseded"
| "superseded_by"
| "reinforced"
| "reinforced_by"
| "rendered"
| "omitted"
| "removed";
memory?: MemoryEvidenceRef;
};
export type EvidenceDetailValue = string | number | boolean | null | string[] | number[];
export type EvidenceEventV1 = {
version: 1;
eventId: string;
createdAt: string;
workspaceKey: string;
workspaceRootHash: string;
sessionHash?: string;
messageHash?: string;
type: EvidenceEventType;
phase: EvidencePhase;
outcome: EvidenceOutcome;
memory?: MemoryEvidenceRef;
relations?: EvidenceRelation[];
reasonCodes: string[];
details?: Record<string, EvidenceDetailValue>;
textPreview?: string;
};
export type EvidenceEventInput = Omit<
EvidenceEventV1,
"version" | "eventId" | "createdAt" | "workspaceKey" | "workspaceRootHash"
>;
export type EvidenceQuery = {
since?: string;
until?: string;
types?: EvidenceEventType[];
phases?: EvidencePhase[];
outcomes?: EvidenceOutcome[];
memoryId?: string;
memoryKeyHash?: string;
identityKeyHash?: string;
sessionHash?: string;
limit?: number;
newestFirst?: boolean;
};
export type MemoryEvidenceSummary = {
memoryId?: string;
memoryKeyHash?: string;
latestOutcome?: EvidenceOutcome;
latestRenderStatus?: "rendered" | "omitted";
reasonCodes: string[];
eventIds: string[];
lastEventAt?: string;
};
export type MemoryLifecycleTrace = {
memoryId?: string;
memoryKeyHash?: string;
identityKeyHash?: string;
events: EvidenceEventV1[];
createdBy?: EvidenceEventV1;
acceptedBy?: EvidenceEventV1;
promotedBy?: EvidenceEventV1;
absorbedBy?: EvidenceEventV1;
supersededBy?: EvidenceEventV1;
reinforcedBy: EvidenceEventV1[];
latestRender?: EvidenceEventV1;
currentStatus:
| "accepted"
| "pending"
| "promoted"
| "absorbed"
| "superseded"
| "rendered"
| "omitted"
| "rejected"
| "unknown";
};
export const EVIDENCE_LOG_LIMITS = {
maxAgeDays: 90,
maxEventsPerWorkspace: 5000,
maxBytesPerWorkspace: 2 * 1024 * 1024,
pruneEveryAppendCount: 100,
} as const;
const appendCounts = new Map<string, number>();
const HASH_PATTERN = /^[a-f0-9]{16}$/i;
const DAY_MS = 24 * 60 * 60 * 1000;
const MAX_DETAIL_STRING_CHARS = 240;
const MAX_DETAIL_ARRAY_ITEMS = 25;
function evidenceHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function normalizeHashValue(value: string | undefined): string | undefined {
if (!value) return undefined;
return HASH_PATTERN.test(value) ? value.toLowerCase() : evidenceHash(value);
}
async function resolvedRoot(root: string): Promise<string> {
return realpath(root).catch(() => root);
}
function evidenceTextPreview(text: string, maxChars = 120): string {
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
}
function sanitizeReasonCode(reason: string): string {
return reason.replace(/[^a-zA-Z0-9_.:-]/g, "_").slice(0, 120);
}
function sanitizeMemoryRef(memory: MemoryEvidenceRef | undefined): MemoryEvidenceRef | undefined {
if (!memory) return undefined;
const sanitized: MemoryEvidenceRef = {};
if (typeof memory.memoryId === "string" && memory.memoryId) sanitized.memoryId = memory.memoryId.slice(0, 160);
if (memory.memoryKeyHash) sanitized.memoryKeyHash = normalizeHashValue(memory.memoryKeyHash);
if (memory.identityKeyHash) sanitized.identityKeyHash = normalizeHashValue(memory.identityKeyHash);
if (memory.type) sanitized.type = memory.type;
if (memory.source) sanitized.source = memory.source;
if (memory.status) sanitized.status = memory.status;
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
}
function sanitizeRelations(relations: EvidenceRelation[] | undefined): EvidenceRelation[] | undefined {
if (!relations) return undefined;
const sanitized = relations
.map(relation => ({
role: relation.role,
memory: sanitizeMemoryRef(relation.memory),
}))
.slice(0, 25);
return sanitized.length > 0 ? sanitized : undefined;
}
function sanitizeDetailString(value: string): string {
return evidenceTextPreview(value, MAX_DETAIL_STRING_CHARS);
}
function sanitizeDetails(details: EvidenceEventInput["details"]): EvidenceEventV1["details"] {
if (!details) return undefined;
const sanitized: Record<string, EvidenceDetailValue> = {};
for (const [rawKey, rawValue] of Object.entries(details).slice(0, 50)) {
const key = rawKey.replace(/[^a-zA-Z0-9_.:-]/g, "_").slice(0, 80);
if (!key) continue;
if (typeof rawValue === "string") {
sanitized[key] = sanitizeDetailString(rawValue);
} else if (typeof rawValue === "number") {
if (Number.isFinite(rawValue)) sanitized[key] = rawValue;
} else if (typeof rawValue === "boolean" || rawValue === null) {
sanitized[key] = rawValue;
} else if (Array.isArray(rawValue)) {
if (rawValue.every(item => typeof item === "string")) {
sanitized[key] = rawValue.slice(0, MAX_DETAIL_ARRAY_ITEMS).map(item => sanitizeDetailString(item));
} else if (rawValue.every(item => typeof item === "number" && Number.isFinite(item))) {
sanitized[key] = rawValue.slice(0, MAX_DETAIL_ARRAY_ITEMS) as number[];
}
}
}
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
}
function buildEvidenceEvent(
input: EvidenceEventInput,
workspaceKeyValue: string,
workspaceRootHash: string,
): EvidenceEventV1 {
const textPreviewMax = input.type === "extraction_candidate_rejected" ? 80 : 120;
const event: EvidenceEventV1 = {
version: 1,
eventId: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`,
createdAt: new Date().toISOString(),
workspaceKey: workspaceKeyValue,
workspaceRootHash,
type: input.type,
phase: input.phase,
outcome: input.outcome,
reasonCodes: input.reasonCodes.map(sanitizeReasonCode).filter(Boolean).slice(0, 25),
};
const memory = sanitizeMemoryRef(input.memory);
const relations = sanitizeRelations(input.relations);
const details = sanitizeDetails(input.details);
if (input.sessionHash) event.sessionHash = normalizeHashValue(input.sessionHash);
if (input.messageHash) event.messageHash = normalizeHashValue(input.messageHash);
if (memory) event.memory = memory;
if (relations) event.relations = relations;
if (details) event.details = details;
if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax);
return event;
}
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
try {
await mkdir(dirname(path), { recursive: true });
await appendFile(path, `${line}\n`, "utf8");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[memory] failed to write evidence event: ${message}`);
}
}
async function maybePruneEvidenceLog(path: string): Promise<void> {
const nextCount = (appendCounts.get(path) ?? 0) + 1;
appendCounts.set(path, nextCount);
if (nextCount % EVIDENCE_LOG_LIMITS.pruneEveryAppendCount !== 0) return;
try {
await pruneEvidenceLogPath(path);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[memory] failed to prune evidence log: ${message}`);
}
}
export async function appendEvidenceEvent(root: string, event: EvidenceEventInput): Promise<EvidenceEventV1> {
const records = await appendEvidenceEvents(root, [event]);
return records[0];
}
export async function appendEvidenceEvents(root: string, events: EvidenceEventInput[]): Promise<EvidenceEventV1[]> {
const path = await workspaceEvidenceLogPath(root);
const rootPath = await resolvedRoot(root);
const workspaceRootHash = evidenceHash(rootPath);
const workspaceKeyValue = await workspaceKey(root);
const records = events.map(event => buildEvidenceEvent(event, workspaceKeyValue, workspaceRootHash));
for (const record of records) {
await safeAppendEvidenceLine(path, JSON.stringify(record));
await maybePruneEvidenceLog(path);
}
return records;
}
export async function appendEvidenceEventForWorkspaceKey(
workspaceKeyValue: string,
event: EvidenceEventInput,
): Promise<EvidenceEventV1> {
const path = join(dataHome(), "opencode-working-memory", "workspaces", workspaceKeyValue, "evidence", "events.jsonl");
const record = buildEvidenceEvent(event, workspaceKeyValue, workspaceKeyValue);
await safeAppendEvidenceLine(path, JSON.stringify(record));
await maybePruneEvidenceLog(path);
return record;
}
type ParsedEvidenceLine = {
event: EvidenceEventV1;
index: number;
};
function parseEvidenceLine(line: string): EvidenceEventV1 | null {
try {
const parsed = JSON.parse(line) as Partial<EvidenceEventV1>;
if (parsed.version !== 1 || !parsed.eventId || !parsed.createdAt || !parsed.type) return null;
return parsed as EvidenceEventV1;
} catch {
return null;
}
}
async function readEvidenceLines(path: string, warnInvalid: boolean): Promise<{ valid: ParsedEvidenceLine[]; invalid: string[] }> {
if (!existsSync(path)) return { valid: [], invalid: [] };
const raw = await readFile(path, "utf8");
const valid: ParsedEvidenceLine[] = [];
const invalid: string[] = [];
raw.split(/\n/).forEach((line, index) => {
if (!line.trim()) return;
const event = parseEvidenceLine(line);
if (event) {
valid.push({ event, index });
} else {
invalid.push(line);
if (warnInvalid) console.warn(`[memory] skipped invalid evidence log line ${index + 1}`);
}
});
return { valid, invalid };
}
function eventTimeMs(event: EvidenceEventV1): number {
const ms = new Date(event.createdAt).getTime();
return Number.isFinite(ms) ? ms : 0;
}
async function atomicWriteText(path: string, text: string): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
try {
await writeFile(tmp, text, { encoding: "utf8", mode: 0o600 });
await rename(tmp, path);
} catch (error) {
await rm(tmp, { force: true }).catch(() => undefined);
throw error;
}
}
function serializeEvents(events: EvidenceEventV1[]): string {
return events.map(event => JSON.stringify(event)).join("\n") + (events.length > 0 ? "\n" : "");
}
function trimEventsToByteLimit(events: EvidenceEventV1[]): EvidenceEventV1[] {
let kept = [...events];
while (kept.length > 0 && Buffer.byteLength(serializeEvents(kept), "utf8") > EVIDENCE_LOG_LIMITS.maxBytesPerWorkspace) {
kept = kept.slice(1);
}
return kept;
}
async function pruneEvidenceLogPath(path: string): Promise<void> {
if (!existsSync(path)) return;
const stats = await stat(path);
if (stats.isDirectory()) return;
const { valid, invalid } = await readEvidenceLines(path, false);
if (invalid.length > 0) {
const corruptPath = `${path}.corrupt-lines-${Date.now()}.jsonl`;
await writeFile(corruptPath, invalid.join("\n") + "\n", { encoding: "utf8", mode: 0o600 }).catch(error => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[memory] failed to quarantine invalid evidence lines: ${message}`);
});
}
const cutoff = Date.now() - EVIDENCE_LOG_LIMITS.maxAgeDays * DAY_MS;
let events = valid
.filter(item => eventTimeMs(item.event) >= cutoff)
.sort((a, b) => eventTimeMs(a.event) - eventTimeMs(b.event) || a.index - b.index)
.map(item => item.event);
if (events.length > EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace) {
events = events.slice(events.length - EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace);
}
events = trimEventsToByteLimit(events);
await atomicWriteText(path, serializeEvents(events));
}
function memoryRefMatches(memory: MemoryEvidenceRef | undefined, query: Pick<EvidenceQuery, "memoryId" | "memoryKeyHash" | "identityKeyHash">): boolean {
if (!memory) return false;
const memoryKeyHash = normalizeHashValue(query.memoryKeyHash);
const identityKeyHash = normalizeHashValue(query.identityKeyHash);
if (query.memoryId && memory.memoryId === query.memoryId) return true;
if (memoryKeyHash && memory.memoryKeyHash === memoryKeyHash) return true;
if (identityKeyHash && memory.identityKeyHash === identityKeyHash) return true;
return false;
}
function eventMatchesMemory(event: EvidenceEventV1, query: Pick<EvidenceQuery, "memoryId" | "memoryKeyHash" | "identityKeyHash">): boolean {
if (!query.memoryId && !query.memoryKeyHash && !query.identityKeyHash) return true;
if (memoryRefMatches(event.memory, query)) return true;
return (event.relations ?? []).some(relation => memoryRefMatches(relation.memory, query));
}
export async function queryEvidenceEvents(
root: string,
query: EvidenceQuery = {},
): Promise<EvidenceEventV1[]> {
const path = await workspaceEvidenceLogPath(root);
const { valid } = await readEvidenceLines(path, true);
const sinceMs = query.since ? new Date(query.since).getTime() : undefined;
const untilMs = query.until ? new Date(query.until).getTime() : undefined;
const sessionHash = normalizeHashValue(query.sessionHash);
let events = valid.map(item => item.event).filter(event => {
const createdAtMs = eventTimeMs(event);
if (Number.isFinite(sinceMs) && createdAtMs < sinceMs) return false;
if (Number.isFinite(untilMs) && createdAtMs > untilMs) return false;
if (query.types && !query.types.includes(event.type)) return false;
if (query.phases && !query.phases.includes(event.phase)) return false;
if (query.outcomes && !query.outcomes.includes(event.outcome)) return false;
if (sessionHash && event.sessionHash !== sessionHash) return false;
if (!eventMatchesMemory(event, query)) return false;
return true;
});
if (query.newestFirst) events = events.slice().reverse();
if (typeof query.limit === "number" && query.limit >= 0) events = events.slice(0, query.limit);
return events;
}
export async function summarizeMemoryEvidence(
root: string,
input: { memoryId?: string; memoryKeyHash?: string },
): Promise<MemoryEvidenceSummary> {
const events = await queryEvidenceEvents(root, input);
const latest = events.at(-1);
const latestRender = events.filter(event => event.phase === "render").at(-1);
const reasonCodes = new Set<string>();
for (const event of events) {
for (const reason of event.reasonCodes) reasonCodes.add(reason);
}
return {
memoryId: input.memoryId,
memoryKeyHash: normalizeHashValue(input.memoryKeyHash),
latestOutcome: latest?.outcome,
latestRenderStatus: latestRender?.outcome === "rendered" || latestRender?.outcome === "omitted"
? latestRender.outcome
: undefined,
reasonCodes: [...reasonCodes],
eventIds: events.map(event => event.eventId),
lastEventAt: latest?.createdAt,
};
}
function currentStatusFromEvent(event: EvidenceEventV1 | undefined): MemoryLifecycleTrace["currentStatus"] {
if (!event) return "unknown";
if (event.outcome === "accepted") return "accepted";
if (event.outcome === "promoted") return "promoted";
if (event.outcome === "absorbed") return "absorbed";
if (event.outcome === "superseded") return "superseded";
if (event.outcome === "rendered") return "rendered";
if (event.outcome === "omitted") return "omitted";
if (event.outcome === "rejected") return "rejected";
if (event.type === "pending_memory_appended") return "pending";
return "unknown";
}
export async function traceMemoryLifecycle(
root: string,
input: { memoryId?: string; memoryKeyHash?: string; identityKeyHash?: string },
): Promise<MemoryLifecycleTrace> {
const events = await queryEvidenceEvents(root, input);
const createdBy = events.find(event => event.type === "explicit_memory_detected" || event.type === "pending_memory_appended");
const acceptedBy = events.find(event => event.type === "extraction_candidate_accepted" || event.type === "explicit_memory_detected");
const promotedBy = events.find(event => event.type === "promotion_promoted");
const absorbedBy = events.find(event => event.outcome === "absorbed");
const supersededBy = events.find(event => event.outcome === "superseded");
const reinforcedBy = events.filter(event => event.type === "memory_reinforced" || event.outcome === "reinforced");
const latestRender = events.filter(event => event.phase === "render").at(-1);
const latest = events.at(-1);
return {
memoryId: input.memoryId,
memoryKeyHash: normalizeHashValue(input.memoryKeyHash),
identityKeyHash: normalizeHashValue(input.identityKeyHash),
events,
createdBy,
acceptedBy,
promotedBy,
absorbedBy,
supersededBy,
reinforcedBy,
latestRender,
currentStatus: currentStatusFromEvent(latest),
};
}
+186 -27
View File
@@ -6,6 +6,7 @@ import { LONG_TERM_LIMITS } from "./types.ts";
import { assessMemoryQuality } from "./memory-quality.ts";
import { extractionRejectionLogPath } from "./paths.ts";
import { redactCredentials } from "./redaction.ts";
import type { EvidenceEventInput } from "./evidence-log.ts";
function id(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -46,6 +47,34 @@ function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
}
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
return extractExplicitMemoriesWithEvidence(text).entries;
}
export type WorkspaceMemoryParseResult = {
entries: LongTermMemoryEntry[];
evidence: EvidenceEventInput[];
};
function evidenceTextPreview(text: string, maxChars = 120): string {
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
}
function memoryEvidence(memory: LongTermMemoryEntry): EvidenceEventInput["memory"] {
return {
memoryId: memory.id,
type: memory.type,
source: memory.source,
status: memory.status,
};
}
function extractionEvidence(
input: Pick<EvidenceEventInput, "type" | "phase" | "outcome" | "reasonCodes" | "textPreview" | "memory" | "details">,
): EvidenceEventInput {
return input;
}
export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemoryParseResult {
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
const patterns = [
@@ -71,29 +100,74 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
const nowMs = Date.now();
const now = new Date(nowMs).toISOString();
const entries: LongTermMemoryEntry[] = [];
const evidence: EvidenceEventInput[] = [];
const seen = new Set<string>();
const negatedLinePattern = /(?:^|\n)\s*(?:(?:please\s+)?(?:do\s+not|don't|dont|never)\s+remember(?:\s+(?:this|that))?|不要\s*(?:記住|记住)|別\s*(?:記住|记住)|别\s*(?:記住|记住))[::,,]?\s*(.+)$/gim;
for (const match of text.matchAll(negatedLinePattern)) {
evidence.push(extractionEvidence({
type: "explicit_memory_ignored",
phase: "explicit",
outcome: "rejected",
reasonCodes: ["negated_request"],
textPreview: evidenceTextPreview(match[1] ?? match[0], 80),
}));
}
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const body = match[1]?.trim();
if (!body || body.length < 8) continue;
if (body && /^(再说|再說|later|next time)$/i.test(body)) {
evidence.push(extractionEvidence({
type: "explicit_memory_ignored",
phase: "explicit",
outcome: "rejected",
reasonCodes: ["deferral"],
textPreview: evidenceTextPreview(body, 80),
}));
continue;
}
if (!body || body.length < 8) {
evidence.push(extractionEvidence({
type: "explicit_memory_ignored",
phase: "explicit",
outcome: "rejected",
reasonCodes: ["too_short"],
textPreview: evidenceTextPreview(body ?? match[0], 80),
}));
continue;
}
// Calculate actual trigger position (after possible newline)
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
// Check if this is a negated request (e.g., "不要記住")
if (isNegatedMemoryRequest(text, triggerIndex)) continue;
if (isNegatedMemoryRequest(text, triggerIndex)) {
evidence.push(extractionEvidence({
type: "explicit_memory_ignored",
phase: "explicit",
outcome: "rejected",
reasonCodes: ["negated_request"],
textPreview: evidenceTextPreview(body, 80),
}));
continue;
}
// Check if it's a deferral (e.g., "later", "next time")
if (/^(再说|再說|later|next time)$/i.test(body)) continue;
// Dedupe by canonical body
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
if (seen.has(key)) continue;
if (seen.has(key)) {
evidence.push(extractionEvidence({
type: "explicit_memory_ignored",
phase: "explicit",
outcome: "rejected",
reasonCodes: ["duplicate_in_message"],
textPreview: evidenceTextPreview(body, 80),
}));
continue;
}
seen.add(key);
const type = classifyExplicitMemory(body);
entries.push({
const memory: LongTermMemoryEntry = {
id: id("mem"),
type,
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
@@ -104,11 +178,20 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
updatedAt: now,
retentionClock: nowMs,
staleAfterDays: staleAfterDaysFor(type),
});
};
entries.push(memory);
evidence.push(extractionEvidence({
type: "explicit_memory_detected",
phase: "explicit",
outcome: "accepted",
reasonCodes: ["explicit_trigger_matched"],
memory: memoryEvidence(memory),
textPreview: evidenceTextPreview(memory.text),
}));
}
}
return entries;
return { entries, evidence };
}
function classifyExplicitMemory(text: string): LongTermType {
@@ -239,6 +322,13 @@ type ExtractionRejectionLogEntry = {
text: string;
reasons: string[];
source: "compaction";
workspaceKey?: string;
workspaceRootHash?: string;
};
type WorkspaceMemoryCandidateParseOptions = {
workspaceKey?: string;
workspaceRootHash?: string;
};
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
@@ -251,15 +341,15 @@ async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promi
}
}
function shouldAcceptWorkspaceMemoryCandidate(
function evaluateWorkspaceMemoryCandidate(
entry: {
type: LongTermType;
text: string;
},
options: {
fromMemoryTrigger?: boolean;
} = {},
): boolean {
} & WorkspaceMemoryCandidateParseOptions = {},
): { accepted: boolean; reasons: string[] } {
const text = entry.text.trim();
const minLength = options.fromMemoryTrigger ? 6 : 20;
@@ -267,14 +357,14 @@ function shouldAcceptWorkspaceMemoryCandidate(
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
// Stable config values can be short — allow below generic min length
} else if (text.length < minLength) {
return false;
return { accepted: false, reasons: ["too_short"] };
}
// Indirect Prompt Injection / Adversarial Instructions
// Rejects attempts to overwrite system behavior or "ignore" rules.
// comparative "instead of" is allowed.
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false;
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return { accepted: false, reasons: ["prompt_injection"] };
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return { accepted: false, reasons: ["prompt_injection"] };
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
if (!quality.accepted) {
@@ -284,11 +374,25 @@ function shouldAcceptWorkspaceMemoryCandidate(
text: redactCredentials(text),
reasons: quality.reasons,
source: "compaction",
workspaceKey: options.workspaceKey,
workspaceRootHash: options.workspaceRootHash,
});
return false;
return { accepted: false, reasons: quality.reasons };
}
return true;
return { accepted: true, reasons: ["quality_gate_passed"] };
}
function shouldAcceptWorkspaceMemoryCandidate(
entry: {
type: LongTermType;
text: string;
},
options: {
fromMemoryTrigger?: boolean;
} & WorkspaceMemoryCandidateParseOptions = {},
): boolean {
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
}
/**
@@ -315,13 +419,24 @@ function extractCandidateBlock(summary: string): string | null {
return null;
}
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
export function parseWorkspaceMemoryCandidates(
summary: string,
options?: WorkspaceMemoryCandidateParseOptions,
): LongTermMemoryEntry[] {
return parseWorkspaceMemoryCandidatesWithEvidence(summary, options).entries;
}
export function parseWorkspaceMemoryCandidatesWithEvidence(
summary: string,
options: WorkspaceMemoryCandidateParseOptions = {},
): WorkspaceMemoryParseResult {
const block = extractCandidateBlock(summary);
if (!block) return [];
if (!block) return { entries: [], evidence: [] };
const nowMs = Date.now();
const now = new Date(nowMs).toISOString();
const entries: LongTermMemoryEntry[] = [];
const evidence: EvidenceEventInput[] = [];
for (const line of block.split("\n")) {
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
@@ -331,18 +446,53 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
if (!item) continue;
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
const normalizedBody = normalizeCandidateBody(item[3]);
if (!normalizedBody) continue;
if (!normalizedBody) {
evidence.push(extractionEvidence({
type: "extraction_candidate_rejected",
phase: "extraction",
outcome: "rejected",
reasonCodes: ["negated_request"],
memory: { type, source: "compaction" },
textPreview: evidenceTextPreview(item[3], 80),
}));
continue;
}
const minLength = normalizedBody.hadTrigger ? 6 : 12;
if (normalizedBody.text.length < minLength) continue;
if (normalizedBody.text.length < minLength) {
evidence.push(extractionEvidence({
type: "extraction_candidate_rejected",
phase: "extraction",
outcome: "rejected",
reasonCodes: ["too_short"],
memory: { type, source: "compaction" },
textPreview: evidenceTextPreview(normalizedBody.text, 80),
}));
continue;
}
// Apply quality gate
if (!shouldAcceptWorkspaceMemoryCandidate(
const quality = evaluateWorkspaceMemoryCandidate(
{ type, text: normalizedBody.text },
{ fromMemoryTrigger: normalizedBody.hadTrigger },
)) continue;
{
fromMemoryTrigger: normalizedBody.hadTrigger,
workspaceKey: options.workspaceKey,
workspaceRootHash: options.workspaceRootHash,
},
);
if (!quality.accepted) {
evidence.push(extractionEvidence({
type: "extraction_candidate_rejected",
phase: "extraction",
outcome: "rejected",
reasonCodes: quality.reasons,
memory: { type, source: "compaction" },
textPreview: evidenceTextPreview(normalizedBody.text, 80),
}));
continue;
}
entries.push({
const memory: LongTermMemoryEntry = {
id: id("mem"),
type,
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
@@ -353,8 +503,17 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
updatedAt: now,
retentionClock: nowMs,
staleAfterDays: staleAfterDaysFor(type),
});
};
entries.push(memory);
evidence.push(extractionEvidence({
type: "extraction_candidate_accepted",
phase: "extraction",
outcome: "accepted",
reasonCodes: ["quality_gate_passed", "valid_candidate_format"],
memory: memoryEvidence(memory),
textPreview: evidenceTextPreview(memory.text),
}));
}
return entries;
return { entries, evidence };
}
+27 -3
View File
@@ -78,15 +78,39 @@ export function isFeedbackQualityViolation(text: string): boolean {
return true;
}
export function isDecisionQualityViolation(text: string): boolean {
const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
export function hasFutureRule(text: string): boolean {
return /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
if (!futureRule) return true;
}
export function isArchitectureLikeDecision(text: string): boolean {
if (/\b(?:[A-Z][A-Z0-9]*_[A-Z0-9_]*|[A-Z][A-Z0-9]{3,})\b/.test(text)) return true;
if (/\b(?:schema|model|scoring|retention|cap|evidence|normalization|root cause|architecture(?!\s+keywords?)|boundary|rule|memory system)\b/i.test(text)) return true;
if (/(?:模型|架構|架构|证据|證據|規則|规则|邊界|边界|記憶系統|记忆系统|原因|採用|采用)/u.test(text)) return true;
return false;
}
export function isImplementationStatusDecision(text: string): boolean {
if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true;
if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true;
return false;
}
export function isDecisionQualityViolation(text: string): boolean {
if (hasFutureRule(text)) {
if (isImplementationStatusDecision(text)) return true;
return false;
}
if (isArchitectureLikeDecision(text)) {
if (isImplementationStatusDecision(text)) return true;
if (/\b(?:session|wave|task|test|CI|compatibility|commit)\b/i.test(text)) return true;
return false;
}
return true;
}
function isRawErrorViolation(text: string): boolean {
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
+4
View File
@@ -24,6 +24,10 @@ export async function workspacePendingJournalPath(root: string): Promise<string>
return join(await memoryRoot(root), "workspace-pending-journal.json");
}
export async function workspaceEvidenceLogPath(root: string): Promise<string> {
return join(await memoryRoot(root), "evidence", "events.jsonl");
}
export async function sessionStatePath(root: string, sessionID: string): Promise<string> {
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
+267 -136
View File
@@ -23,21 +23,23 @@
* - Parses compaction summaries for memory candidates and merges them
*/
import { rm } from "fs/promises";
import { createHash } from "node:crypto";
import { realpath, rm } from "fs/promises";
import type { Plugin } from "@opencode-ai/plugin";
import {
extractExplicitMemories,
extractExplicitMemoriesWithEvidence,
extractActiveFiles,
extractErrorsFromBash,
parseWorkspaceMemoryCandidates,
parseWorkspaceMemoryCandidatesWithEvidence,
} from "./extractors.ts";
import {
loadWorkspaceMemory,
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
renderWorkspaceMemory,
reinforceMemory,
accountWorkspaceMemoryRender,
workspaceMemoryIdentityKey,
} from "./workspace-memory.ts";
import { reinforceMemory } from "./retention.ts";
import {
appendPendingMemories,
clearPendingMemories,
@@ -56,14 +58,15 @@ import {
addRecentDecision,
renderHotSessionState,
} from "./session-state.ts";
import { sessionStatePath } from "./paths.ts";
import { sessionStatePath, workspaceKey } from "./paths.ts";
import {
latestUserText,
latestCompactionSummary,
pendingTodos,
} from "./opencode.ts";
import { accountPendingPromotions } from "./promotion-accounting.ts";
import { WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts";
import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts";
import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
/**
* Build the complete compaction prompt.
@@ -113,6 +116,9 @@ function buildCompactionPrompt(privateContext: string): string {
"",
"CRITICAL MEMORY RULES:",
"- 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 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.",
@@ -162,6 +168,38 @@ function renderTodosForCompaction(todos: Array<{ content: string; status: string
return lines.join("\n");
}
function safeErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.replace(/\s+/g, " ").slice(0, 240);
}
async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise<void> {
const message = safeErrorMessage(error);
console.error(`[memory] ${scope} failed: ${message}`);
if (root) {
await appendEvidenceEvent(root, {
type: "hook_failed",
phase: "hook",
outcome: "failed",
reasonCodes: [scope],
details: { message },
}).catch(() => undefined);
}
}
async function workspaceRootHash(root: string): Promise<string> {
const resolved = await realpath(root).catch(() => root);
return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
}
async function workspaceIdentity(root: string): Promise<{ workspaceKey: string; workspaceRootHash: string }> {
const [workspaceKeyValue, workspaceRootHashValue] = await Promise.all([
workspaceKey(root),
workspaceRootHash(root),
]);
return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue };
}
export const MemoryV2Plugin: Plugin = async (input) => {
const { directory, client } = input;
@@ -198,6 +236,29 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Cache for processed user message IDs (to avoid duplicate processing)
const processedUserMessages = new Map<string, Set<string>>();
function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
return {
memoryId: memory.id,
memoryKeyHash: memoryKey(memory),
identityKeyHash: workspaceMemoryIdentityKey(memory),
type: memory.type,
source: memory.source,
status: memory.status,
};
}
function pendingAppendedEvidence(memory: LongTermMemoryEntry): EvidenceEventInput {
return {
type: "pending_memory_appended",
phase: "pending_journal",
outcome: "accepted",
memory: memoryEvidenceRef(memory),
relations: [{ role: "pending", memory: memoryEvidenceRef(memory) }],
reasonCodes: ["pending_journal_append"],
textPreview: memory.text,
};
}
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
@@ -254,7 +315,14 @@ export const MemoryV2Plugin: Plugin = async (input) => {
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
const memories = extractExplicitMemories(latestMessage.text).map(memory => ({
const extraction = extractExplicitMemoriesWithEvidence(latestMessage.text);
await appendEvidenceEvents(directory, extraction.evidence.map(event => ({
...event,
sessionHash: sessionID,
messageHash: latestMessage.id,
})));
const memories = extraction.entries.map(memory => ({
...memory,
pendingOwnerSessionID: sessionID,
pendingMessageID: latestMessage.id,
@@ -267,6 +335,11 @@ export const MemoryV2Plugin: Plugin = async (input) => {
return state;
});
await appendPendingMemories(directory, memories);
await appendEvidenceEvents(directory, memories.map(memory => ({
...pendingAppendedEvidence(memory),
sessionHash: sessionID,
messageHash: latestMessage.id,
})));
}
if (decisions.length > 0) {
@@ -285,6 +358,22 @@ export const MemoryV2Plugin: Plugin = async (input) => {
rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession);
}
function dedupePendingPromotionMemories(
memories: LongTermMemoryEntry[],
): LongTermMemoryEntry[] {
const seen = new Set<string>();
const deduped: LongTermMemoryEntry[] = [];
for (const memory of memories) {
const key = memoryKey(memory);
if (seen.has(key)) continue;
seen.add(key);
deduped.push(memory);
}
return deduped;
}
async function promotePendingMemories(
sessionID?: string,
options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {},
@@ -302,10 +391,10 @@ export const MemoryV2Plugin: Plugin = async (input) => {
return false;
});
const pending = [
const pending = dedupePendingPromotionMemories([
...(sessionState?.pendingMemories ?? []),
...journalPending,
];
]);
if (pending.length === 0) return;
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
@@ -364,6 +453,20 @@ export const MemoryV2Plugin: Plugin = async (input) => {
},
);
await appendEvidenceEvents(directory, [
...updateResult.evidence,
...promotionAccountingEvidenceEvents({
pending,
after: updateResult.store.entries,
events: updateResult.events,
accounting,
exhaustedRejectedKeys,
}),
].map(event => ({
...event,
sessionHash: sessionID,
})));
const sessionRemovalKeys = new Set([
...accounting.clearableKeys,
...exhaustedRejectedKeys,
@@ -438,7 +541,12 @@ export const MemoryV2Plugin: Plugin = async (input) => {
}
const store = await loadWorkspaceMemory(root);
const renderedPrompt = renderWorkspaceMemory(store);
const renderAccounting = accountWorkspaceMemoryRender(store);
const renderedPrompt = renderAccounting.prompt;
await appendEvidenceEvents(root, renderAccounting.evidence.map(event => ({
...event,
sessionHash: sessionID,
})));
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
pruneFrozenWorkspaceMemoryCache(now);
return { store, renderedPrompt };
@@ -462,38 +570,42 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const { sessionID } = hookInput;
if (!sessionID) return;
pruneFrozenWorkspaceMemoryCache();
pruneProcessedUserMessagesCache();
try {
pruneFrozenWorkspaceMemoryCache();
pruneProcessedUserMessagesCache();
// Sub-agents are short-lived - skip memory system
if (await isSubAgent(sessionID)) return;
// Sub-agents are short-lived - skip memory system
if (await isSubAgent(sessionID)) return;
// Process explicit user memory even on no-tool turns. Keep this after the
// sub-agent guard so child sessions never append to the parent journal.
await processLatestUserMessage(sessionID);
// Process explicit user memory even on no-tool turns. Keep this after the
// sub-agent guard so child sessions never append to the parent journal.
await processLatestUserMessage(sessionID);
// Before first snapshot in this session, promote durable unowned backlog from
// prior sessions. Current-turn owned explicit memory remains pending and only
// appears in hot state for this transform.
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
}
// Before first snapshot in this session, promote durable unowned backlog from
// prior sessions. Current-turn owned explicit memory remains pending and only
// appears in hot state for this transform.
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
}
// Get frozen workspace memory snapshot (loaded and rendered once per session)
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
// Get frozen workspace memory snapshot (loaded and rendered once per session)
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
// Get current hot session state
const sessionState = await loadSessionState(directory, sessionID);
// Get current hot session state
const sessionState = await loadSessionState(directory, sessionID);
// Inject frozen workspace memory snapshot
if (workspaceSnapshot.renderedPrompt) {
output.system.push(workspaceSnapshot.renderedPrompt);
}
// Inject frozen workspace memory snapshot
if (workspaceSnapshot.renderedPrompt) {
output.system.push(workspaceSnapshot.renderedPrompt);
}
// Render and inject hot session state
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
output.system.push(hotPrompt);
// Render and inject hot session state
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
output.system.push(hotPrompt);
}
} catch (error) {
await warnMemoryHook("chat.system.transform", error, directory);
}
},
@@ -506,50 +618,54 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Sub-agents don't need memory tracking
if (await isSubAgent(sessionID)) return;
await updateSessionState(directory, sessionID, state => {
// Track active files from tool usage
if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") {
const files = extractActiveFiles(
toolName,
args as Record<string, unknown>,
toolOutput ?? ""
);
for (const { path, action } of files) {
touchActiveFile(state, path, action);
if (action === "edit" || action === "write") {
markErrorsMaybeFixedForFile(state, path, directory);
try {
await updateSessionState(directory, sessionID, state => {
// Track active files from tool usage
if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") {
const files = extractActiveFiles(
toolName,
args as Record<string, unknown>,
toolOutput ?? ""
);
for (const { path, action } of files) {
touchActiveFile(state, path, action);
if (action === "edit" || action === "write") {
markErrorsMaybeFixedForFile(state, path, directory);
}
}
}
}
// Track errors from failed bash commands
if (toolName === "bash") {
const argsRecord = args as Record<string, unknown>;
const command: string = typeof argsRecord?.command === "string"
? argsRecord.command
: "";
const outputText: string = toolOutput ?? "";
// Track errors from failed bash commands
if (toolName === "bash") {
const argsRecord = args as Record<string, unknown>;
const command: string = typeof argsRecord?.command === "string"
? argsRecord.command
: "";
const outputText: string = toolOutput ?? "";
// Check if command succeeded - clear errors for that category
const exitCode = bashExitCode(hookOutput);
if (typeof exitCode !== "number") {
// Unknown exit status: do not extract and do not clear
} else if (exitCode === 0 && command) {
clearErrorsForSuccessfulCommand(state, command);
} else if (command) {
// Only extract errors for commands with explicit non-zero exit
const errors = extractErrorsFromBash(command, outputText);
for (const error of errors) {
upsertOpenError(state, error);
// Check if command succeeded - clear errors for that category
const exitCode = bashExitCode(hookOutput);
if (typeof exitCode !== "number") {
// Unknown exit status: do not extract and do not clear
} else if (exitCode === 0 && command) {
clearErrorsForSuccessfulCommand(state, command);
} else if (command) {
// Only extract errors for commands with explicit non-zero exit
const errors = extractErrorsFromBash(command, outputText);
for (const error of errors) {
upsertOpenError(state, error);
}
}
}
}
return state;
});
return state;
});
// Process explicit memory from latest user message
// Only process once per message ID
await processLatestUserMessage(sessionID);
// Process explicit memory from latest user message
// Only process once per message ID
await processLatestUserMessage(sessionID);
} catch (error) {
await warnMemoryHook("tool.execute.after", error, directory);
}
},
/**
@@ -567,95 +683,110 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const { sessionID } = hookInput;
if (!sessionID) return;
// Sub-agents don't need compaction support
if (await isSubAgent(sessionID)) return;
try {
// Sub-agents don't need compaction support
if (await isSubAgent(sessionID)) return;
// Preserve context injected by other plugins that ran before us.
// Setting output.prompt bypasses the default prompt + context join,
// so we must explicitly carry forward any existing output.context.
const otherContext = output.context.filter(Boolean).join("\n\n");
// Preserve context injected by other plugins that ran before us.
// Setting output.prompt bypasses the default prompt + context join,
// so we must explicitly carry forward any existing output.context.
const otherContext = output.context.filter(Boolean).join("\n\n");
// Build our private context (workspace memory, hot state, todos)
const contextParts: string[] = [];
// Build our private context (workspace memory, hot state, todos)
const contextParts: string[] = [];
// 1. Frozen workspace memory snapshot
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
if (workspaceSnapshot.renderedPrompt) {
contextParts.push(workspaceSnapshot.renderedPrompt);
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
if (workspaceSnapshot.renderedPrompt) {
contextParts.push(workspaceSnapshot.renderedPrompt);
}
// 2. Hot session state
const sessionState = await loadSessionState(directory, sessionID);
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
contextParts.push(hotPrompt);
}
// 3. Pending todos from OpenCode
const todos = await pendingTodos(client, sessionID);
const todosPrompt = renderTodosForCompaction(todos);
if (todosPrompt) {
contextParts.push(todosPrompt);
}
// Combine: other plugins' context first, then our private context
const privateContext = [otherContext, ...contextParts]
.filter(Boolean)
.join("\n\n");
// Replace the default prompt entirely with our ---free template
output.prompt = buildCompactionPrompt(privateContext);
// Clear context array since we consumed it into output.prompt.
// Subsequent plugins that set output.prompt will also need to check
// output.context if they want to preserve other plugin contributions.
output.context.length = 0;
} catch (error) {
await warnMemoryHook("session.compacting", error, directory);
}
// 2. Hot session state
const sessionState = await loadSessionState(directory, sessionID);
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
contextParts.push(hotPrompt);
}
// 3. Pending todos from OpenCode
const todos = await pendingTodos(client, sessionID);
const todosPrompt = renderTodosForCompaction(todos);
if (todosPrompt) {
contextParts.push(todosPrompt);
}
// Combine: other plugins' context first, then our private context
const privateContext = [otherContext, ...contextParts]
.filter(Boolean)
.join("\n\n");
// Replace the default prompt entirely with our ---free template
output.prompt = buildCompactionPrompt(privateContext);
// Clear context array since we consumed it into output.prompt.
// Subsequent plugins that set output.prompt will also need to check
// output.context if they want to preserve other plugin contributions.
output.context.length = 0;
},
// Handle session events
event: async ({ event }) => {
if (event.type === "session.compacted") {
const sessionID = sessionIDFromEventProperties(event.properties);
if (!sessionID) return;
// Sub-agents don't need post-compaction processing
if (await isSubAgent(sessionID)) return;
// Parse latest compaction summary for memory candidates, stage them into
// durable pending journal, then promote pending memories.
const summary = await latestCompactionSummary(client, sessionID);
const candidates = summary ? parseWorkspaceMemoryCandidates(summary) : [];
if (candidates.length > 0) {
await appendPendingMemories(directory, candidates);
}
try {
const sessionID = sessionIDFromEventProperties(event.properties);
if (!sessionID) return;
// Sub-agents don't need post-compaction processing
if (await isSubAgent(sessionID)) return;
// Parse latest compaction summary for memory candidates, stage them into
// durable pending journal, then promote pending memories.
const summary = await latestCompactionSummary(client, sessionID);
const parseResult = summary
? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory))
: { entries: [], evidence: [] };
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
...event,
sessionHash: sessionID,
})));
const candidates = parseResult.entries;
if (candidates.length > 0) {
await appendPendingMemories(directory, candidates);
await appendEvidenceEvents(directory, candidates.map(memory => ({
...pendingAppendedEvidence(memory),
sessionHash: sessionID,
})));
}
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
} catch {
} catch (error) {
// Keep pending memories in session/journal for retry on next event/session.
await warnMemoryHook("event.session.compacted", error, directory);
}
}
if (event.type === "session.deleted") {
const sessionID = sessionIDFromEventProperties(event.properties);
if (sessionID) {
try {
const sessionID = sessionIDFromEventProperties(event.properties);
if (sessionID) {
// Promote pending memories before deleting per-session state.
// If promotion fails, leave session state and journal intact.
let promoted = false;
try {
let promoted = false;
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
promoted = true;
} catch {
return;
} finally {
if (promoted) {
frozenWorkspaceMemoryCache.delete(sessionID);
processedUserMessages.delete(sessionID);
sessionParentCache.delete(sessionID);
}
}
await rm(await sessionStatePath(directory, sessionID), { force: true });
await rm(await sessionStatePath(directory, sessionID), { force: true });
}
} catch (error) {
await warnMemoryHook("event.session.deleted", error, directory);
}
}
},
+116
View File
@@ -2,6 +2,7 @@ import type { LongTermMemoryEntry } from "./types.ts";
import { memoryKey } from "./pending-journal.ts";
import type { MemoryConsolidationEvent } from "./workspace-memory.ts";
import { workspaceMemoryIdentityKey } from "./workspace-memory.ts";
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
export type PendingPromotionAccounting = {
promotedKeys: Set<string>;
@@ -105,3 +106,118 @@ export function accountPendingPromotions(input: {
clearableKeys,
};
}
function memoryRef(memory: LongTermMemoryEntry | undefined): MemoryEvidenceRef | undefined {
if (!memory) return undefined;
return {
memoryId: memory.id,
memoryKeyHash: memoryKey(memory),
identityKeyHash: workspaceMemoryIdentityKey(memory),
type: memory.type,
source: memory.source,
status: memory.status,
};
}
function retainedMemoryFor(
pending: LongTermMemoryEntry,
event: MemoryConsolidationEvent | undefined,
after: LongTermMemoryEntry[],
): LongTermMemoryEntry | undefined {
if (event?.retainedId) {
const byId = after.find(memory => memory.id === event.retainedId);
if (byId) return byId;
}
const exactKey = memoryKey(pending);
const identityKey = workspaceMemoryIdentityKey(pending);
return after.find(memory => memory.status !== "superseded" && (
memoryKey(memory) === exactKey || workspaceMemoryIdentityKey(memory) === identityKey
));
}
function promotionEventBase(
type: EvidenceEventInput["type"],
outcome: EvidenceEventInput["outcome"],
memory: LongTermMemoryEntry,
reasonCodes: string[],
): EvidenceEventInput {
return {
type,
phase: "promotion",
outcome,
memory: memoryRef(memory),
reasonCodes,
textPreview: memory.text,
};
}
export function promotionAccountingEvidenceEvents(input: {
pending: LongTermMemoryEntry[];
after: LongTermMemoryEntry[];
events?: MemoryConsolidationEvent[];
accounting: PendingPromotionAccounting;
exhaustedRejectedKeys?: Set<string>;
}): EvidenceEventInput[] {
const terminalByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event]));
const exhaustedRejectedKeys = input.exhaustedRejectedKeys ?? new Set<string>();
const evidence: EvidenceEventInput[] = [];
for (const pending of input.pending) {
const key = memoryKey(pending);
const terminal = terminalByKey.get(key);
const retained = retainedMemoryFor(pending, terminal, input.after);
if (input.accounting.promotedKeys.has(key)) {
evidence.push({
...promotionEventBase("promotion_promoted", "promoted", pending, ["new_workspace_entry"]),
relations: [
{ role: "promoted", memory: memoryRef(retained ?? pending) },
],
});
continue;
}
if (input.accounting.absorbedKeys.has(key)) {
const exact = terminal?.reason !== "absorbed_identity";
evidence.push({
...promotionEventBase(
exact ? "promotion_absorbed_exact" : "promotion_absorbed_identity",
"absorbed",
pending,
[exact ? "same_exact_key" : "same_identity_key"],
),
relations: [
{ role: "absorbed" as const, memory: memoryRef(pending) },
{ role: "retained" as const, memory: memoryRef(retained) },
].filter(relation => relation.memory),
});
continue;
}
if (input.accounting.supersededKeys.has(key)) {
evidence.push({
...promotionEventBase("promotion_superseded", "superseded", pending, ["superseded_existing"]),
relations: [
{ role: "superseded" as const, memory: memoryRef(pending) },
{ role: "superseded_by" as const, memory: memoryRef(retained) },
].filter(relation => relation.memory),
});
continue;
}
if (input.accounting.rejectedKeys.has(key)) {
evidence.push(promotionEventBase("promotion_rejected_capacity", "rejected", pending, ["capacity_rejected"]));
if (input.accounting.retryableRejectedKeys.has(key)) {
evidence.push(promotionEventBase(
exhaustedRejectedKeys.has(key) ? "promotion_retry_exhausted" : "promotion_retry_scheduled",
exhaustedRejectedKeys.has(key) ? "exhausted" : "retried",
pending,
[exhaustedRejectedKeys.has(key) ? "max_attempts_reached" : "retryable_capacity_rejection"],
));
}
}
}
return evidence;
}
+148
View File
@@ -0,0 +1,148 @@
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
// Retention decay model constants (v1.5)
export const BASE_HALF_LIFE_DAYS = 45;
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
export const REINFORCEMENT_MAX_COUNT = 6;
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
export const DORMANT_DECAY_MULTIPLIER = 0.25;
export const DAY_MS = 24 * 60 * 60 * 1000;
export const TYPE_FACTOR = {
reference: 1.0,
project: 1.25,
feedback: 2.25,
decision: 2.5,
} as const;
export const SOURCE_FACTOR = {
compaction: 1.0,
manual: 1.4,
explicit: 2.0,
} as const;
export const USER_IMPORTANCE_FACTOR = {
low: 0.7,
normal: 1.0,
high: 1.5,
} as const;
export const RETENTION_TYPE_MAX = {
feedback: 10,
decision: 10,
project: 8,
reference: 6,
} as const;
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
return typeFactor * sourceFactor * importanceFactor;
}
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
const reinforcementCount = Math.min(
memory.reinforcementCount ?? 0,
REINFORCEMENT_MAX_COUNT,
);
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
return BASE_HALF_LIFE_DAYS / factor;
}
function timestampMs(value: unknown, fallback: number): number {
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
return Number.isFinite(ms) ? ms : fallback;
}
export function calculateRetentionStrength(
memory: LongTermMemoryEntry,
now: number,
lastActivityAt?: string,
): number {
const initialStrength = calculateInitialStrength(memory);
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
// Use retentionClock if available, fallback to updatedAt.
const retentionStart = Number.isFinite(memory.retentionClock)
? memory.retentionClock
: memory.updatedAt ?? memory.createdAt;
const createdAtMs = timestampMs(retentionStart, now);
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
// Calculate strength using exponential decay.
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
}
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
const lastActivity = store.lastActivityAt
? new Date(store.lastActivityAt).getTime()
: now;
if (!Number.isFinite(lastActivity)) return 0;
const daysSinceActivity = (now - lastActivity) / DAY_MS;
return Math.max(0, daysSinceActivity);
}
export function calculateEffectiveAgeDays(
entryStartMs: number,
now: number,
lastActivityAt?: string,
): number {
const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS);
if (!lastActivityAt) return wallAgeDays;
const lastActivityMs = new Date(lastActivityAt).getTime();
if (!Number.isFinite(lastActivityMs)) return wallAgeDays;
const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS;
const overlapStartMs = Math.max(entryStartMs, dormantStartMs);
const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS);
const activeDays = wallAgeDays - dormantOverlapDays;
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
}
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
const d1 = new Date(ts1);
const d2 = new Date(ts2);
return d1.getUTCFullYear() === d2.getUTCFullYear()
&& d1.getUTCMonth() === d2.getUTCMonth()
&& d1.getUTCDate() === d2.getUTCDate();
}
export function reinforceMemory(
memory: LongTermMemoryEntry,
sessionId: string,
now: number,
): LongTermMemoryEntry {
if (memory.lastReinforcedSessionID === sessionId) {
return memory;
}
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
return memory;
}
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
return memory;
}
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
return memory;
}
return {
...memory,
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
retentionClock: now,
};
}
+70 -1
View File
@@ -3,17 +3,66 @@ import { randomUUID } from "crypto";
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
import type { FileHandle } from "fs/promises";
import { dirname } from "path";
import { appendEvidenceEventForWorkspaceKey } from "./evidence-log.ts";
const fileLocks = new Map<string, Promise<unknown>>();
const LOCK_WAIT_TIMEOUT_MS = 5000;
const LOCK_STALE_MS = 30_000;
const LOCK_HEARTBEAT_MS = 1_000;
function workspaceKeyFromStorePath(path: string): string | undefined {
return path.match(/[\\/]opencode-working-memory[\\/]workspaces[\\/]([a-f0-9]{16})[\\/]/i)?.[1];
}
function storeKindFromPath(path: string): string {
if (path.endsWith("workspace-memory.json")) return "workspace_memory";
if (path.endsWith("workspace-pending-journal.json")) return "pending_journal";
if (path.includes(`${"/"}sessions${"/"}`) || path.includes(`${"\\"}sessions${"\\"}`)) return "session_state";
return "unknown";
}
async function emitStorageEvidence(path: string, event: Parameters<typeof appendEvidenceEventForWorkspaceKey>[1]): Promise<void> {
const key = workspaceKeyFromStorePath(path);
if (!key) return;
await appendEvidenceEventForWorkspaceKey(key, event).catch(() => undefined);
}
async function quarantineCorruptJSON(path: string): Promise<string | null> {
const quarantinePath = `${path}.corrupt-${Date.now()}-${process.pid}-${randomUUID()}`;
try {
await rename(path, quarantinePath);
return quarantinePath;
} catch {
return null;
}
}
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
if (!existsSync(path)) return fallback();
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const quarantinePath = await quarantineCorruptJSON(path);
if (quarantinePath) {
console.error(`[memory] invalid JSON in ${path}; quarantined to ${quarantinePath}: ${message}`);
await emitStorageEvidence(path, {
type: "storage_corrupt_json_quarantined",
phase: "storage",
outcome: "quarantined",
reasonCodes: ["invalid_json"],
details: {
storeKind: storeKindFromPath(path),
quarantined: true,
},
});
} else {
console.error(`[memory] invalid JSON in ${path}; using fallback without quarantine: ${message}`);
}
return fallback();
}
}
@@ -82,10 +131,30 @@ async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
if (await isLockStale(lockPath)) {
await rm(lockPath, { force: true });
await emitStorageEvidence(path, {
type: "storage_stale_lock_recovered",
phase: "storage",
outcome: "recovered",
reasonCodes: ["stale_lock"],
details: {
storeKind: storeKindFromPath(path),
waitMs: Date.now() - started,
},
});
continue;
}
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) {
await emitStorageEvidence(path, {
type: "storage_lock_timeout",
phase: "storage",
outcome: "failed",
reasonCodes: ["lock_wait_timeout"],
details: {
storeKind: storeKindFromPath(path),
waitMs: LOCK_WAIT_TIMEOUT_MS,
},
});
throw new Error(`Timed out waiting for lock ${lockPath}`);
}
+276 -163
View File
@@ -6,148 +6,19 @@ import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts"
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
import { redactCredentials } from "./redaction.ts";
import {
RETENTION_TYPE_MAX,
calculateRetentionStrength,
reinforceMemory,
} from "./retention.ts";
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
import { appendEvidenceEvents } from "./evidence-log.ts";
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
const MIN_ENVELOPE_LENGTH = 80;
const MIGRATION_ID = "2026-04-26-p0-cleanup";
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
// Retention decay model constants (v1.5)
const BASE_HALF_LIFE_DAYS = 45;
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
const REINFORCEMENT_MAX_COUNT = 6;
const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
const DORMANT_DECAY_MULTIPLIER = 0.25;
const DAY_MS = 24 * 60 * 60 * 1000;
const TYPE_FACTOR = {
reference: 1.0,
project: 1.25,
feedback: 2.25,
decision: 2.5,
} as const;
const SOURCE_FACTOR = {
compaction: 1.0,
manual: 1.4,
explicit: 2.0,
} as const;
const USER_IMPORTANCE_FACTOR = {
low: 0.7,
normal: 1.0,
high: 1.5,
} as const;
const SAFETY_CRITICAL_FACTOR = 6.0;
const TYPE_MAX = {
feedback: 10,
decision: 10,
project: 8,
reference: 6,
} as const;
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
const safetyFactor = memory.safetyCritical ? SAFETY_CRITICAL_FACTOR : 1.0;
return typeFactor * sourceFactor * importanceFactor * safetyFactor;
}
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
const reinforcementCount = Math.min(
memory.reinforcementCount ?? 0,
REINFORCEMENT_MAX_COUNT,
);
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
return BASE_HALF_LIFE_DAYS / factor;
}
function timestampMs(value: unknown, fallback: number): number {
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
return Number.isFinite(ms) ? ms : fallback;
}
export function calculateRetentionStrength(
memory: LongTermMemoryEntry,
now: number,
lastActivityAt?: string,
): number {
const initialStrength = calculateInitialStrength(memory);
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
// Use retentionClock if available, fallback to updatedAt.
const retentionStart = Number.isFinite(memory.retentionClock)
? memory.retentionClock
: memory.updatedAt ?? memory.createdAt;
const createdAtMs = timestampMs(retentionStart, now);
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
// Calculate strength using exponential decay.
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
}
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
const lastActivity = store.lastActivityAt
? new Date(store.lastActivityAt).getTime()
: now;
if (!Number.isFinite(lastActivity)) return 0;
const daysSinceActivity = (now - lastActivity) / DAY_MS;
return Math.max(0, daysSinceActivity);
}
export function calculateEffectiveAgeDays(
entryStartMs: number,
now: number,
lastActivityAt?: string,
): number {
const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS);
if (!lastActivityAt) return wallAgeDays;
const lastActivityMs = new Date(lastActivityAt).getTime();
if (!Number.isFinite(lastActivityMs)) return wallAgeDays;
const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS;
const overlapStartMs = Math.max(entryStartMs, dormantStartMs);
const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS);
const activeDays = wallAgeDays - dormantOverlapDays;
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
}
export function reinforceMemory(
memory: LongTermMemoryEntry,
sessionId: string,
now: number,
): LongTermMemoryEntry {
if (memory.lastReinforcedSessionID === sessionId) {
return memory;
}
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
return memory;
}
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
return memory;
}
return {
...memory,
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
retentionClock: now,
};
}
const RETENTION_CLOCK_BACKFILL_MIGRATION_ID = "2026-05-01-retention-clock-backfill";
export type MemoryConsolidationReason =
| "promoted"
@@ -170,6 +41,7 @@ export type LongTermLimitResult = {
dropped: MemoryConsolidationEvent[];
absorbed: MemoryConsolidationEvent[];
superseded: MemoryConsolidationEvent[];
evidence: EvidenceEventInput[];
};
export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
@@ -177,6 +49,16 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
events: MemoryConsolidationEvent[];
};
export type WorkspaceMemoryRenderAccounting = {
rendered: LongTermMemoryEntry[];
omitted: Array<{
memory: LongTermMemoryEntry;
reason: "superseded" | "type_cap" | "global_cap" | "char_budget" | "empty_render_budget";
}>;
evidence: EvidenceEventInput[];
prompt: string;
};
export type QualityCleanupMigrationLogEntry = {
migrationId: string;
timestamp: string;
@@ -192,6 +74,17 @@ export type QualityCleanupMigrationLogEntry = {
afterStatus: "superseded";
};
export type P0CleanupMigrationResult = {
store: WorkspaceMemoryStore;
events: EvidenceEventInput[];
};
export type QualityCleanupMigrationResult = {
store: WorkspaceMemoryStore;
events: QualityCleanupMigrationLogEntry[];
evidence: EvidenceEventInput[];
};
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
return {
@@ -233,6 +126,14 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
// writes for ordering/capacity/timestamp-only normalization.
if (hasSecurityOrMigrationChange(store, normalized.store)) {
await atomicWriteJSON(path, normalized.store);
// loadWorkspaceMemory has a narrow migration side effect: when a first-load
// migration actually supersedes entries, append evidence for those storage
// changes. Migration IDs keep this idempotent on repeated loads.
const migrationEvidence = normalized.evidence.filter(event => event.type === "memory_migration_superseded");
if (migrationEvidence.length > 0) {
await appendEvidenceEvents(root, migrationEvidence);
}
}
return normalized.store;
@@ -249,6 +150,7 @@ function hasSecurityOrMigrationChange(
if (beforeEntry.text !== afterEntry.text) return true;
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
if (beforeEntry.status !== afterEntry.status) return true;
if ((beforeEntry.retentionClock ?? null) !== (afterEntry.retentionClock ?? null)) return true;
}
const beforeMigrations = JSON.stringify(before.migrations ?? []);
@@ -277,8 +179,15 @@ export async function updateWorkspaceMemoryWithAccounting(
const fallback = await emptyWorkspaceMemory(root);
let finalResult: WorkspaceMemoryNormalizationResult | undefined;
const store = await updateJSON(path, () => fallback, async current => {
const normalized = await normalizeWorkspaceMemory(root, current);
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized));
const currentNormalization = await normalizeWorkspaceMemoryWithAccounting(root, current);
const currentMigrationEvidence = currentNormalization.evidence.filter(event => event.type === "memory_migration_superseded");
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(currentNormalization.store));
if (currentMigrationEvidence.length > 0) {
finalResult = {
...finalResult,
evidence: [...currentMigrationEvidence, ...finalResult.evidence],
};
}
return finalResult.store;
});
@@ -288,6 +197,7 @@ export async function updateWorkspaceMemoryWithAccounting(
dropped: [],
absorbed: [],
superseded: [],
evidence: [],
events: [],
};
}
@@ -303,7 +213,8 @@ export async function normalizeWorkspaceMemoryWithAccounting(
root: string,
store: WorkspaceMemoryStore,
): Promise<WorkspaceMemoryNormalizationResult> {
const nowIso = new Date().toISOString();
const nowMs = Date.now();
const nowIso = new Date(nowMs).toISOString();
let result: WorkspaceMemoryStore = {
...store,
@@ -340,10 +251,12 @@ export async function normalizeWorkspaceMemoryWithAccounting(
const beforeQualityCleanup = result;
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
result = qualityCleanup.store;
let migrationEvidence: EvidenceEventInput[] = [];
let skipRemainingMigrations = false;
if (qualityCleanup.events.length > 0) {
try {
await appendQualityCleanupMigrationLog(qualityCleanup.events);
migrationEvidence = [...migrationEvidence, ...qualityCleanup.evidence];
} catch (error) {
console.error("[memory] failed to write quality cleanup migration log:", error);
console.error("[memory] aborting migration to maintain audit trail integrity");
@@ -352,7 +265,18 @@ export async function normalizeWorkspaceMemoryWithAccounting(
}
}
if (!skipRemainingMigrations) {
result = runMigrationP0Cleanup(result, nowIso);
const p0Cleanup = runMigrationP0Cleanup(result, nowIso);
result = p0Cleanup.store;
migrationEvidence = [...migrationEvidence, ...p0Cleanup.events];
}
result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs));
if (!result.migrations.includes(RETENTION_CLOCK_BACKFILL_MIGRATION_ID)) {
result = {
...result,
migrations: [...result.migrations, RETENTION_CLOCK_BACKFILL_MIGRATION_ID],
updatedAt: nowIso,
};
}
// P0 accounting only considers active entries. Entries that were already
@@ -376,38 +300,64 @@ export async function normalizeWorkspaceMemoryWithAccounting(
dropped: accounting.dropped,
absorbed: accounting.absorbed,
superseded: accounting.superseded,
evidence: [...migrationEvidence, ...accounting.evidence],
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
};
}
function backfillRetentionClock(entry: LongTermMemoryEntry, nowMs: number): LongTermMemoryEntry {
if (Number.isFinite(entry.retentionClock)) {
return entry;
}
const createdAtMs = new Date(entry.createdAt).getTime();
if (Number.isFinite(createdAtMs)) {
return { ...entry, retentionClock: createdAtMs };
}
const updatedAtMs = new Date(entry.updatedAt).getTime();
if (Number.isFinite(updatedAtMs)) {
return { ...entry, retentionClock: updatedAtMs };
}
return { ...entry, retentionClock: nowMs };
}
export function runMigrationP0Cleanup(
store: WorkspaceMemoryStore,
nowIso: string,
): WorkspaceMemoryStore {
): P0CleanupMigrationResult {
if (store.migrations?.includes(MIGRATION_ID)) {
return store;
return { store, events: [] };
}
const events: EvidenceEventInput[] = [];
const entries = store.entries.map(entry => {
if (entry.source !== "compaction") return entry;
if (entry.type !== "project") return entry;
if (entry.status === "superseded") return entry;
if (isProgressSnapshotViolation(entry.text)) {
return {
const superseded = {
...entry,
status: "superseded" as const,
updatedAt: nowIso,
};
events.push(migrationSupersededEvidence(superseded, ["migration:p0_cleanup"], MIGRATION_ID));
return superseded;
}
return entry;
});
return {
...store,
entries,
migrations: [...(store.migrations || []), MIGRATION_ID],
updatedAt: nowIso,
store: {
...store,
entries,
migrations: [...(store.migrations || []), MIGRATION_ID],
updatedAt: nowIso,
},
events,
};
}
@@ -421,12 +371,13 @@ async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationL
export function runMigrationQualityCleanup(
store: WorkspaceMemoryStore,
nowIso: string,
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
): QualityCleanupMigrationResult {
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
return { store, events: [] };
return { store, events: [], evidence: [] };
}
const events: QualityCleanupMigrationLogEntry[] = [];
const evidence: EvidenceEventInput[] = [];
let changed = false;
const entries = store.entries.map(entry => {
if (entry.source !== "compaction") return entry;
@@ -460,12 +411,19 @@ export function runMigrationQualityCleanup(
...hardReasons.map(reason => `quality:${reason}`),
]);
return {
const superseded = {
...entry,
status: "superseded" as const,
updatedAt: nowIso,
tags: [...tags],
};
evidence.push(migrationSupersededEvidence(
superseded,
["migration:quality_cleanup", ...hardReasons.map(reason => `quality:${reason}`)],
QUALITY_CLEANUP_MIGRATION_ID,
{ hardReasons },
));
return superseded;
});
return {
@@ -476,6 +434,7 @@ export function runMigrationQualityCleanup(
updatedAt: changed ? nowIso : store.updatedAt,
},
events,
evidence,
};
}
@@ -588,6 +547,54 @@ function consolidationEvent(
};
}
function capacityRemovalEvidence(
memory: LongTermMemoryEntry,
reason: "type_cap" | "global_cap" | "capacity",
): EvidenceEventInput {
return {
type: "memory_removed_capacity",
phase: "storage",
outcome: "removed",
reasonCodes: [reason],
memory: memoryEvidenceRef(memory),
relations: [{
role: "removed",
memory: memoryEvidenceRef(memory),
}],
details: {
type: memory.type,
globalCap: LONG_TERM_LIMITS.maxEntries,
...(reason === "type_cap" ? { typeCap: RETENTION_TYPE_MAX[memory.type] } : {}),
...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}),
...(memory.createdAt ? { createdAt: memory.createdAt } : {}),
...(memory.source ? { source: memory.source } : {}),
},
};
}
function migrationSupersededEvidence(
memory: LongTermMemoryEntry,
reasonCodes: string[],
migrationId: string,
details: EvidenceEventInput["details"] = {},
): EvidenceEventInput {
return {
type: "memory_migration_superseded",
phase: "storage",
outcome: "superseded",
memory: memoryEvidenceRef(memory),
relations: [{ role: "superseded", memory: memoryEvidenceRef(memory) }],
reasonCodes,
details: {
migrationId,
type: memory.type,
source: memory.source,
...details,
},
textPreview: memory.text,
};
}
/** Choose better memory when identity/topic keys conflict */
function chooseBetterMemory(
a: LongTermMemoryEntry,
@@ -643,6 +650,13 @@ export function enforceLongTermLimitsWithAccounting(
const capped = applyTypeMaxCaps(sorted);
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
const keptIds = new Set(kept.map(entry => entry.id));
const cappedIds = new Set(capped.map(entry => entry.id));
const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id));
const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id));
const capacityEvidence: EvidenceEventInput[] = [
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap")),
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap")),
];
const capacityDropped = sorted
.filter(entry => !keptIds.has(entry.id))
.map(entry => consolidationEvent(entry, "rejected_capacity"));
@@ -652,36 +666,46 @@ export function enforceLongTermLimitsWithAccounting(
dropped: [...dedupeResult.dropped, ...capacityDropped],
absorbed: dedupeResult.absorbed,
superseded: dedupeResult.superseded,
evidence: [...dedupeResult.evidence, ...capacityEvidence],
};
}
function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
return applyTypeMaxCapsWithOmissions(entries).kept;
}
function applyTypeMaxCapsWithOmissions(entries: LongTermMemoryEntry[]): { kept: LongTermMemoryEntry[]; omitted: LongTermMemoryEntry[] } {
const capped: LongTermMemoryEntry[] = [];
const omitted: LongTermMemoryEntry[] = [];
const typeCounts: Partial<Record<LongTermMemoryEntry["type"], number>> = {};
for (const entry of entries) {
if (entry.safetyCritical) {
capped.push(entry);
const count = typeCounts[entry.type] ?? 0;
const max = RETENTION_TYPE_MAX[entry.type] ?? Infinity;
if (count >= max) {
omitted.push(entry);
continue;
}
const count = typeCounts[entry.type] ?? 0;
const max = TYPE_MAX[entry.type] ?? Infinity;
if (count >= max) continue;
capped.push(entry);
typeCounts[entry.type] = count + 1;
}
return capped;
return { kept: capped, omitted };
}
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
const now = Date.now();
const absorbed: MemoryConsolidationEvent[] = [];
const superseded: MemoryConsolidationEvent[] = [];
const evidence: EvidenceEventInput[] = [];
// For project/reference/feedback: dedupe by concrete identity or exact canonical text.
// Feedback is grouped with project/reference for entity dedupe, but
// workspaceMemoryIdentityKey() returns exact key for feedback (no concrete
// identity extraction). This means feedback absorbed_identity is currently
// impossible. When identity key extraction is extended to all types, feedback
// with matching concrete identifiers will correctly produce absorbed_identity.
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
// Build identity key dedup for project/reference/feedback.
@@ -703,6 +727,8 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
reinforcementSessionId(retained, dropped),
now,
);
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
absorbed.push(consolidationEvent(dropped, reason, reinforced));
entityDeduped.set(key, reinforced);
@@ -723,12 +749,14 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const dropped = retained === entry ? existing : entry;
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "superseded_existing" as const;
: "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs
const reinforced = reinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
if (reason === "superseded_existing") {
superseded.push(consolidationEvent(dropped, reason, reinforced));
@@ -751,6 +779,40 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
dropped: [],
absorbed,
superseded,
evidence,
};
}
function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
return {
memoryId: memory.id,
memoryKeyHash: workspaceMemoryExactKey(memory),
identityKeyHash: workspaceMemoryIdentityKey(memory),
type: memory.type,
source: memory.source,
status: memory.status,
};
}
function reinforcementEvidence(
retained: LongTermMemoryEntry,
dropped: LongTermMemoryEntry,
reinforced: LongTermMemoryEntry,
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
): EvidenceEventInput | undefined {
if ((reinforced.reinforcementCount ?? 0) <= (retained.reinforcementCount ?? 0)) return undefined;
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
return {
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: memoryEvidenceRef(reinforced),
relations: [
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
textPreview: reinforced.text,
};
}
@@ -785,20 +847,49 @@ function wouldFit(
}
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
const active = enforceLongTermLimitsWithAccounting(store.entries, store).kept;
if (active.length === 0) return "";
return accountWorkspaceMemoryRender(store).prompt;
}
export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting {
const now = Date.now();
const maxChars = Math.min(
store.limits.maxRenderedChars,
LONG_TERM_LIMITS.maxRenderedChars
);
const omitted: WorkspaceMemoryRenderAccounting["omitted"] = [];
const evidence: EvidenceEventInput[] = [];
for (const entry of store.entries) {
if (entry.status === "superseded") {
omitted.push({ memory: entry, reason: "superseded" });
}
}
const activeEntries = store.entries.filter(entry => entry.status !== "superseded");
const phase1 = activeEntries.map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) }));
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, store.lastActivityAt));
const typeCapResult = applyTypeMaxCapsWithOmissions(sorted);
for (const memory of typeCapResult.omitted) omitted.push({ memory, reason: "type_cap" });
const active = typeCapResult.kept.slice(0, LONG_TERM_LIMITS.maxEntries);
for (const memory of typeCapResult.kept.slice(LONG_TERM_LIMITS.maxEntries)) omitted.push({ memory, reason: "global_cap" });
if (active.length === 0) {
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
return { rendered: [], omitted, evidence, prompt: "" };
}
// If maxChars smaller than minimum envelope, return empty string
if (maxChars < MIN_ENVELOPE_LENGTH) return "";
if (maxChars < MIN_ENVELOPE_LENGTH) {
for (const memory of active) omitted.push({ memory, reason: "empty_render_budget" });
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
return { rendered: [], omitted, evidence, prompt: "" };
}
const lines: string[] = [
"Workspace memory (cross-session, verify if stale):",
];
const rendered: LongTermMemoryEntry[] = [];
for (const type of ["feedback", "project", "decision", "reference"] as const) {
const items = active.filter(entry => entry.type === type);
@@ -810,6 +901,9 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
const line = `- ${renderEntry(item)}`;
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
sectionLines.push(line);
rendered.push(item);
} else {
omitted.push({ memory: item, reason: "char_budget" });
}
}
@@ -818,7 +912,26 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
}
}
return lines.join("\n");
for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered"));
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
return { rendered, omitted, evidence, prompt: lines.join("\n") };
}
function renderEvidence(
memory: LongTermMemoryEntry,
outcome: "rendered" | "omitted",
reason?: WorkspaceMemoryRenderAccounting["omitted"][number]["reason"],
): EvidenceEventInput {
return {
type: outcome === "rendered" ? "render_selected" : "render_omitted",
phase: "render",
outcome,
memory: memoryEvidenceRef(memory),
relations: [{ role: outcome === "rendered" ? "rendered" : "omitted", memory: memoryEvidenceRef(memory) }],
reasonCodes: outcome === "rendered" ? ["within_caps", "within_char_budget"] : [reason ?? "char_budget"],
textPreview: memory.text,
};
}
function renderEntry(entry: LongTermMemoryEntry): string {
+323
View File
@@ -0,0 +1,323 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import {
EVIDENCE_LOG_LIMITS,
appendEvidenceEvent,
appendEvidenceEvents,
queryEvidenceEvents,
summarizeMemoryEvidence,
traceMemoryLifecycle,
type EvidenceEventInput,
type EvidenceEventV1,
} from "../src/evidence-log.ts";
import { workspaceEvidenceLogPath, workspaceKey } from "../src/paths.ts";
async function tempRoot(): Promise<string> {
return mkdtemp(join(tmpdir(), "opencode-evidence-log-"));
}
function eventInput(overrides: Partial<EvidenceEventInput> = {}): EvidenceEventInput {
return {
type: "promotion_promoted",
phase: "promotion",
outcome: "promoted",
reasonCodes: ["new_workspace_entry"],
memory: { memoryId: "mem-a", type: "decision", source: "explicit", status: "active" },
textPreview: "Use npm test before release",
...overrides,
};
}
async function readLog(root: string): Promise<string> {
return readFile(await workspaceEvidenceLogPath(root), "utf8");
}
function privacyHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
async function workspaceRootHash(root: string): Promise<string> {
const resolved = await realpath(root).catch(() => root);
return privacyHash(resolved);
}
function manualEvent(rootKey: string, rootHash: string, id: string, createdAt: string): EvidenceEventV1 {
return {
version: 1,
eventId: id,
createdAt,
workspaceKey: rootKey,
workspaceRootHash: rootHash,
type: "render_selected",
phase: "render",
outcome: "rendered",
memory: { memoryId: id, type: "decision" },
reasonCodes: ["within_caps", "within_char_budget"],
};
}
test("appendEvidenceEvent writes versioned JSONL with workspace hashes", async () => {
const root = await tempRoot();
try {
const event = await appendEvidenceEvent(root, eventInput({ sessionHash: "session-1", messageHash: "message-1" }));
const raw = await readLog(root);
const stored = JSON.parse(raw.trim()) as EvidenceEventV1;
assert.equal(stored.version, 1);
assert.match(stored.eventId, /^evt_\d+_[a-z0-9]{8}$/);
assert.equal(stored.eventId, event.eventId);
assert.equal(stored.workspaceKey, await workspaceKey(root));
assert.equal(stored.workspaceRootHash, await workspaceRootHash(root));
assert.equal(stored.sessionHash, privacyHash("session-1"));
assert.equal(stored.messageHash, privacyHash("message-1"));
assert.equal(stored.type, "promotion_promoted");
assert.equal(stored.outcome, "promoted");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("appendEvidenceEvent redacts text previews before writing", async () => {
const root = await tempRoot();
try {
await appendEvidenceEvent(root, eventInput({
type: "extraction_candidate_rejected",
phase: "extraction",
outcome: "rejected",
reasonCodes: ["raw_error"],
textPreview: "password: sushi\nAdmin PIN 是 456123\nBearer abc.def.ghi\nThis candidate is rejected and should be short",
details: {
note: "password: sushi Admin PIN 是 456123 Bearer abc.def.ghi",
},
}));
const raw = await readLog(root);
const stored = JSON.parse(raw.trim()) as EvidenceEventV1;
assert.ok(!raw.includes("sushi"));
assert.ok(!raw.includes("456123"));
assert.ok(!raw.includes("abc.def.ghi"));
assert.ok(stored.textPreview?.includes("[REDACTED]"));
assert.ok((stored.textPreview?.length ?? 0) <= 80);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("queryEvidenceEvents filters by type outcome and memory id", async () => {
const root = await tempRoot();
try {
await appendEvidenceEvents(root, [
eventInput({ type: "promotion_promoted", outcome: "promoted", memory: { memoryId: "mem-a" } }),
eventInput({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["type_cap"], memory: { memoryId: "mem-a" } }),
eventInput({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["global_cap"], memory: { memoryId: "mem-b" } }),
]);
const result = await queryEvidenceEvents(root, {
types: ["render_omitted"],
outcomes: ["omitted"],
memoryId: "mem-a",
});
assert.equal(result.length, 1);
assert.equal(result[0].type, "render_omitted");
assert.equal(result[0].memory?.memoryId, "mem-a");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory_removed_capacity event round-trips through append and query", async () => {
const root = await tempRoot();
try {
await appendEvidenceEvent(root, eventInput({
type: "memory_removed_capacity",
phase: "storage",
outcome: "removed",
reasonCodes: ["global_cap"],
memory: { memoryId: "removed-memory", type: "reference", source: "compaction", status: "active" },
relations: [{ role: "removed", memory: { memoryId: "removed-memory", type: "reference", source: "compaction", status: "active" } }],
details: {
type: "reference",
globalCap: 28,
retentionClock: Date.UTC(2026, 4, 1),
createdAt: "2026-05-01T00:00:00.000Z",
source: "compaction",
},
}));
const result = await queryEvidenceEvents(root, {
types: ["memory_removed_capacity"],
phases: ["storage"],
outcomes: ["removed"],
memoryId: "removed-memory",
});
assert.equal(result.length, 1);
assert.equal(result[0].type, "memory_removed_capacity");
assert.equal(result[0].phase, "storage");
assert.equal(result[0].outcome, "removed");
assert.deepEqual(result[0].details, {
type: "reference",
globalCap: 28,
retentionClock: Date.UTC(2026, 4, 1),
createdAt: "2026-05-01T00:00:00.000Z",
source: "compaction",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory_migration_superseded event round-trips through append and query", async () => {
const root = await tempRoot();
try {
await appendEvidenceEvent(root, eventInput({
type: "memory_migration_superseded",
phase: "storage",
outcome: "superseded",
reasonCodes: ["migration:quality_cleanup", "quality:progress_snapshot"],
memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" },
relations: [{ role: "superseded", memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" } }],
details: {
migrationId: "2026-04-28-quality-cleanup",
type: "project",
source: "compaction",
},
}));
const result = await queryEvidenceEvents(root, {
types: ["memory_migration_superseded"],
phases: ["storage"],
outcomes: ["superseded"],
memoryId: "migrated-memory",
});
assert.equal(result.length, 1);
assert.equal(result[0].type, "memory_migration_superseded");
assert.equal(result[0].phase, "storage");
assert.equal(result[0].outcome, "superseded");
assert.ok(result[0].reasonCodes.includes("migration:quality_cleanup"));
assert.equal(result[0].memory?.status, "superseded");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("queryEvidenceEvents supports newestFirst and limit", async () => {
const root = await tempRoot();
try {
const events = await appendEvidenceEvents(root, [
eventInput({ memory: { memoryId: "oldest" } }),
eventInput({ memory: { memoryId: "middle" } }),
eventInput({ memory: { memoryId: "newest" } }),
]);
const result = await queryEvidenceEvents(root, { newestFirst: true, limit: 2 });
assert.deepEqual(result.map(event => event.eventId), [events[2].eventId, events[1].eventId]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("appendEvidenceEvent returns a record when appendFile fails", async () => {
const root = await tempRoot();
try {
const path = await workspaceEvidenceLogPath(root);
await mkdir(path, { recursive: true });
const event = await appendEvidenceEvent(root, eventInput());
assert.equal(event.version, 1);
assert.match(event.eventId, /^evt_\d+_[a-z0-9]{8}$/);
assert.equal(event.workspaceKey, await workspaceKey(root));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("evidence log pruning drops old events, caps count, and quarantines invalid lines", async () => {
const root = await tempRoot();
try {
const path = await workspaceEvidenceLogPath(root);
await mkdir(dirname(path), { recursive: true });
const rootKey = await workspaceKey(root);
const rootHash = await workspaceRootHash(root);
const old = manualEvent(rootKey, rootHash, "old-event", new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString());
const recentEvents = Array.from({ length: EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace + 1 }, (_, i) =>
manualEvent(rootKey, rootHash, `recent-${i}`, new Date(Date.now() - (EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace - i) * 1000).toISOString())
);
await writeFile(path, [
JSON.stringify(old),
"{not valid json",
...recentEvents.map(event => JSON.stringify(event)),
].join("\n") + "\n", "utf8");
const appended = await appendEvidenceEvents(root, Array.from({ length: EVIDENCE_LOG_LIMITS.pruneEveryAppendCount }, (_, i) =>
eventInput({ memory: { memoryId: `appended-${i}` }, reasonCodes: ["new_workspace_entry"] })
));
const events = await queryEvidenceEvents(root);
const memoryIds = new Set(events.map(event => event.memory?.memoryId));
const files = await readdir(dirname(path));
assert.ok(events.length <= EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace);
assert.equal(memoryIds.has("old-event"), false);
assert.equal(memoryIds.has("recent-0"), false, "oldest events over the count cap should be pruned");
assert.equal(memoryIds.has(appended.at(-1)?.memory?.memoryId), true);
assert.ok(files.some(file => file.startsWith("events.jsonl.corrupt-lines-")));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory evidence summary and lifecycle trace derive latest status", async () => {
const root = await tempRoot();
try {
await appendEvidenceEvents(root, [
eventInput({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", reasonCodes: ["quality_gate_passed"], memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }),
eventInput({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", reasonCodes: ["new_workspace_entry"], memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }),
eventInput({ type: "memory_reinforced", phase: "reinforcement", outcome: "reinforced", reasonCodes: ["duplicate_exact"], relations: [{ role: "reinforced", memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }] }),
eventInput({ type: "render_selected", phase: "render", outcome: "rendered", reasonCodes: ["within_caps", "within_char_budget"], memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }),
]);
const summary = await summarizeMemoryEvidence(root, { memoryId: "mem-life" });
const trace = await traceMemoryLifecycle(root, { memoryId: "mem-life" });
assert.equal(summary.latestOutcome, "rendered");
assert.equal(summary.latestRenderStatus, "rendered");
assert.ok(summary.reasonCodes.includes("duplicate_exact"));
assert.equal(trace.currentStatus, "rendered");
assert.ok(trace.acceptedBy);
assert.ok(trace.promotedBy);
assert.equal(trace.reinforcedBy.length, 1);
assert.equal(trace.latestRender?.type, "render_selected");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("evidence relation roles reject sublimation placeholders at compile-time surface", () => {
const allowedRoles: Array<NonNullable<EvidenceEventInput["relations"]>[number]["role"]> = [
"candidate",
"pending",
"promoted",
"retained",
"absorbed",
"superseded",
"superseded_by",
"reinforced",
"reinforced_by",
"rendered",
"omitted",
"removed",
];
assert.equal(allowedRoles.includes("candidate"), true);
});
+86 -1
View File
@@ -3,7 +3,13 @@ import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extractErrorsFromBash, extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import {
extractErrorsFromBash,
extractExplicitMemories,
extractExplicitMemoriesWithEvidence,
parseWorkspaceMemoryCandidates,
parseWorkspaceMemoryCandidatesWithEvidence,
} from "../src/extractors.ts";
async function waitForFile(path: string, attempts = 20): Promise<string> {
let lastError: unknown;
@@ -145,6 +151,20 @@ test("extractExplicitMemories captures multiple memories in same message", () =>
assert.equal(items.length, 2);
});
test("explicit memory extraction returns detected and ignored evidence", () => {
const result = extractExplicitMemoriesWithEvidence([
"remember this: Prefer deterministic tests.",
"don't remember this: temporary password: sushi",
"remember this: later",
].join("\n"));
assert.equal(result.entries.length, 1);
assert.ok(result.evidence.some(event => event.type === "explicit_memory_detected"));
assert.ok(result.evidence.some(event => event.type === "explicit_memory_ignored" && event.reasonCodes.includes("negated_request")));
assert.ok(result.evidence.some(event => event.type === "explicit_memory_ignored" && event.reasonCodes.includes("deferral")));
assert.equal(JSON.stringify(result.evidence).includes("sushi"), false);
});
// ============================================
// Task 7: Compaction quality gate tests
// ============================================
@@ -176,6 +196,41 @@ test("parseWorkspaceMemoryCandidates rejects raw error", () => {
assert.equal(items.length, 0);
});
test("compaction accepted candidate returns privacy-safe extraction evidence", () => {
const summary = `
Memory candidates:
- [decision] Use accounting evidence events to explain promoted memories in diagnostics.
`;
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
assert.equal(result.entries.length, 1);
assert.equal(result.evidence.length, 1);
assert.equal(result.evidence[0].type, "extraction_candidate_accepted");
assert.ok(result.evidence[0].reasonCodes.includes("quality_gate_passed"));
assert.ok(result.evidence[0].reasonCodes.includes("valid_candidate_format"));
assert.match(result.evidence[0].textPreview ?? "", /accounting evidence events/);
});
test("compaction rejected candidate returns rejection evidence without secrets", () => {
const summary = `
Memory candidates:
- [feedback] password: sushi Admin PIN 是 456123 Bearer abc.def.ghi TypeError: Cannot read property x
`;
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
const raw = JSON.stringify(result.evidence);
assert.equal(result.entries.length, 0);
assert.equal(result.evidence.length, 1);
assert.equal(result.evidence[0].type, "extraction_candidate_rejected");
assert.ok(result.evidence[0].reasonCodes.length > 0);
assert.equal(raw.includes("sushi"), false);
assert.equal(raw.includes("456123"), false);
assert.equal(raw.includes("abc.def.ghi"), false);
assert.ok((result.evidence[0].textPreview?.length ?? 0) <= 80);
});
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
const summary = `
## Memory Candidates
@@ -332,6 +387,36 @@ Memory candidates:
}
});
test("new rejection records include workspaceKey and workspaceRootHash when provided", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-scope-data-"));
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
const summary = `
Memory candidates:
- feedback Wave 1 completed successfully and all tests passed
`;
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary, {
workspaceKey: "testkey1234567",
workspaceRootHash: "abcdef123456",
});
assert.equal(result.entries.length, 0);
const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl");
const lines = (await waitForFile(logPath)).trim().split("\n");
assert.equal(lines.length, 1);
const event = JSON.parse(lines[0]);
assert.equal(event.workspaceKey, "testkey1234567");
assert.equal(event.workspaceRootHash, "abcdef123456");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(dataHome, { recursive: true, force: true });
}
});
test("parseWorkspaceMemoryCandidates redacts secrets in extraction rejection log", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-"));
const previousXdgDataHome = process.env.XDG_DATA_HOME;
+135
View File
@@ -0,0 +1,135 @@
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
@@ -0,0 +1,105 @@
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
@@ -0,0 +1,110 @@
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
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,224 @@
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 });
}
});
+662 -26
View File
@@ -1,14 +1,16 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtempSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } from "../src/types.ts";
import { extractionRejectionLogPath, workspaceEvidenceLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
const execFileAsync = promisify(execFile);
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -45,18 +47,163 @@ async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[],
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
async function runMemoryDiagHealth(root: string): Promise<string> {
const { stdout } = await execFileAsync(process.execPath, [
"--experimental-strip-types",
"scripts/memory-diag.ts",
"health",
"--workspace",
root,
], { cwd: repoRoot });
async function runMemoryDiagStatusVerbose(root: string): Promise<string> {
return runMemoryDiag(["status", "--workspace", root, "--verbose"]);
}
async function runMemoryDiag(args: string[]): Promise<string> {
const { stdout } = await runMemoryDiagResult(args);
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",
"scripts/memory-diag.ts",
...args,
], { cwd: options.cwd ?? repoRoot });
return { stdout: stdout.trim(), stderr: stderr.trim() };
}
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().toISOString(),
};
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
async function writeRejectionRecords(records: unknown[]): Promise<void> {
const path = extractionRejectionLogPath();
await mkdir(dirname(path), { recursive: true });
await writeFile(path, records.map(record => JSON.stringify(record)).join("\n") + "\n", "utf8");
}
function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
return {
type: "promotion_promoted",
phase: "promotion",
outcome: "promoted",
memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" },
reasonCodes: ["new_workspace_entry"],
...overrides,
};
}
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 () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
@@ -66,18 +213,19 @@ test("memory health reports stored vs rendered retention counts", async () => {
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
const stdout = await runMemoryDiagStatusVerbose(root);
assert.match(stdout, /Stored active memories:/);
assert.match(stdout, /Rendered candidates:/);
assert.match(stdout, /feedback\s+stored=17\s+rendered=10/);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /active: 28 \/ 28/);
assert.match(stdout, /rendered: 20/);
assert.match(stdout, /feedback: 17 \/ 10 FULL/);
assert.match(stdout, /Top rendered candidates:\n\s+- strength=/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports dormancy and retention monitoring alerts", async () => {
test("memory health reports dormancy and retention monitoring deprecations", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const lastActivityAt = new Date(Date.now() - 19 * 24 * 60 * 60 * 1000).toISOString();
@@ -89,15 +237,14 @@ test("memory health reports dormancy and retention monitoring alerts", async ()
}));
await writeWorkspaceStore(root, entries, { lastActivityAt });
const stdout = await runMemoryDiagHealth(root);
const stdout = await runMemoryDiagStatusVerbose(root);
assert.match(stdout, /Dormancy:/);
assert.match(stdout, /wall days since activity: 19\.0/);
assert.match(stdout, /dormant discount active: yes/);
assert.match(stdout, /dormant days past grace: 5\.0/);
assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
assert.match(stdout, /safety_critical_count: 6 .* ALERT/);
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
assert.match(stdout, /Top rendered candidates:/);
assert.match(stdout, /Weakest active memories:/);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -114,9 +261,9 @@ test("memory health reports global cap overflow separately from type caps", asyn
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
const stdout = await runMemoryDiagStatusVerbose(root);
assert.match(stdout, /Rendered candidates: 28/);
assert.match(stdout, /rendered: 28/);
assert.match(stdout, /type-capped entries: 0/);
assert.match(stdout, /global-cap overflow: 6/);
} finally {
@@ -129,14 +276,503 @@ test("memory health reports missing dormancy and non-alert monitoring defaults",
try {
await writeWorkspaceStore(root, [], { omitLastActivityAt: true });
const stdout = await runMemoryDiagHealth(root);
const stdout = await runMemoryDiagStatusVerbose(root);
assert.match(stdout, /lastActivityAt: \(missing\)/);
assert.match(stdout, /wall days since activity: unknown/);
assert.match(stdout, /dormant discount active: no/);
assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
assert.match(stdout, /safety_critical_count: 0 \(alert > 5\)\n/);
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
assert.match(stdout, /Top rendered candidates:\n\s+\(none\)/);
assert.match(stdout, /Weakest active memories:\n\s+\(none\)/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health --json prints parseable privacy-safe diagnostics matching human counts", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-json-"));
try {
const rendered = { ...entry("mem-rendered", "Prefer small focused changes", "feedback"), source: "explicit" as const };
const secret = { ...entry("mem-secret", "Use password: sushi only in test fixtures", "decision"), source: "manual" as const };
const superseded = { ...entry("mem-old", "Old decision that was superseded", "decision"), status: "superseded" as const };
const pending = { ...entry("mem-pending", "Retry this pending memory later", "project"), promotionAttempts: 1 };
await writeWorkspaceStore(root, [rendered, secret, superseded]);
await writePendingJournal(root, [pending]);
await appendEvidenceEvents(root, [
evidence({ type: "extraction_candidate_rejected", phase: "extraction", outcome: "rejected", memory: { memoryId: "mem-rejected", type: "feedback", source: "explicit" }, reasonCodes: ["raw_secret"], textPreview: "password: sushi should not leak" }),
evidence({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined", memory: undefined, reasonCodes: ["invalid_json"] }),
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["new_workspace_entry"] }),
evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["within_caps", "within_char_budget"] }),
]);
const human = await runMemoryDiagStatusVerbose(root);
const jsonText = await runMemoryDiag(["status", "--workspace", root, "--json"]);
const parsed = JSON.parse(jsonText) as {
version: 1;
summary: { storedActive: number; rendered: number; pending: number; rejectedLast7Days: number; corruptStoresQuarantinedLast30Days: number };
memories: Array<{ id: string; status: string; reasonCodes: string[]; evidenceEventIds: string[]; textPreview?: string }>;
recentEvents: Array<{ eventId: string; type: string; outcome: string; createdAt: string; reasonCodes: string[] }>;
};
assert.equal(parsed.version, 1);
assert.equal(parsed.summary.storedActive, Number(human.match(/active: (\d+) \/ 28/)?.[1]));
assert.equal(parsed.summary.rendered, Number(human.match(/rendered: (\d+)/)?.[1]));
assert.equal(parsed.summary.pending, 1);
assert.equal(parsed.summary.rejectedLast7Days, 1);
assert.equal(parsed.summary.corruptStoresQuarantinedLast30Days, 1);
assert.ok(parsed.recentEvents.some(event => event.eventId && event.type === "render_selected" && event.outcome === "rendered" && event.createdAt && event.reasonCodes.includes("within_caps")));
assert.ok(parsed.memories.find(memory => memory.id === "mem-rendered")?.evidenceEventIds.length);
assert.ok(!jsonText.includes("sushi"));
assert.ok(jsonText.trim().startsWith("{"));
assert.ok(jsonText.trim().endsWith("}"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag explain shows rendered, omitted, pending, and evidence reason status", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-explain-"));
try {
const rendered = { ...entry("mem-rendered", "Rendered feedback wins the render set", "feedback"), source: "explicit" as const };
const superseded = { ...entry("mem-superseded", "Superseded memory is not rendered", "decision"), status: "superseded" as const };
const typeCapped = Array.from({ length: 11 }, (_, i) => entry(`mem-type-${i}`, `Type cap feedback memory ${i}`, "feedback"));
const globalCapped = [
...Array.from({ length: 10 }, (_, i) => entry(`mem-g-feedback-${i}`, `Global cap feedback memory ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`mem-g-decision-${i}`, `Global cap decision memory ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`mem-g-project-${i}`, `Global cap project memory ${i}`, "project")),
...Array.from({ length: 7 }, (_, i) => entry(`mem-g-reference-${i}`, `Global cap reference memory ${i}`, "reference")),
];
const charBudget = { ...entry("mem-char-budget", "This active memory cannot fit the tiny character budget", "project") };
await writeWorkspaceStore(root, [rendered, superseded, ...typeCapped, ...globalCapped, charBudget]);
const key = await workspaceKey(root);
const path = await workspaceMemoryPath(root);
const raw = JSON.parse(await readFile(path, "utf8")) as WorkspaceMemoryStore;
raw.limits.maxRenderedChars = 100;
raw.workspace = { root, key };
await writeFile(path, JSON.stringify(raw, null, 2), "utf8");
const retry = { ...entry("mem-pending-retry", "Pending retry memory", "project"), promotionAttempts: 1, lastPromotionFailureReason: "capacity_rejected" };
const exhausted = { ...entry("mem-pending-capacity", "Pending capacity rejected memory", "project"), promotionAttempts: PROMOTION_RETRY_LIMITS.maxExplicitAttempts, lastPromotionFailureReason: "capacity_rejected" };
await writePendingJournal(root, [retry, exhausted]);
await appendEvidenceEvents(root, [
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "explicit" }, reasonCodes: ["same_exact_key"] }),
evidence({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined", memory: undefined, reasonCodes: ["invalid_json"] }),
]);
const stdout = await runMemoryDiag(["explain", "--workspace", root]);
assert.match(stdout, /Memory mem-rendered: rendered/);
assert.match(stdout, /Memory mem-superseded: omitted_superseded/);
assert.match(stdout, /omitted_type_cap/);
assert.match(stdout, /omitted_global_cap/);
assert.match(stdout, /omitted_char_budget/);
assert.match(stdout, /Memory mem-pending-retry: pending_retry/);
assert.match(stdout, /Memory mem-pending-capacity: pending_rejected_capacity/);
assert.match(stdout, /Memory mem-absorbed: omitted_absorbed_duplicate/);
assert.match(stdout, /quarantined_corrupt_store/);
assert.match(stdout, /- strength=\d+\.\d{3}, type=feedback, source=explicit/);
assert.match(stdout, /- evidence: .*promotion_absorbed_exact/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag explain --memory prints lifecycle relations and redacts secrets", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-"));
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: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "pending", memory: { memoryId: "mem-life" } }], reasonCodes: ["pending_journal_append"] }),
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
evidence({ type: "memory_reinforced", phase: "reinforcement", outcome: "reinforced", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "reinforced_by", memory: { memoryId: "mem-duplicate" } }], reasonCodes: ["duplicate_exact"] }),
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"] }),
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"]);
assert.match(stdout, /Memory mem-life: omitted_superseded/);
assert.match(stdout, /Lifecycle:/);
assert.match(stdout, /extraction_candidate_accepted: accepted; reasons=quality_gate_passed/);
assert.match(stdout, /pending_memory_appended: accepted; reasons=pending_journal_append/);
assert.match(stdout, /promotion_superseded: superseded; reasons=superseded_existing; .*superseded_by=mem-new/);
assert.match(stdout, /memory_reinforced: reinforced; reasons=duplicate_exact; .*reinforced_by=mem-duplicate/);
assert.match(stdout, /Superseded by:\n- mem-new/);
assert.match(stdout, /Reinforced by:\n- mem-duplicate/);
assert.ok(!stdout.includes("sushi"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory-diag explain positional memory id prints lifecycle", 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-"));
try {
const stdout = await runMemoryDiag(["explain", "--workspace", root, "--memory", "missing-memory"]);
assert.match(stdout, /Memory missing-memory: unknown/);
assert.match(stdout, /Lifecycle:\n\(none\)/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
async function setupQualityFixture(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-quality-"));
const key = await workspaceKey(root);
const now = new Date().toISOString();
const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries }, (_, i) => ({
...entry(`quality-${i}`, `Quality fixture memory ${i}`, i % 2 === 0 ? "feedback" as const : "decision" as const),
retentionClock: i === 0 ? -1 : Date.now(),
}));
await writeWorkspaceStore(root, entries);
await appendEvidenceEvents(root, [
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "quality-1", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "quality-historical", type: "reference", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
]);
await writeRejectionRecords([
{ timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] },
{ timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] },
]);
return root;
}
test("status verbose output includes summary and aggregate inspection counts", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]);
assert.match(stdout, /Memory status inspection/);
assert.match(stdout, /Summary: Workspace memory quality is degraded:/);
assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/);
assert.match(stdout, /decision: 14 \/ 10 FULL/);
assert.match(stdout, /feedback: 14 \/ 10 FULL/);
assert.match(stdout, /Retention clocks:\n\s+present: 27\n\s+missing: 0\n\s+invalid: 1/);
assert.match(stdout, /Evidence:\n\s+current with evidence: 1\n\s+current without evidence: 27/);
assert.match(stdout, /Rejection scoping:\n\s+total records: 2\n\s+workspace scoped: 1\n\s+legacy unscoped: 1/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("status --json includes summary status fields", async () => {
const root = await setupQualityFixture();
try {
const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as {
summary: { storedActive: number; status: string; evidenceCoveragePercent: number; needsAttention: string[] };
};
assert.equal(parsed.summary.storedActive, LONG_TERM_LIMITS.maxEntries);
assert.equal(parsed.summary.status, "degraded");
assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number");
assert.ok(parsed.summary.needsAttention.length > 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
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 {
await rm(root, { recursive: true, force: true });
}
});
test("coverage human output includes class counts and per-memory rows", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-coverage-"));
try {
await writeWorkspaceStore(root, [
entry("mem-full", "Full lifecycle memory", "feedback"),
entry("mem-render-only", "Render only memory", "decision"),
entry("mem-no-evidence", "No evidence memory", "project"),
]);
await appendEvidenceEvents(root, [
evidence({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" }, reasonCodes: ["quality_gate_passed"] }),
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-render-only", type: "decision", source: "compaction" }, reasonCodes: ["within_caps"] }),
]);
const stdout = await runMemoryDiag(["coverage", "--workspace", root]);
assert.match(stdout, /Class counts:/);
assert.match(stdout, /full_lifecycle: 1/);
assert.match(stdout, /render_only: 1/);
assert.match(stdout, /no_evidence: 1/);
assert.match(stdout, /Per-memory rows:/);
assert.match(stdout, /mem-full full_lifecycle .*extraction=1 promotion=1/);
assert.match(stdout, /mem-render-only render_only .*render=1/);
assert.match(stdout, /mem-no-evidence no_evidence .*total=0/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("coverage --json includes event counts", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-coverage-json-"));
try {
await writeWorkspaceStore(root, [entry("mem-full", "Full lifecycle memory", "feedback")]);
await appendEvidenceEvents(root, [
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
]);
const stdout = await runMemoryDiag(["coverage", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as { memories: Array<{ id: string; eventCounts: { total: number; byType: Record<string, number> } }> };
const row = parsed.memories.find(memory => memory.id === "mem-full");
assert.equal(row?.eventCounts.total, 1);
assert.equal(row?.eventCounts.byType.promotion_promoted, 1);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("missing default output summarizes unknown disappearances", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-"));
try {
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
await appendEvidenceEvents(root, [
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
]);
const stdout = await runMemoryDiag(["missing", "--workspace", root]);
assert.match(stdout, /Missing memory summary/);
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 {
await rm(root, { recursive: true, force: true });
}
});
test("missing --verbose shows capacity details and render omitted type-cap observations", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-explain-"));
try {
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
await appendEvidenceEvents(root, [
evidence({
type: "memory_removed_capacity",
phase: "storage",
outcome: "removed",
memory: { memoryId: "capacity-loser", type: "decision", source: "compaction", status: "active" },
relations: [{ role: "removed", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction", status: "active" } }],
reasonCodes: ["type_cap"],
details: { type: "decision", globalCap: 28, typeCap: 10, source: "compaction" },
}),
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"]);
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, /events:/);
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_omitted type-cap observation: reasons=type_cap/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("missing --json includes disappearances and additive summary", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-missing-json-"));
try {
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
await appendEvidenceEvents(root, [
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
]);
const stdout = await runMemoryDiag(["missing", "--workspace", root, "--json"]);
const parsed = JSON.parse(stdout) as { disappearances: Array<{ id: string }>; summary: { total: number; explained: number; needsReview: number } };
assert.equal(parsed.summary.total, 1);
assert.equal(parsed.summary.explained, 0);
assert.equal(parsed.summary.needsReview, 1);
assert.equal(parsed.disappearances[0]?.id, "historical-unknown");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("rejected default output is concise with top reasons and samples", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-"));
try {
const key = await workspaceKey(root);
const now = new Date().toISOString();
await writeRejectionRecords([
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Keep memory system boundary 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: "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]);
assert.match(stdout, /Rejected memory summary/);
assert.match(stdout, /Total rejected: 3/);
assert.match(stdout, /Unique texts: 3/);
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);
} finally {
await rm(root, { recursive: true, force: true });
}
+27
View File
@@ -136,6 +136,33 @@ test("decision must be future-facing rule, not completed implementation note", (
assert.equal(assessMemoryQuality({ type: "decision", text: "Added semantic merge tests in the previous wave", source: "compaction" }).accepted, false);
});
test("bad_decision 3-tier gate: architecture-like decisions accepted without future-rule imperative", () => {
const architectureLikeCases = [
{ text: "Rule 不在記憶系統 schema 內,歸用戶(agent.md / claude.md),系統最多到 Preference + Suggestion", type: "decision" as const },
{ text: "Ghost memory root cause: normalization 把 capacity losers 從 store 移除時沒有 emit terminal evidence", type: "decision" as const },
{ text: "BASE_HALF_LIFE_DAYS 應從 60 降低到 45", type: "decision" as const },
{ text: "採用 decay-rate 模型取代 priority+penalty 模型", type: "decision" as const },
{ text: "從 scoring 移除 confidence,目前是固定值無意義", type: "decision" as const },
];
const stillRejectedCases = [
{ text: "Implemented phase 2 and updated tests", type: "decision" as const },
{ text: "Implemented CI_SCHEMA_UPDATE for compatibility run 42", type: "decision" as const },
{ text: "Session reviewed the architecture model changes", type: "decision" as const },
{ text: "Some random text with no architecture keywords or future rules", type: "decision" as const },
];
for (const entry of architectureLikeCases) {
const result = assessMemoryQuality({ ...entry, source: "compaction" });
assert.equal(result.reasons.includes("bad_decision"), false, `${entry.text} -> ${result.reasons.join(",")}`);
}
for (const entry of stillRejectedCases) {
const result = assessMemoryQuality({ ...entry, source: "compaction" });
assert.equal(result.reasons.includes("bad_decision"), true, `${entry.text} -> ${result.reasons.join(",")}`);
}
});
test("shared quality gate owns extractor low-quality syntax rejections", () => {
const rejected = [
{ type: "project" as const, text: "fix: add new feature" },
+201 -2
View File
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { MemoryV2Plugin } from "../src/plugin.ts";
@@ -8,9 +8,10 @@ import { loadSessionState, saveSessionState } from "../src/session-state.ts";
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import type { OpenError } from "../src/types.ts";
import { PROMOTION_RETRY_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "../src/types.ts";
import { workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { sessionStatePath, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts";
import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts";
import { queryEvidenceEvents } from "../src/evidence-log.ts";
// Mock client for root session (not a sub-agent)
function mockRootClient() {
@@ -338,6 +339,69 @@ 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 () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -450,6 +514,62 @@ test("chat system transform reuses frozen rendered workspace snapshot", async ()
}
});
test("chat system transform degrades gracefully when workspace memory JSON is corrupt", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const path = await workspaceMemoryPath(tmpDir);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, "{ invalid json", "utf8");
const plugin = await MemoryV2Plugin({
directory: tmpDir,
client: mockRootClient(),
});
const output = { system: ["base header"] };
await assert.doesNotReject(async () => {
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "corrupt-json-session", model: {} },
output,
);
});
assert.deepEqual(output.system, ["base header"]);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("hook failure emits hook_failed evidence without raw tool output", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const statePath = await sessionStatePath(tmpDir, "hook-failure-session");
await mkdir(dirname(statePath), { recursive: true });
await mkdir(statePath, { recursive: true });
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "bash",
sessionID: "hook-failure-session",
args: { command: "cat secret-output.txt" },
},
{ output: "raw tool output password: sushi should not be in evidence", exitCode: 1 },
);
const events = await queryEvidenceEvents(tmpDir, { types: ["hook_failed"] });
const raw = JSON.stringify(events);
assert.equal(events.length, 1);
assert.equal(events[0].reasonCodes.includes("tool.execute.after"), true);
assert.equal(raw.includes("raw tool output"), false);
assert.equal(raw.includes("sushi"), false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("no compaction: owned explicit memory is not promoted by unrelated next session start", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -876,6 +996,85 @@ test("integration: explicit memory flows from user message through pending journ
}
});
test("explicit memory lifecycle emits detected appended and promoted evidence", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const plugin = await MemoryV2Plugin({
directory: tmpDir,
client: mockClientWithLatestUser("remember this: Prefer evidence-backed lifecycle tests.", "msg-evidence-life"),
});
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "evidence-life-session", model: {} },
{ system: ["base header"] },
);
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "evidence-life-session" } },
});
const events = await queryEvidenceEvents(tmpDir, { newestFirst: false });
const eventTypes = events.map(event => event.type);
assert.ok(eventTypes.includes("explicit_memory_detected"));
assert.ok(eventTypes.includes("pending_memory_appended"));
assert.ok(eventTypes.includes("promotion_promoted"));
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "evidence-life-session", model: {} },
output,
);
const finalEvents = await queryEvidenceEvents(tmpDir, { newestFirst: false });
assert.ok(finalEvents.map(event => event.type).includes("render_selected"));
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session.compacted promotes first-time explicit memory without self-reinforcement", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const plugin = await MemoryV2Plugin({
directory: tmpDir,
client: mockClientWithLatestUser(
"remember this: Prefer no self reinforcement during first promotion.",
"msg-no-self-reinforce",
),
});
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "no-self-reinforce-session", model: {} },
{ system: ["base header"] },
);
await (plugin as Record<string, Function>)["event"]({
event: {
type: "session.compacted",
properties: { sessionID: "no-self-reinforce-session" },
},
});
const workspace = await loadWorkspaceMemory(tmpDir);
const promoted = workspace.entries.find(entry =>
/Prefer no self reinforcement/.test(entry.text)
);
assert.ok(promoted, "explicit memory should be promoted");
assert.equal(promoted.reinforcementCount ?? 0, 0);
assert.equal(promoted.lastReinforcedSessionID, undefined);
const state = await loadSessionState(tmpDir, "no-self-reinforce-session");
assert.equal(state.pendingMemories.length, 0);
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("integration: compaction candidate flows through journal promotion and clears pending journal", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
+55 -1
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { LongTermMemoryEntry } from "../src/types.ts";
import { accountPendingPromotions } from "../src/promotion-accounting.ts";
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "../src/promotion-accounting.ts";
import { memoryKey } from "../src/pending-journal.ts";
import type { MemoryConsolidationEvent } from "../src/workspace-memory.ts";
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
@@ -229,3 +229,57 @@ test("accountPendingPromotions marks manual capacity rejection as retryable", ()
assert.equal(result.clearableKeys.size, 0);
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
});
test("promotionAccountingEvidenceEvents maps every promotion outcome with relations", () => {
const promoted = mem("promoted", "Promoted memory should produce evidence.", { source: "explicit" });
const absorbed = mem("absorbed", "Absorbed memory should produce evidence.", { source: "explicit" });
const retained = mem("retained", "absorbed memory should produce evidence.", { source: "explicit" });
const identityAbsorbed = mem("identity-absorbed", "Project config lives in `src/config.ts`", { type: "reference" });
const identityRetained = mem("identity-retained", "Project config lives in `./src/config.ts`", { type: "reference" });
const superseded = mem("superseded", "Parser supports 3 formats.", { source: "compaction" });
const replacement = mem("replacement", "Parser supports 4 formats.", { source: "compaction" });
const capacity = mem("capacity", "Capacity rejected explicit memory should retry.", { source: "explicit", type: "reference" });
const exhausted = mem("exhausted", "Exhausted explicit memory should stop retrying.", { source: "explicit", type: "reference" });
const pending = [promoted, absorbed, identityAbsorbed, superseded, capacity, exhausted];
const accounting = {
promotedKeys: new Set([memoryKey(promoted)]),
absorbedKeys: new Set([memoryKey(absorbed), memoryKey(identityAbsorbed)]),
supersededKeys: new Set([memoryKey(superseded)]),
rejectedKeys: new Set([memoryKey(capacity), memoryKey(exhausted)]),
retryableRejectedKeys: new Set([memoryKey(capacity), memoryKey(exhausted)]),
clearableKeys: new Set([memoryKey(promoted), memoryKey(absorbed), memoryKey(identityAbsorbed), memoryKey(superseded), memoryKey(exhausted)]),
};
const events = [
{ ...event(absorbed, "absorbed_exact"), retainedId: retained.id },
{ ...event(identityAbsorbed, "absorbed_identity"), retainedId: identityRetained.id },
{ ...event(superseded, "superseded_existing"), retainedId: replacement.id, supersededId: superseded.id },
event(capacity, "rejected_capacity"),
event(exhausted, "rejected_capacity"),
];
const evidence = promotionAccountingEvidenceEvents({
pending,
after: [promoted, retained, identityRetained, replacement],
events,
accounting,
exhaustedRejectedKeys: new Set([memoryKey(exhausted)]),
});
const expectedPromotionEventTypes = new Set([
"promotion_promoted",
"promotion_absorbed_exact",
"promotion_absorbed_identity",
"promotion_superseded",
"promotion_rejected_capacity",
"promotion_retry_scheduled",
"promotion_retry_exhausted",
]);
assert.deepEqual(new Set(evidence.map(event => event.type)), expectedPromotionEventTypes);
const absorbedEvent = evidence.find(event => event.type === "promotion_absorbed_exact");
assert.ok(absorbedEvent?.relations?.some(relation => relation.role === "absorbed" && relation.memory?.memoryId === absorbed.id));
assert.ok(absorbedEvent?.relations?.some(relation => relation.role === "retained" && relation.memory?.memoryId === retained.id));
const supersededEvent = evidence.find(event => event.type === "promotion_superseded");
assert.ok(supersededEvent?.relations?.some(relation => relation.role === "superseded" && relation.memory?.memoryId === superseded.id));
assert.ok(supersededEvent?.relations?.some(relation => relation.role === "superseded_by" && relation.memory?.memoryId === replacement.id));
});
+124 -3
View File
@@ -1,11 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { spawn } from "node:child_process";
import { updateJSON } from "../src/storage.ts";
import { readJSON, updateJSON } from "../src/storage.ts";
import { queryEvidenceEvents } from "../src/evidence-log.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
test("updateJSON serializes concurrent increments", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-"));
@@ -37,6 +39,44 @@ test("updateJSON does not replace corrupt JSON with fallback", async () => {
}
});
test("readJSON quarantines corrupt JSON and returns fallback", async () => {
const dir = await mkdtemp(join(tmpdir(), "wm-storage-corrupt-"));
const path = join(dir, "store.json");
try {
await writeFile(path, "{ invalid json", "utf8");
const loaded = await readJSON(path, () => ({ ok: true }));
assert.deepEqual(loaded, { ok: true });
const files = await readdir(dir);
assert.equal(files.includes("store.json"), false);
assert.equal(files.some(file => file.startsWith("store.json.corrupt-")), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("readJSON emits corrupt JSON quarantine evidence for workspace stores", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-corrupt-"));
try {
const path = await workspaceMemoryPath(root);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, "{ invalid json", "utf8");
const loaded = await readJSON(path, () => ({ ok: true }));
const events = await queryEvidenceEvents(root, { types: ["storage_corrupt_json_quarantined"] });
assert.deepEqual(loaded, { ok: true });
assert.equal(events.length, 1);
assert.equal(events[0].reasonCodes.includes("invalid_json"), true);
assert.equal(JSON.stringify(events).includes("invalid json"), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON recovers stale lock files left by crashed process", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-stale-lock-"));
try {
@@ -52,6 +92,47 @@ test("updateJSON recovers stale lock files left by crashed process", async () =>
}
});
test("updateJSON emits stale lock recovery evidence for workspace stores", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-stale-lock-"));
try {
const path = await workspaceMemoryPath(root);
const lockPath = `${path}.lock`;
await mkdir(dirname(path), { recursive: true });
await writeFile(lockPath, `999999\n0\n`, "utf8");
await updateJSON(path, () => ({ count: 0 }), current => ({ count: current.count + 1 }));
const events = await queryEvidenceEvents(root, { types: ["storage_stale_lock_recovered"] });
assert.equal(events.length, 1);
assert.equal(events[0].reasonCodes.includes("stale_lock"), true);
assert.equal(JSON.stringify(events).includes("999999"), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON emits lock timeout evidence and still throws", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-timeout-"));
try {
const path = await workspaceMemoryPath(root);
const lockPath = `${path}.lock`;
await mkdir(dirname(path), { recursive: true });
await writeFile(lockPath, `${process.pid}\n${Date.now()}\n`, "utf8");
await assert.rejects(
updateJSON(path, () => ({ count: 0 }), current => current),
/Timed out waiting for lock/,
);
const events = await queryEvidenceEvents(root, { types: ["storage_lock_timeout"] });
assert.equal(events.length, 1);
assert.equal(events[0].reasonCodes.includes("lock_wait_timeout"), true);
assert.equal(JSON.stringify(events).includes(String(process.pid)), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON serializes writes across separate node processes", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-xproc-"));
try {
@@ -81,3 +162,43 @@ test("updateJSON serializes writes across separate node processes", async () =>
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON waits for a live cross-process lock and preserves both updates", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-live-lock-"));
try {
const path = join(root, "counter.json");
const worker = `
import { updateJSON } from ${JSON.stringify(new URL("../src/storage.ts", import.meta.url).href)};
const path = process.argv[1];
await updateJSON(path, () => ({ count: 0, order: [] }), async current => {
await new Promise(resolve => setTimeout(resolve, 250));
return { count: current.count + 1, order: [...current.order, "child"] };
});
`;
const child = spawn(
process.execPath,
["--experimental-strip-types", "--input-type=module", "-e", worker, path],
{ stdio: "inherit" },
);
await new Promise(resolve => setTimeout(resolve, 50));
await updateJSON(path, () => ({ count: 0, order: [] as string[] }), current => ({
count: current.count + 1,
order: [...current.order, "parent"],
}));
await new Promise<void>((resolve, reject) => {
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`child exited ${code}`)));
child.on("error", reject);
});
const final = await updateJSON(path, () => ({ count: 0, order: [] as string[] }), current => current);
assert.equal(final.count, 2);
assert.deepEqual(new Set(final.order), new Set(["child", "parent"]));
assert.equal(existsSync(`${path}.lock`), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+632 -24
View File
@@ -6,8 +6,10 @@ import { tmpdir } from "node:os";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
import { queryEvidenceEvents } from "../src/evidence-log.ts";
import {
renderWorkspaceMemory,
accountWorkspaceMemoryRender,
enforceLongTermLimits,
dedupeLongTermEntriesWithAccounting,
enforceLongTermLimitsWithAccounting,
@@ -19,13 +21,16 @@ import {
loadWorkspaceMemory,
saveWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
} from "../src/workspace-memory.ts";
import {
RETENTION_TYPE_MAX,
calculateInitialStrength,
calculateEffectiveHalfLife,
calculateRetentionStrength,
calculateDormantDays,
calculateEffectiveAgeDays,
reinforceMemory,
} from "../src/workspace-memory.ts";
} from "../src/retention.ts";
import { redactCredentials } from "../src/redaction.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
@@ -154,6 +159,47 @@ test("renderWorkspaceMemory returns empty for no entries", () => {
assert.equal(rendered, "");
});
test("accountWorkspaceMemoryRender reports rendered and omitted reasons", () => {
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [
...Array.from({ length: 12 }, (_, i) => entry(`feedback-render-${i}`, `Unique rendered feedback preference ${i}`, "feedback")),
{ ...entry("superseded-render", "Old superseded memory", "decision"), status: "superseded" as const },
],
updatedAt: new Date().toISOString(),
};
const accounting = accountWorkspaceMemoryRender(store);
assert.equal(accounting.rendered.length, 10);
assert.ok(accounting.omitted.some(item => item.reason === "type_cap"));
assert.ok(accounting.omitted.some(item => item.reason === "superseded"));
assert.ok(accounting.evidence.some(event => event.type === "render_selected"));
assert.ok(accounting.evidence.some(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap")));
});
test("accountWorkspaceMemoryRender reports char budget and empty budget omissions", () => {
const charBudgetStore: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 180, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: Array.from({ length: 3 }, (_, i) => entry(`char-budget-${i}`, `Long rendered memory ${i} `.repeat(20), "decision")),
updatedAt: new Date().toISOString(),
};
const emptyBudgetStore: WorkspaceMemoryStore = {
...charBudgetStore,
limits: { maxRenderedChars: 10, maxEntries: LONG_TERM_LIMITS.maxEntries },
};
const charBudget = accountWorkspaceMemoryRender(charBudgetStore);
const emptyBudget = accountWorkspaceMemoryRender(emptyBudgetStore);
assert.ok(charBudget.omitted.some(item => item.reason === "char_budget"));
assert.ok(emptyBudget.omitted.some(item => item.reason === "empty_render_budget"));
});
// ============================================
// PR-2 Task 5 tests (for enforceLongTermLimits)
// ============================================
@@ -270,7 +316,7 @@ test("enforceLongTermLimits respects maxEntries limit", () => {
assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`);
});
test("calculateInitialStrength multiplies type, source, importance, and safety factors", () => {
test("calculateInitialStrength multiplies type, source, and importance factors", () => {
const memory: LongTermMemoryEntry = {
...entry("strength", "Never store raw credentials", "reference"),
source: "explicit",
@@ -278,7 +324,20 @@ test("calculateInitialStrength multiplies type, source, importance, and safety f
safetyCritical: true,
};
assert.equal(calculateInitialStrength(memory), 18);
assert.equal(calculateInitialStrength(memory), 3);
});
test("calculateInitialStrength ignores deprecated safetyCritical field", () => {
const memory: LongTermMemoryEntry = {
...entry("safety-deprecated", "Deprecated safety field should not affect strength", "decision"),
source: "explicit",
userImportance: "high",
safetyCritical: true,
};
const withoutSafety = { ...memory, safetyCritical: undefined };
assert.equal(calculateInitialStrength(memory), calculateInitialStrength(withoutSafety));
});
test("calculateEffectiveHalfLife clamps reinforcement count at configured maximum", () => {
@@ -447,6 +506,33 @@ test("reinforceMemory enforces session interval and max guards", () => {
assert.equal(reinforceMemory(atMax, "session-c", now), atMax);
});
test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => {
const firstReinforcedAt = Date.UTC(2026, 3, 29, 0, 15);
const sameUtcDayMuchLater = Date.UTC(2026, 3, 29, 23, 30);
const nextUtcDayAfterInterval = Date.UTC(2026, 3, 30, 1, 30);
const base: LongTermMemoryEntry = {
...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"),
reinforcementCount: 1,
lastReinforcedAt: firstReinforcedAt,
lastReinforcedSessionID: "session-a",
};
assert.equal(reinforceMemory(base, "session-b", sameUtcDayMuchLater), base);
const reinforcedNextDay = reinforceMemory(base, "session-b", nextUtcDayAfterInterval);
assert.notEqual(reinforcedNextDay, base);
assert.equal(reinforcedNextDay.reinforcementCount, 2);
assert.equal(reinforcedNextDay.lastReinforcedAt, nextUtcDayAfterInterval);
assert.equal(reinforcedNextDay.lastReinforcedSessionID, "session-b");
assert.equal(reinforcedNextDay.retentionClock, nextUtcDayAfterInterval);
const atMax: LongTermMemoryEntry = {
...base,
reinforcementCount: 6,
};
assert.equal(reinforceMemory(atMax, "session-c", nextUtcDayAfterInterval), atMax);
});
test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => {
const now = Date.now();
const retained: LongTermMemoryEntry = {
@@ -469,6 +555,47 @@ test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates",
assert.equal(result.kept[0].reinforcementCount, 1);
assert.equal(result.kept[0].lastReinforcedSessionID, "reinforce-session");
assert.ok(typeof result.kept[0].retentionClock === "number");
assert.ok(result.evidence.some(event =>
event.type === "memory_reinforced" &&
event.reasonCodes.includes("duplicate_exact") &&
event.relations?.some(relation => relation.role === "reinforced" && relation.memory?.memoryId === "duplicate") &&
event.relations?.some(relation => relation.role === "reinforced_by" && relation.memory?.memoryId === "retained")
));
});
test("dedupeLongTermEntriesWithAccounting decision same-identity variants absorb exact only", () => {
const retained = entry("decision-a", "Use pnpm for package management!!!", "decision");
const duplicate = entry("decision-b", "use pnpm for package management", "decision");
assert.notEqual(retained.text, duplicate.text);
assert.equal(workspaceMemoryIdentityKey(retained), workspaceMemoryIdentityKey(duplicate));
assert.equal(workspaceMemoryExactKey(retained), workspaceMemoryExactKey(duplicate));
const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]);
assert.equal(result.kept.length, 1);
assert.equal(result.absorbed.length, 1);
assert.equal(result.absorbed[0].reason, "absorbed_exact");
assert.equal(result.superseded.length, 0);
assert.equal(result.absorbed.some(event => event.reason === "superseded_existing"), false);
});
test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => {
const now = Date.now();
const retained: LongTermMemoryEntry = {
...entry("retained-identity", "OpenCode plugin config location: `.opencode-agenthub/current/xdg/opencode/opencode.json` in workspace", "reference"),
retentionClock: now - 10 * DAY_MS,
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-identity", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference"),
pendingOwnerSessionID: "identity-session",
};
const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]);
assert.ok(result.evidence.some(event =>
event.type === "memory_reinforced" && event.reasonCodes.includes("duplicate_identity")
));
});
test("reinforced memory with same initial strength and age ranks above unreinforced memory", () => {
@@ -514,6 +641,7 @@ test("dedupe reinforcement does not increment for same session", () => {
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "same-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
});
test("dedupe reinforcement does not increment under one hour", () => {
@@ -536,6 +664,23 @@ test("dedupe reinforcement does not increment under one hour", () => {
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "old-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
});
test("dedupe reinforcement does not emit evidence at max reinforcement count", () => {
const existing: LongTermMemoryEntry = {
...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 6,
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false);
});
test("enforceLongTermLimits orders entries by retention strength", () => {
@@ -567,7 +712,73 @@ test("enforceLongTermLimits applies per-type caps after strength sorting", () =>
assert.equal(kept.filter(memory => memory.type === "feedback").length, 10);
});
test("enforceLongTermLimits exempts safety-critical entries from type caps", () => {
test("safetyCritical entries compete under RETENTION_TYPE_MAX caps like other entries", () => {
const safetyEntries: LongTermMemoryEntry[] = Array.from({ length: 6 }, (_, i) => ({
...entry(`safety-${i}`, `Safety memory ${i}`, "feedback"),
source: "explicit",
safetyCritical: true,
}));
const ordinaryEntries: LongTermMemoryEntry[] = Array.from({ length: 10 }, (_, i) => ({
...entry(`ordinary-${i}`, `Ordinary memory ${i}`, "feedback"),
source: "explicit",
}));
const all = [...safetyEntries, ...ordinaryEntries];
const kept = enforceLongTermLimits(all);
const feedbackCount = kept.filter(e => e.type === "feedback").length;
assert.equal(feedbackCount, RETENTION_TYPE_MAX.feedback);
// safetyCritical entries are no longer exempt from type caps
assert.ok(kept.filter(e => e.safetyCritical).length < 6);
});
test("workspace memory JSON with deprecated safetyCritical loads and competes normally", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-safety-compat-"));
try {
const key = await workspaceKey(root);
const path = await workspaceMemoryPath(root);
const now = new Date().toISOString();
const safetyEntries: LongTermMemoryEntry[] = Array.from({ length: 6 }, (_, i) => ({
...entry(`safety-fixture-${i}`, `Safety fixture memory ${i}`, "feedback"),
source: "explicit",
userImportance: i === 0 ? "high" : "normal",
safetyCritical: true,
}));
const ordinaryEntries: LongTermMemoryEntry[] = Array.from({ length: 10 }, (_, i) => ({
...entry(`ordinary-fixture-${i}`, `Ordinary fixture memory ${i}`, "feedback"),
source: "explicit",
}));
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [...safetyEntries, ...ordinaryEntries],
migrations: [],
updatedAt: now,
lastActivityAt: now,
};
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
const loaded = await loadWorkspaceMemory(root);
const safetyEntry = loaded.entries.find(memory => memory.safetyCritical);
assert.ok(safetyEntry, "fixture should include deprecated safetyCritical entries");
assert.equal(
calculateInitialStrength(safetyEntry),
calculateInitialStrength({ ...safetyEntry, safetyCritical: undefined }),
);
const kept = enforceLongTermLimits(loaded.entries);
assert.equal(kept.filter(memory => memory.type === "feedback").length, RETENTION_TYPE_MAX.feedback);
assert.ok(kept.filter(memory => memory.safetyCritical).length < safetyEntries.length);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("enforceLongTermLimits applies type caps to deprecated safetyCritical entries", () => {
const ordinaryFeedback = Array.from({ length: 12 }, (_, i) =>
entry(`feedback_${i}`, `Unique safe ordinary feedback preference ${i}`, "feedback")
);
@@ -578,12 +789,11 @@ test("enforceLongTermLimits exempts safety-critical entries from type caps", ()
const kept = enforceLongTermLimits([safetyCriticalFeedback, ...ordinaryFeedback]);
assert.equal(kept.length, 11);
assert.ok(kept.some(memory => memory.id === "safety-feedback"));
assert.equal(kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10);
assert.equal(kept.length, RETENTION_TYPE_MAX.feedback);
assert.equal(kept.filter(memory => memory.type === "feedback").length, RETENTION_TYPE_MAX.feedback);
});
test("mixed retention scenario applies caps, safety exemption, and reinforcement ordering", () => {
test("mixed retention scenario applies caps and reinforcement ordering", () => {
const now = Date.now();
const oldAge = now - 120 * DAY_MS;
const ordinaryFeedback = Array.from({ length: 17 }, (_, i) =>
@@ -625,18 +835,15 @@ test("mixed retention scenario applies caps, safety exemption, and reinforcement
lastActivityAt: new Date(now).toISOString(),
};
assert.ok(entries.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length > 10);
assert.ok(entries.filter(memory => memory.type === "decision" && !memory.safetyCritical).length > 10);
assert.ok(entries.filter(memory => memory.type === "feedback").length > 10);
assert.ok(entries.filter(memory => memory.type === "decision").length > 10);
const result = enforceLongTermLimitsWithAccounting(entries, store);
assert.ok(result.kept.length <= 28);
assert.ok(result.kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length <= 10);
assert.ok(result.kept.filter(memory => memory.type === "decision" && !memory.safetyCritical).length <= 10);
assert.ok(result.kept.some(memory => memory.safetyCritical));
assert.equal(result.kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10);
assert.equal(result.kept.filter(memory => memory.type === "feedback" && memory.safetyCritical).length, 1);
assert.equal(result.kept.filter(memory => memory.type === "feedback").length, 11);
assert.ok(result.kept.filter(memory => memory.type === "feedback").length <= RETENTION_TYPE_MAX.feedback);
assert.ok(result.kept.filter(memory => memory.type === "decision").length <= RETENTION_TYPE_MAX.decision);
assert.equal(result.kept.filter(memory => memory.type === "feedback").length, RETENTION_TYPE_MAX.feedback);
const reinforcedIndex = result.kept.findIndex(memory => memory.id === "old-reinforced");
const unreinforcedIndex = result.kept.findIndex(memory => memory.id === "old-unreinforced");
assert.ok(reinforcedIndex >= 0, "old reinforced reference should be kept");
@@ -810,6 +1017,16 @@ test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () =
assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading");
});
test("workspaceMemoryIdentityKey returns exact key for feedback entries", () => {
const feedback = entry(
"feedback-exact-identity",
"User prefers references to mention `.opencode/opencode.json` explicitly.",
"feedback",
);
assert.equal(workspaceMemoryIdentityKey(feedback), workspaceMemoryExactKey(feedback));
});
test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => {
const root = "/repo";
const now = new Date().toISOString();
@@ -840,6 +1057,143 @@ test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounti
assert.equal(result.store.entries[0].text, "Admin PIN 是 [REDACTED]");
});
test("retentionClock backfill: missing clock becomes createdAt timestamp", async () => {
const root = "/repo";
const createdAt = "2026-01-02T03:04:05.000Z";
const memory = {
...entry("backfill-created", "Use durable createdAt for retention backfill", "decision"),
createdAt,
updatedAt: "2026-04-01T03:04:05.000Z",
};
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries: [memory],
updatedAt: createdAt,
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.store.entries[0].retentionClock, new Date(createdAt).getTime());
});
test("retentionClock backfill: invalid createdAt uses updatedAt", async () => {
const root = "/repo";
const updatedAt = "2026-02-03T04:05:06.000Z";
const memory = {
...entry("backfill-updated", "Use updatedAt only when createdAt is invalid", "decision"),
createdAt: "not-a-date",
updatedAt,
};
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries: [memory],
updatedAt,
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.store.entries[0].retentionClock, new Date(updatedAt).getTime());
});
test("retentionClock backfill: both invalid uses Date.now()", async () => {
const root = "/repo";
const before = Date.now();
const memory = {
...entry("backfill-now", "Use current wall clock when stored timestamps are invalid", "decision"),
createdAt: "not-a-date",
updatedAt: "also-not-a-date",
};
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries: [memory],
updatedAt: new Date(before).toISOString(),
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
const after = Date.now();
const retentionClock = result.store.entries[0].retentionClock;
assert.equal(typeof retentionClock, "number");
assert.ok(Number.isFinite(retentionClock));
assert.ok((retentionClock ?? 0) >= before);
assert.ok((retentionClock ?? 0) <= after);
});
test("retentionClock backfill: valid clock is unchanged", async () => {
const root = "/repo";
const createdAt = "2026-01-02T03:04:05.000Z";
const retentionClock = new Date("2025-12-31T00:00:00.000Z").getTime();
const memory = {
...entry("backfill-valid", "Preserve existing valid retention clocks", "decision"),
createdAt,
updatedAt: "2026-04-01T03:04:05.000Z",
retentionClock,
};
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries: [memory],
updatedAt: createdAt,
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.store.entries[0].retentionClock, retentionClock);
});
test("retentionClock backfill: rendered IDs unchanged before and after", async () => {
const root = "/repo";
const oldCreatedAt = "2026-01-01T00:00:00.000Z";
const newCreatedAt = "2026-02-01T00:00:00.000Z";
const entries = [
{
...entry("with-clock", "Decision with a pre-existing retention clock", "decision"),
createdAt: newCreatedAt,
updatedAt: newCreatedAt,
retentionClock: new Date(newCreatedAt).getTime(),
},
{
...entry("missing-clock", "Decision missing a retention clock but with createdAt", "decision"),
createdAt: oldCreatedAt,
updatedAt: "2026-04-01T00:00:00.000Z",
},
];
const baseStore: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-28-quality-cleanup", "2026-04-26-p0-cleanup"],
entries,
updatedAt: newCreatedAt,
};
const prefilledStore: WorkspaceMemoryStore = {
...baseStore,
entries: entries.map(memory => ({
...memory,
retentionClock: memory.retentionClock ?? new Date(memory.createdAt).getTime(),
})),
};
const missingClockResult = await normalizeWorkspaceMemoryWithAccounting(root, baseStore);
const prefilledResult = await normalizeWorkspaceMemoryWithAccounting(root, prefilledStore);
assert.deepEqual(
missingClockResult.kept.map(memory => memory.id),
prefilledResult.kept.map(memory => memory.id),
);
});
test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => {
const root = "/repo";
const now = new Date().toISOString();
@@ -926,6 +1280,50 @@ 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
// ============================================
@@ -1278,7 +1676,7 @@ test("redactCredentials is idempotent and also redacts rationale text", () => {
})),
},
now,
);
).store;
assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]");
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
});
@@ -1337,14 +1735,69 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o
};
const once = runMigrationP0Cleanup(store, now);
assert.deepEqual(once.migrations, ["2026-04-26-p0-cleanup"]);
assert.equal(once.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
assert.equal(once.entries.find(e => e.id === "project-explicit")?.status, "active");
assert.equal(once.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
assert.deepEqual(once.store.migrations, ["2026-04-26-p0-cleanup"]);
assert.equal(once.store.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
assert.equal(once.store.entries.find(e => e.id === "project-explicit")?.status, "active");
assert.equal(once.store.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
assert.equal(once.events.length, 1);
assert.equal(once.events[0].type, "memory_migration_superseded");
assert.equal(once.events[0].phase, "storage");
assert.equal(once.events[0].outcome, "superseded");
assert.ok(once.events[0].reasonCodes.includes("migration:p0_cleanup"));
assert.equal(once.events[0].memory?.memoryId, "project-snapshot");
const twice = runMigrationP0Cleanup(once, later);
assert.deepEqual(twice.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
const twice = runMigrationP0Cleanup(once.store, later);
assert.deepEqual(twice.store.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
assert.equal(twice.store.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.store.entries.find(e => e.id === "project-snapshot")?.updatedAt);
assert.equal(twice.events.length, 0);
});
test("loadWorkspaceMemory appends P0 migration evidence once", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-p0-evidence-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-26T00:00:00.000Z";
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify({
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "p0_progress",
type: "project",
text: "Phase 1-4 completed successfully",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
}],
migrations: ["2026-04-28-quality-cleanup"],
updatedAt: now,
}, null, 2), "utf8");
const firstLoad = await loadWorkspaceMemory(root);
await loadWorkspaceMemory(root);
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
assert.equal(firstLoad.entries.find(memory => memory.id === "p0_progress")?.status, "superseded");
assert.equal(events.length, 1);
assert.equal(events[0].type, "memory_migration_superseded");
assert.equal(events[0].phase, "storage");
assert.equal(events[0].outcome, "superseded");
assert.deepEqual(events[0].reasonCodes, ["migration:p0_cleanup"]);
assert.equal(events[0].memory?.memoryId, "p0_progress");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(sandbox, { recursive: true, force: true });
}
});
test("quality cleanup migration preserves soft-only feedback and decision violations", async () => {
@@ -1471,6 +1924,56 @@ test("quality cleanup migration writes audit log for hard supersedes", async ()
}
});
test("quality cleanup migration appends superseded evidence with hard reasons", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-evidence-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-28T00:00:00.000Z";
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify({
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "quality_progress",
type: "project",
text: "Test suite: 1237 tests pass, 226 suites",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
staleAfterDays: 60,
}],
migrations: [],
updatedAt: now,
}, null, 2), "utf8");
const loaded = await loadWorkspaceMemory(root);
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
assert.equal(loaded.entries.find(memory => memory.id === "quality_progress")?.status, "superseded");
assert.equal(events.length, 1);
assert.equal(events[0].type, "memory_migration_superseded");
assert.equal(events[0].phase, "storage");
assert.equal(events[0].outcome, "superseded");
assert.ok(events[0].reasonCodes.includes("migration:quality_cleanup"));
assert.ok(events[0].reasonCodes.includes("quality:progress_snapshot"));
assert.equal(events[0].memory?.memoryId, "quality_progress");
assert.equal(events[0].memory?.status, "superseded");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(sandbox, { recursive: true, force: true });
}
});
test("quality cleanup migration aborts supersede when audit log cannot be written", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-"));
const dataHome = join(sandbox, "xdg-data-home");
@@ -1990,3 +2493,108 @@ test("loadWorkspaceMemory normalizes and persists credentials from legacy unreda
await rm(sandbox, { recursive: true, force: true });
}
});
function decisionEntry(id: string, text: string, timestampMs: number): LongTermMemoryEntry {
const timestamp = new Date(timestampMs).toISOString();
return {
id,
type: "decision",
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
retentionClock: timestampMs,
};
}
test("enforceLongTermLimitsWithAccounting capacity drops return 3 lower-ranked decisions in dropped", () => {
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
const thirtyDaysAgo = now - 30 * DAY_MS;
const existingDecisions = Array.from({ length: 10 }, (_, i) =>
decisionEntry(
`existing-decision-${i}`,
`Existing durable architecture decision ${i}`,
thirtyDaysAgo,
)
);
const newDecisions = Array.from({ length: 3 }, (_, i) =>
decisionEntry(
`new-decision-${i}`,
`Newer durable architecture decision ${i}`,
now,
)
);
const result = enforceLongTermLimitsWithAccounting([...existingDecisions, ...newDecisions]);
const capacityDrops = result.dropped.filter(event => event.reason === "rejected_capacity");
const droppedIds = new Set(capacityDrops.map(event => event.memory.id));
const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity");
assert.equal(capacityDrops.length, 3);
assert.equal(capacityEvidence.length, 3);
for (const event of capacityEvidence) {
assert.equal(event.phase, "storage");
assert.equal(event.outcome, "removed");
assert.ok(event.memory?.memoryId, "capacity evidence should include removed memory id");
assert.ok(event.memory.memoryKeyHash, "capacity evidence should include removed memory key hash");
assert.ok(event.memory.identityKeyHash, "capacity evidence should include removed identity key hash");
assert.equal(droppedIds.has(event.memory.memoryId), true);
assert.ok(event.relations?.[0]?.memory.memoryKeyHash, "removed relation should include memory key hash");
assert.ok(event.relations?.[0]?.memory.identityKeyHash, "removed relation should include identity key hash");
assert.deepEqual(event.reasonCodes, ["type_cap"]);
}
});
test("enforceLongTermLimitsWithAccounting emits global_cap evidence for global cap losers", () => {
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: RETENTION_TYPE_MAX.feedback }, (_, i) =>
decisionEntry(`global-feedback-${i}`, `Global cap feedback ${i}`, now)
).map(memory => ({ ...memory, type: "feedback" as const })),
...Array.from({ length: RETENTION_TYPE_MAX.decision }, (_, i) =>
decisionEntry(`global-decision-${i}`, `Global cap decision ${i}`, now)
),
...Array.from({ length: RETENTION_TYPE_MAX.project }, (_, i) =>
decisionEntry(`global-project-${i}`, `Global cap project ${i}`, now)
).map(memory => ({ ...memory, type: "project" as const })),
...Array.from({ length: RETENTION_TYPE_MAX.reference }, (_, i) =>
decisionEntry(`global-reference-${i}`, `Global cap reference ${i}`, now)
).map(memory => ({ ...memory, type: "reference" as const })),
];
const result = enforceLongTermLimitsWithAccounting(entries);
const capacityEvidence = result.evidence.filter(event => event.type === "memory_removed_capacity");
const globalCapEvidence = capacityEvidence.filter(event => event.reasonCodes.includes("global_cap"));
assert.equal(entries.length, 34);
assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries);
assert.equal(globalCapEvidence.length, entries.length - LONG_TERM_LIMITS.maxEntries);
assert.equal(capacityEvidence.some(event => event.reasonCodes.includes("type_cap")), false);
assert.ok(globalCapEvidence.every(event => event.phase === "storage" && event.outcome === "removed"));
});
test("accountWorkspaceMemoryRender emits render_omitted for type_cap with 11 decisions", () => {
const now = Date.UTC(2026, 4, 1, 6, 24, 0);
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: Array.from({ length: 11 }, (_, i) =>
decisionEntry(`render-decision-${i}`, `Render durable decision ${i}`, now)
),
updatedAt: new Date(now).toISOString(),
lastActivityAt: new Date(now).toISOString(),
};
const accounting = accountWorkspaceMemoryRender(store);
const typeCapOmissions = accounting.omitted.filter(item => item.reason === "type_cap");
const typeCapEvidence = accounting.evidence.filter(event =>
event.type === "render_omitted" && event.reasonCodes.includes("type_cap")
);
assert.equal(accounting.rendered.length, RETENTION_TYPE_MAX.decision);
assert.equal(typeCapOmissions.length, 1);
assert.equal(typeCapEvidence.length, 1);
});
+1 -1
View File
@@ -25,6 +25,6 @@
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["index.ts", "src/**/*.ts"],
"include": ["index.ts", "src/**/*.ts", "scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts"],
"exclude": ["node_modules", "dist"]
}