Compare commits

...

4 Commits

Author SHA1 Message Date
Ralph Chang dbc5c01818 fix(package): publish JavaScript plugin entry 2026-05-31 14:44:25 +08:00
Ralph Chang 01bda7c134 fix(memory): freeze hot session prompt epoch 2026-05-20 12:38:16 +08:00
Ralph Chang 041115c173 chore(code-health): prepare v1.6.5 2026-05-19 15:05:48 +08:00
Ralph Chang a480b734b2 feat(memory): add rolling reinforcement window 2026-05-15 11:16:34 +08:00
38 changed files with 2291 additions and 529 deletions
+2 -2
View File
@@ -208,8 +208,8 @@ const typedData = data as WorkspaceMemoryStore; // Explicit cast after validati
// ============================================================================
// ✅ REQUIRED: Block comments for complex logic
// Quality gate: Reject candidates that are git hashes, errors, or path-heavy
function shouldAcceptWorkspaceMemoryCandidate(candidate: string): boolean {
// Quality gate: return accepted/reasons so rejection evidence stays explainable
function evaluateWorkspaceMemoryCandidate(candidate: WorkspaceMemoryCandidate): CandidateEvaluation {
// ...
}
+70
View File
@@ -5,6 +5,76 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6.7] - 2026-05-31
### Fixed
- Fixed published OpenCode plugin loading under Node-based plugin loaders by compiling the runtime plugin entry to JavaScript and pointing package entry exports at `dist/`.
- Added package smoke coverage for bare, `./server`, and `./tui` imports from an installed tarball so TypeScript entry points cannot regress under `node_modules`.
## [1.6.6] - 2026-05-20
### Changed
- Froze hot session state with the existing prompt-epoch model to reduce pre-history prompt churn for better prefix KV-cache reuse.
- Switched frozen prompt cache pressure eviction to recency-aware tracking.
- Updated README and architecture docs for the new frozen hot snapshot behavior.
### Fixed
- Fixed KV prefix-cache instability caused by per-turn hot session prompt changes.
### Thanks
- Thanks to @nilo85 for opening PR #5 and surfacing the local-LLM KV cache hit-rate issue that led to this release.
## [1.6.5] - 2026-05-19
### Added
- Added `check:package-integrity` to verify `package.json` and on-disk `package-lock.json` root versions stay aligned even though the lockfile remains ignored by git.
- Added `tsconfig.unused.json` as a strict unused-symbol audit gate for development and release checks.
- Added package-integrity tests covering matching versions, mismatch reporting, and missing-lockfile guidance.
- Added storage/evidence contract tests for full-state JSON overwrites and concurrent evidence JSONL appends.
- Added workspace-memory render-order characterization and memory-visibility order coverage for the shared memory type order.
### Changed
- Centralized the current memory type ordering (`feedback`, `project`, `decision`, `reference`) in a narrow `memory-kind-policy` seam used by workspace rendering, TUI grouping, and memory visibility.
- Extracted diagnostics producer-version grouping and inference helpers from `memory-diag quality` into a pure diagnostics-only module while preserving the existing JSON and human output contracts.
- Documented storage write-path contracts in code: `updateJSON` is the locked read-modify-write path, `atomicWriteJSON` is the full-state overwrite primitive, and evidence logs remain append-only JSONL with bounded pruning.
- Marked legacy parser fixtures and retention caps as intentional compatibility/policy-contract test coverage.
- Updated developer docs to reference `evaluateWorkspaceMemoryCandidate` instead of the removed private acceptance wrapper.
### Deprecated
- Marked `REINFORCEMENT_MIN_INTERVAL_MS` with JSDoc `@deprecated`; the rolling reinforcement policy uses `REINFORCEMENT_MIN_ELAPSED_MS`.
### Removed
- Removed unused imports and private unused helpers discovered by the new unused-symbol audit, including the private `shouldAcceptWorkspaceMemoryCandidate` wrapper.
### Fixed
- Fixed release hygiene drift detection for the ignored lockfile by adding an explicit package integrity check.
- Reduced future diagnostics and memory-kind change risk by extracting small behavior-preserving seams without changing runtime memory behavior.
## [1.6.4] - 2026-05-15
### Changed
- Replaced same-session reinforcement blocking with a rolling 7-day elapsed reinforcement window so long-lived OpenCode sessions can reinforce durable memories after meaningful weekly recurrence.
- Kept the 45-day base half-life while changing the max reinforcement count to a growth saturation point: memories at count 6 can refresh retention timestamps weekly without increasing count or effective half-life.
- Bumped memory evidence instrumentation to version 3 for the new elapsed-window and refresh-only reinforcement semantics.
- Updated `memory-diag commands --memory` to show elapsed-window details, `sameSession` evidence, `reinforcementMode`, and legacy missing timestamp markers without exposing raw session IDs.
- Updated `memory-diag quality` to keep historical `same_session` block accounting while preventing new `sameSession` evidence from triggering old same-session diagnostic questions.
### Fixed
- Prevented long-lived sessions from indefinitely blocking reinforcement solely because the session ID stayed the same across days.
- Prevented saturated memories from growing stronger beyond the max reinforcement count while still allowing continued weekly use to keep them fresh.
- Preserved historical block reason compatibility for `same_session`, `same_utc_day`, `min_interval`, and `max_count` without producing those reasons from the new policy path.
## [1.6.3] - 2026-05-14
### Added
+5 -5
View File
@@ -132,22 +132,22 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls.
┌──────────────────────────────────────┐
│ ⚡ Prompt Context │
│ system[1]*: frozen workspace memory │
│ system[2+]*: hot session state
│ system[2+]*: frozen hot snapshot
└──────────────────────────────────────┘
```
\* Conceptually, workspace memory is pushed first when it is non-empty, and hot session state is pushed after workspace memory. If workspace memory is empty, hot state may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
\* Conceptually, frozen workspace memory is pushed first when it is non-empty, and the frozen hot snapshot is pushed after workspace memory. If workspace memory is empty, the hot snapshot may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
**Cache-friendly layout:** durable workspace memory and hot session state are rendered as separate frozen prompts that share the same epoch lifecycle. Hot state is an epoch-start snapshot: active files and open errors can change after it is created, and the conversation/tool transcript is the source of truth for newer events. The plugin intentionally does not invalidate the hot snapshot on active-file, open-error, recent-decision, or pending-memory changes because doing so would defeat prefix KV-cache reuse. Explicit pending memories remain durable and promote safely at compaction, but after the current epoch caches exist they do not force a prompt refresh.
The runtime context has three layers:
| Layer | Purpose | Lifetime |
|---|---|---|
| Workspace Memory | Durable decisions, preferences, project facts, references | Cross-session |
| Hot Session State | Active files, open errors, recent context | Current session |
| Hot Session State | Active files, open errors, recent context, pending memories | Current session storage; frozen prompt refreshes at epoch boundaries |
| Native OpenCode State | Todos and built-in state | OpenCode-managed |
## Workspace Memory
@@ -261,7 +261,7 @@ Default behavior:
- Workspace memory budget: 3600 characters (~900 tokens)
- Workspace memory limit: 28 entries
- Hot session state budget: 700 characters (~175 tokens)
- Hot session state budget: 700 characters (~175 tokens) per frozen hot snapshot
- Active files shown: 8
- Open errors shown: 3
+100
View File
@@ -1,5 +1,105 @@
# Release Notes
## 1.6.6 (2026-05-20)
### KV Cache Stability
This patch release reduces pre-history prompt churn by freezing hot session state with the existing prompt-epoch model, improving prefix KV-cache reuse for local LLMs.
Thanks to @nilo85 for opening PR #5 and surfacing the cache hit-rate issue.
### What Changed
- Hot session state now uses a frozen epoch snapshot instead of changing on every normal turn.
- Frozen prompt caches use recency-aware cache pressure eviction.
- The hot-state prompt now labels itself as an epoch snapshot so conversation/tool history remains the source of truth for newer events.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files, session state files, and evidence logs remain compatible.
### Validation
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/session-state.test.ts` — 14 tests passing
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/plugin.test.ts` — 67 tests passing
- `npm run typecheck``TYPECHECK_PASS`
- `npm test` — 509 tests passing, `TEST_PASS`
---
## 1.6.5 (2026-05-19)
### Code Health and Release Hygiene
This patch release is an internal health release before the next feature wave. It does not change memory extraction, reinforcement policy, TUI behavior, or the `memory-diag` CLI contract. Instead, it makes the codebase easier to audit and safer to modify.
The release adds package-version integrity checks, a clean unused-symbol audit, focused characterization tests, storage/evidence contract coverage, a narrow shared memory-type ordering seam, and a small diagnostics versioning extraction.
### What Changed
- **Package integrity check**: added `npm run check:package-integrity` to verify `package.json` and the on-disk `package-lock.json` root versions match, with a clear `run npm install first` message when the ignored lockfile is missing.
- **Unused-symbol audit**: added `tsconfig.unused.json` and cleaned the existing unused imports/private helpers so the audit now passes cleanly.
- **Memory type order seam**: centralized the current order (`feedback`, `project`, `decision`, `reference`) for workspace rendering, memory visibility, and TUI grouping without creating a broader policy registry.
- **Storage/evidence contracts**: documented write-path semantics and added tests for full-state JSON overwrite behavior and concurrent evidence JSONL appends.
- **Diagnostics containment**: extracted producer-version grouping and inference helpers from `memory-diag quality` into a pure diagnostics-only module while preserving existing output shape and wording.
- **Characterization coverage**: added render-order coverage and labeled compatibility/policy-contract tests so future refactors can distinguish intentional legacy behavior from brittle fixtures.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files and evidence logs remain compatible.
- The `memory-diag` CLI JSON shape and human output wording are intended to be unchanged.
- `package-lock.json` remains ignored by git in this repository; run `npm install` before `npm run check:package-integrity` if the lockfile is missing locally.
- `REINFORCEMENT_MIN_INTERVAL_MS` remains exported for compatibility but is now marked `@deprecated`; use `REINFORCEMENT_MIN_ELAPSED_MS` for the rolling reinforcement policy.
### Validation
- `npm run check:package-integrity``PACKAGE_INTEGRITY_PASS version=1.6.5`
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/package-integrity.test.ts` — 3 tests passing
- `./node_modules/.bin/tsc -p tsconfig.unused.json` — no unused-symbol errors
- `node --test --experimental-strip-types tests/memory-diag-quality.test.ts tests/memory-diag.test.ts` — 93 tests passing
- `node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/storage.test.ts tests/evidence-log.test.ts` — 22 tests passing
- `npm run typecheck``TYPECHECK_PASS`
- `npm test` — 504 tests passing, `TEST_PASS`
- `npm run build``BUILD_PASS`
---
## 1.6.4 (2026-05-15)
### Rolling Weekly Reinforcement
This patch release fixes the reinforcement policy for users who work in long-lived OpenCode sessions. Reinforcement no longer treats `same_session` as a hard block. Instead, each memory uses a rolling 7-day elapsed window, so recurring preferences can be reinforced after meaningful weekly use even when the session stays open.
The base retention half-life remains 45 days. The max reinforcement count remains 6, but it now acts as a growth saturation point rather than a lifetime hard stop.
### What Changed
- **7-day rolling window**: repeated reinforcement is allowed once 7 rolling days have elapsed since the memory's last reinforcement; 7 days minus 1ms still blocks.
- **Same-session as evidence**: `sameSession` is recorded for diagnostics but no longer blocks reinforcement by itself.
- **Refresh-only saturation**: memories at reinforcement count 6 can refresh `retentionClock`, `lastReinforcedAt`, and session evidence after the weekly window without increasing count or effective half-life.
- **Instrumentation v3**: new reinforcement evidence records elapsed-window fields, `sameSession`, `reinforcementMode`, and legacy missing timestamp markers.
- **Diagnostics updated**: `memory-diag commands --memory` exposes the new fields, while `memory-diag quality` keeps historical `same_session` block analysis separate from new same-session evidence.
### Upgrade Notes
- No configuration changes are required.
- Existing workspace memory files and evidence logs remain compatible.
- Historical diagnostics may still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, or `max_count`; new instrumentation-version-3 events use the rolling elapsed-window semantics.
- Consumers of `memory-diag commands --memory --json` should use `reinforcementMode` to distinguish count-increment reinforcement from refresh-only saturation.
### Validation
- `node --test --experimental-strip-types tests/retention.test.ts` — 10 tests passing
- `node --test --experimental-strip-types tests/workspace-memory.test.ts tests/plugin.test.ts` — 181 tests passing
- `node --test --experimental-strip-types tests/memory-diag.test.ts tests/memory-diag-quality.test.ts` — 93 tests passing
- `npm run typecheck``TYPECHECK_PASS`
- `npm test` — 498 tests passing, `TEST_PASS`
- `npm run build``BUILD_PASS`
---
## 1.6.3 (2026-05-14)
### Diagnostic Quality Review Board
+19 -9
View File
@@ -18,7 +18,8 @@ OpenCode Working Memory implements a **three-layer memory architecture** designe
│ LAYER 2: HOT SESSION STATE (Short-term, per-session) │
│ • Session-scoped tracking: active files, open errors │
│ • Storage: sessions/{sessionID}.json │
│ • Auto-extracted from tool usage patterns
│ • Frozen prompt snapshot shares the workspace epoch
│ • Auto-extracted from tool usage and explicit remembers │
│ • Cleared: on new session start │
└─────────────────────────────────────────────────────────────┘
@@ -77,7 +78,7 @@ Workspace diagnostics also read the append-only evidence log for the current wor
}
```
Instrumentation version 2 adds optional causal details for future diagnostics without backfilling old JSONL records. Reinforcement-block events may include `details.blockReason` (for example `same_session` or `same_utc_day`). Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior.
Instrumentation version 2 added optional causal block details for diagnostics without backfilling old JSONL records. Instrumentation version 3 adds elapsed-window reinforcement details such as `details.elapsedMs`, `details.requiredElapsedMs`, `details.sameSession`, `details.reinforcementMode`, and `details.legacyMissingTimestamp`. Historical reinforcement-block events may still include older `details.blockReason` values such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or may have missing block details. Capacity-removal events may include `details.strengthAtRemoval`, `details.rankAtRemoval`, `details.typeRankAtRemoval`, and `details.ageDaysAtRemoval`. `memory-diag quality` treats missing producer/instrumentation fields as historical or ambiguous rather than proof of current behavior.
### Entry Types
@@ -154,7 +155,7 @@ Default type caps:
The type-cap total is 34, intentionally above the global 28-entry cap. These are maximums, not quotas.
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement.
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry only after a rolling 7-day elapsed window. Below reinforcement count 6, an allowed recurrence increments the reinforcement count and refreshes retention timestamps; at count 6 or higher, an allowed recurrence refreshes retention timestamps without increasing the count. Same-session status is recorded as diagnostic evidence, not as a new-policy block reason.
### Safety-Critical Deprecation
@@ -182,6 +183,9 @@ Track current session context automatically:
- What files are you working on?
- What errors are currently open?
- What decisions were made recently?
- Which explicit memories are pending promotion?
Hot session state is stored continuously during a session, but it is not rendered as a per-turn dynamic prompt. The prompt layer uses a frozen hot snapshot created or refreshed at the same epoch boundary as frozen workspace memory. Active files and open errors are current at epoch boundaries, not on every normal turn. After epoch start, the conversation/tool transcript is the source of truth for newer events.
### Storage
@@ -195,7 +199,8 @@ Track current session context automatically:
updatedAt: string,
activeFiles: ActiveFile[],
openErrors: OpenError[],
recentDecisions: SessionDecision[]
recentDecisions: SessionDecision[],
pendingMemories: LongTermMemoryEntry[]
}
```
@@ -242,12 +247,17 @@ Short-term decisions made this session. Candidates for promotion to workspace me
### System Prompt Injection
Hot session state is injected after workspace memory:
Workspace memory and hot session state are separate cached prompt layers that share a prompt epoch lifecycle:
```text
system[1]*: frozen workspace memory
system[2+]*: frozen hot snapshot
```
The hot state example below is included in a frozen hot snapshot when the epoch is created or refreshed, not rendered again on every normal turn. Active files and open errors are current at epoch boundaries, not on every normal turn; the plugin intentionally does not invalidate the hot snapshot on active-file or open-error changes because doing so would defeat prefix KV-cache reuse. Explicit pending memories persist in session state and the pending journal, then promote safely at compaction; once the current epoch caches exist, new pending memories do not force pre-history prompt refresh. After epoch start, the conversation/tool transcript is the source of truth for newer events.
```
---
Hot session state (current session):
Hot session state snapshot (epoch start; conversation history may be newer):
active_files:
- src/plugin.ts (edit, 18x)
@@ -280,7 +290,7 @@ OpenCode Working Memory hooks into OpenCode lifecycle events:
### `experimental.chat.system.transform`
Injects workspace memory and hot session state into system prompt.
Injects cached frozen workspace memory and cached frozen hot snapshot prompts into the system prompt. Normal tool/user churn updates storage but does not mutate these pre-history prompts until a new epoch starts.
### `tool.execute.after`
+6 -4
View File
@@ -38,8 +38,8 @@ Every diagnostic section must document:
For `memory-diag quality`:
- `reinforcementRules`: `inventory_only` (cannot distinguish spam from legitimate blocks)
- `evictionAndCaps`: `inventory_only` (cannot distinguish healthy turnover from premature eviction)
- Old evidence remains ambiguous. Answerability improves only for events produced after instrumentation version 2. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections.
- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and UTC-day grouping; they still require human content judgment.
- Old evidence remains ambiguous. Answerability improves for producer-instrumented events, including instrumentation version 2 block details and instrumentation version 3 elapsed-window details. Mixed old/new logs will show a mix of `inventory_only` and `partial` sections.
- Producer-instrumented reinforcement blocks can upgrade `reinforcementRules` to `partial` by showing exact block reasons and, when available, rolling elapsed-window fields; they still require human content judgment.
- Producer-instrumented capacity removals with rank/strength snapshots can upgrade `evictionAndCaps` to `partial`; fullness alone remains occupancy inventory, not proof of a capacity problem.
## Examples
@@ -78,9 +78,11 @@ npx --package opencode-working-memory memory-diag commands --verbose
npx --package opencode-working-memory memory-diag commands --memory <memory-id>
```
The report includes successful reinforcements, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode.
The report includes successful reinforcements, refresh-only reinforcements, successful replacements, malformed commands, stale refs, protected replacement blocks, and latest command events in verbose mode.
Use `commands --memory <memory-id>` when you need a focused, evidence-only reinforcement view for one memory. It reports current memory status separately from recorded reinforcement attempts, block reasons, missing block details, and UTC-day evidence without judging whether the policy is correct.
Use `commands --memory <memory-id>` when you need a focused, evidence-only reinforcement view for one memory. It reports current memory status separately from recorded reinforcement attempts, block reasons, missing block details, elapsed-window fields (`elapsedMs`, `requiredElapsedMs`), `sameSession` evidence, `reinforcementMode` (`increment` or `refresh_only`), `legacyMissingTimestamp` when true, and historical UTC-day evidence without judging whether the policy is correct.
Current reinforcement policy uses a rolling 7-day elapsed window. Below reinforcement count 6, allowed attempts increment the count and refresh retention timestamps; at count 6 or higher, allowed attempts refresh retention timestamps without increasing the count. Historical evidence can still show older block reasons such as `same_session`, `same_utc_day`, `min_interval`, `max_count`, or missing block details because evidence logs are append-only and are not backfilled.
## Dry-run Recovery
+118
View File
@@ -0,0 +1,118 @@
# Plan: Package JS Entry for OpenCode Node Loader
## Goal
Fix GitHub issue #6 by making the published `opencode-working-memory` package loadable through OpenCode's Node-based plugin loader. The package must not expose TypeScript source files as runtime entry points under `node_modules`.
## Background
`opencode-working-memory@1.6.6` currently publishes:
- `main: "index.ts"`
- `exports["."]: "./index.ts"`
- `exports["./server"]: "./index.ts"`
- `exports["./tui"]: "./src/tui-plugin.ts"`
Node can import the local repo `index.ts`, but refuses to strip TypeScript types for files under `node_modules`. This means local path testing passes while npm/opencode package-cache loading fails.
## Proposed Changes
1. Expand the existing dist build config into a full package build.
- Update `tsconfig.memory-diag.json` to include `index.ts`, `src/**/*.ts`, `scripts/memory-diag.ts`, and `scripts/memory-diag/**/*.ts`.
- Remove the current exclusions for runtime plugin dependencies: `src/plugin.ts`, `src/tui-plugin.ts`, `src/opencode.ts`, `src/session-state.ts`, `src/extractors.ts`, `src/pending-journal.ts`, `src/promotion-accounting.ts`, and `src/memory-visibility.ts`.
- Keep `rootDir: "."` and `outDir: "dist"` so `index.ts` emits to `dist/index.js` and `src/tui-plugin.ts` emits to `dist/src/tui-plugin.js`.
- Keep `rewriteRelativeImportExtensions: true` so emitted ESM imports point at `.js`.
2. Update `package.json` runtime entry points.
- `main` should point to `dist/index.js`.
- `exports["."]` and `exports["./server"]` should point to `./dist/index.js`.
- `exports["./tui"]` should point to `./dist/src/tui-plugin.js`.
3. Update build scripts.
- Keep one clean build path that emits both plugin runtime JS and `memory-diag` JS.
- Preserve the existing `memory-diag` binary wrapper behavior.
- Rename or alias the script so the unified dist build is not hidden behind a memory-diag-only name.
4. Update packaging tests.
- Add a smoke test that builds/packs/installs the tarball into a temporary prefix and imports `opencode-working-memory` from `node_modules`.
- Assert the default export id is `working-memory`.
- Assert `opencode-working-memory/server` imports and exposes the same default plugin id.
- Assert `opencode-working-memory/tui` imports and exposes the TUI plugin id.
- Assert package manifest entry points do not point at `.ts`.
- Use an isolated npm cache under `/private/tmp` or the test temp root so local `~/.npm` permissions do not affect the smoke test.
5. Update package file allowlist if needed.
- Ensure `dist/`, `scripts/memory-diag-bin.cjs`, `README.md`, and `LICENSE` are included.
- Keeping TypeScript source files in the tarball is acceptable only if runtime entry points resolve to JS.
## Affected Files
- `package.json`
- `tsconfig.memory-diag.json` or a new build tsconfig
- `tests/smoke/memory-diag-packaging.test.ts` or a new smoke test under `tests/smoke/`
- `CHANGELOG.md`
- Possibly `package-lock.json` if package metadata changes require npm to refresh it
## Verification
Run:
```bash
npm run build
test -f dist/index.js
test -f dist/src/tui-plugin.js
node -e "import('./dist/index.js').then(m => console.log(m.default.id))"
node -e "import('./dist/src/tui-plugin.js').then(m => console.log(m.default.id))"
rg 'from\s+\".*\\.ts\"|import\s*\\(.*\\.ts' dist
npm run typecheck
npm test
npm run check:package-integrity
```
Package-path smoke:
```bash
rm -rf /private/tmp/owm-pack /private/tmp/owm-install /private/tmp/npm-cache
mkdir -p /private/tmp/owm-pack /private/tmp/owm-install /private/tmp/npm-cache
npm pack --cache /private/tmp/npm-cache --pack-destination /private/tmp/owm-pack
npm install --cache /private/tmp/npm-cache --prefix /private/tmp/owm-install /private/tmp/owm-pack/opencode-working-memory-*.tgz
node -e "import('/private/tmp/owm-install/node_modules/opencode-working-memory').then(m => console.log(m.default.id))"
node -e "import('/private/tmp/owm-install/node_modules/opencode-working-memory/server').then(m => console.log(m.default.id))"
node -e "import('/private/tmp/owm-install/node_modules/opencode-working-memory/tui').then(m => console.log(m.default.id))"
```
Expected output for both import checks:
```text
working-memory
```
For the TUI import, expected output is:
```text
working-memory-tui
```
Also inspect the installed package manifest and assert `main`, `exports["."]`, `exports["./server"]`, and `exports["./tui"]` all point to `.js` files.
## Release Preparation
After implementation and verification:
1. Inspect `git diff`.
2. Confirm the working tree contains only this release fix plus the pre-existing unrelated untracked plan.
3. Add a `CHANGELOG.md` entry for `1.6.7` describing the Node loader/package entry fix.
4. Run `npm version patch` or otherwise bump `package.json` and `package-lock.json` to `1.6.7` consistently.
5. If publishing from `main`, prepare the verified package for publish after the final git diff review.
## Risks
- Build config may expose TypeScript type-check errors in files that were previously excluded from `tsconfig.memory-diag.json`.
- Package smoke tests that call `npm pack` can fail on the developer machine if `~/.npm` has permission problems; tests should use an isolated cache under `/private/tmp`.
- OpenCode may resolve `./server` or `./tui` differently from bare package import, so both exports should be checked.
- `npm run check:package-integrity` currently checks package-lock version alignment only; entry-point assertions must live in packaging tests or a new entry-point check.
- Publishing both `src/` and `dist/` increases tarball size, but runtime correctness depends on the JS entry points rather than removing sources.
## Rollback
Revert the package entry/build changes and publish a corrected package if the JS entry causes a runtime regression. Existing local-path development remains unaffected by reverting because it can still import TypeScript from outside `node_modules`.
+9 -7
View File
@@ -1,13 +1,13 @@
{
"name": "opencode-working-memory",
"version": "1.6.3",
"version": "1.6.7",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
"main": "dist/index.js",
"exports": {
".": "./index.ts",
"./server": "./index.ts",
"./tui": "./src/tui-plugin.ts"
".": "./dist/index.js",
"./server": "./dist/index.js",
"./tui": "./dist/src/tui-plugin.js"
},
"bin": {
"memory-diag": "./scripts/memory-diag-bin.cjs"
@@ -24,11 +24,13 @@
],
"scripts": {
"clean:dist": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"build:memory-diag": "npm run clean:dist && tsc -p tsconfig.memory-diag.json",
"build": "npm run build:memory-diag && node -e \"console.log('BUILD_PASS')\"",
"build:dist": "npm run clean:dist && tsc -p tsconfig.memory-diag.json",
"build:memory-diag": "npm run build:dist",
"build": "npm run build:dist && node -e \"console.log('BUILD_PASS')\"",
"prepack": "npm run build",
"diag": "npm run --silent build:memory-diag && node ./scripts/memory-diag-bin.cjs",
"test:pack:memory-diag": "node --test --experimental-strip-types tests/smoke/memory-diag-packaging.test.ts",
"check:package-integrity": "node --experimental-strip-types scripts/dev/check-package-integrity.ts",
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
type PackageManifest = {
version?: unknown;
};
type PackageLock = {
version?: unknown;
packages?: Record<string, { version?: unknown } | undefined>;
};
export type PackageVersionMismatch = {
field: "package-lock.json version" | "package-lock.json packages[\"\"].version";
expected: string;
actual: unknown;
};
export function packageVersionMismatches(
packageJson: PackageManifest,
packageLock: PackageLock,
): PackageVersionMismatch[] {
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("package.json version must be a non-empty string");
}
const expected = packageJson.version;
const rootLockVersion = packageLock.version;
const rootPackageVersion = packageLock.packages?.[""]?.version;
const candidates = [
{ field: "package-lock.json version" as const, actual: rootLockVersion },
{ field: "package-lock.json packages[\"\"].version" as const, actual: rootPackageVersion },
];
return candidates
.filter(candidate => candidate.actual !== expected)
.map(candidate => ({ ...candidate, expected }));
}
export function formatPackageVersionMismatch(mismatch: PackageVersionMismatch): string {
return `${mismatch.field} (${String(mismatch.actual)}) does not match package.json version (${mismatch.expected})`;
}
export function packageLockReadErrorMessage(error: unknown): string {
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
if (code === "ENOENT") return "package-lock.json not found; run npm install first";
const message = error instanceof Error ? error.message : String(error);
return `Unable to read package-lock.json; run npm install first. ${message}`;
}
async function readJsonFile<T>(path: string): Promise<T> {
return JSON.parse(await readFile(path, "utf8")) as T;
}
async function main(): Promise<void> {
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
const packageJson = await readJsonFile<PackageManifest>(join(repoRoot, "package.json"));
let packageLock: PackageLock;
try {
packageLock = await readJsonFile<PackageLock>(join(repoRoot, "package-lock.json"));
} catch (error) {
console.error(packageLockReadErrorMessage(error));
process.exit(1);
}
const mismatches = packageVersionMismatches(packageJson, packageLock);
if (mismatches.length > 0) {
console.error("Package integrity check failed:");
for (const mismatch of mismatches) {
console.error(`- ${formatPackageVersionMismatch(mismatch)}`);
}
process.exit(1);
}
console.log(`PACKAGE_INTEGRITY_PASS version=${packageJson.version}`);
}
function isMainModule(): boolean {
const invokedPath = process.argv[1];
return invokedPath ? import.meta.url === pathToFileURL(resolve(invokedPath)).href : false;
}
if (isMainModule()) {
await main();
}
+30 -1
View File
@@ -68,6 +68,11 @@ type MemoryCommandDetail = {
reasonCodes: string[];
attemptedAtIso?: string;
lastReinforcedAtIso?: string;
elapsedMs?: number;
requiredElapsedMs?: number;
sameSession?: boolean;
legacyMissingTimestamp?: boolean;
reinforcementMode?: string;
crossUtcDay?: boolean | "unknown";
producerVersion?: string;
instrumentationVersion?: number;
@@ -116,6 +121,16 @@ function stringDetail(event: EvidenceEventV1, key: string): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function numberDetail(event: EvidenceEventV1, key: string): number | undefined {
const value = event.details?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function booleanDetail(event: EvidenceEventV1, key: string): boolean | undefined {
const value = event.details?.[key];
return typeof value === "boolean" ? value : undefined;
}
function isRejectedOrBlocked(event: EvidenceEventV1): boolean {
return event.outcome === "rejected" || hasReason(event, "reinforcement_window_blocked");
}
@@ -175,6 +190,11 @@ function detailEventJSON(event: EvidenceEventV1): MemoryCommandDetail["events"][
reasonCodes: event.reasonCodes,
attemptedAtIso,
lastReinforcedAtIso,
elapsedMs: numberDetail(event, "elapsedMs"),
requiredElapsedMs: numberDetail(event, "requiredElapsedMs"),
sameSession: booleanDetail(event, "sameSession"),
legacyMissingTimestamp: booleanDetail(event, "legacyMissingTimestamp") === true ? true : undefined,
reinforcementMode: stringDetail(event, "reinforcementMode"),
crossUtcDay: blocked ? isCrossUtcDay(attemptedAtIso, lastReinforcedAtIso) : undefined,
producerVersion: event.producerVersion,
instrumentationVersion: event.instrumentationVersion,
@@ -315,15 +335,24 @@ function formatCrossUtcDay(value: boolean | "unknown" | undefined): string {
return "unknown";
}
function formatBoolean(value: boolean): string {
return value ? "yes" : "no";
}
function formatMemoryCommandDetailEvents(events: MemoryCommandDetail["events"]): string[] {
if (events.length === 0) return [" (none)"];
return events.map(event => {
const ref = event.ref ? ` ref=${event.ref}` : "";
const blockReason = event.blockReason ? ` blockReason=${event.blockReason}` : "";
const reinforcementMode = event.reinforcementMode ? ` reinforcementMode=${event.reinforcementMode}` : "";
const attemptedAt = event.attemptedAtIso ? ` attemptedAt=${event.attemptedAtIso}` : "";
const lastReinforcedAt = event.lastReinforcedAtIso ? ` lastReinforcedAt=${event.lastReinforcedAtIso}` : "";
const elapsedMs = event.elapsedMs !== undefined ? ` elapsedMs=${event.elapsedMs}` : "";
const requiredElapsedMs = event.requiredElapsedMs !== undefined ? ` requiredElapsedMs=${event.requiredElapsedMs}` : "";
const sameSession = event.sameSession !== undefined ? ` sameSession=${formatBoolean(event.sameSession)}` : "";
const legacyMissingTimestamp = event.legacyMissingTimestamp === true ? " legacyMissingTimestamp=yes" : "";
const crossUtcDay = event.crossUtcDay !== undefined ? ` crossUtcDay=${formatCrossUtcDay(event.crossUtcDay)}` : "";
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${attemptedAt}${lastReinforcedAt}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`;
return ` - ${event.createdAt} outcome=${event.outcome}${ref}${blockReason}${reinforcementMode}${attemptedAt}${lastReinforcedAt}${elapsedMs}${requiredElapsedMs}${sameSession}${legacyMissingTimestamp}${crossUtcDay} reasons=${event.reasonCodes.join(",") || "none"}`;
});
}
+1 -1
View File
@@ -3,7 +3,7 @@ import {
RETENTION_TYPE_MAX,
} from "../../../src/retention.ts";
import { TYPES } from "../constants.ts";
import { daysSinceIso, formatStrength } from "../retention-model.ts";
import { formatStrength } from "../retention-model.ts";
import { cleanText, truncate } from "../text.ts";
import type { MemoryInspectionReadModel, RetentionDiagItem } from "../types.ts";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { EvidenceEventV1 } from "../../src/evidence-log.ts";
import type { LongTermType } from "../../src/types.ts";
import { countBy, objectFromCounts, uniqueStrings } from "./text.ts";
import { countBy, objectFromCounts } from "./text.ts";
import { groupEvidenceByMemoryId } from "./evidence-model.ts";
import { loadRejectionRecords } from "./rejections-model.ts";
import { snapshotForOptions } from "./workspace-snapshot.ts";
+34 -233
View File
@@ -5,75 +5,42 @@ import { RETENTION_TYPE_MAX } from "../../src/retention.ts";
import type { LongTermMemoryEntry, LongTermType } from "../../src/types.ts";
import { TYPES } from "./constants.ts";
import { disappearanceRows } from "./inspection-model.ts";
import {
VERSION_ANALYSIS_SAMPLE_THRESHOLD,
VERSION_GROUPS,
buildVersionBuckets,
buildVersionCoverage,
computeVersionedInference,
hasKnownProducerVersion,
hasProducerFields,
producerVersionGroupFor,
} from "./quality-versioning.ts";
import type {
AnswerabilityLevel,
ProducerBearingRecord,
ProducerVersionGroup,
VersionCoverage,
VersionedMechanismDiagnosticQuestion,
VersionedMechanismFacts,
} from "./quality-versioning.ts";
import { hasWorkspaceScope, rejectionQualitySummary, uniqueByCanonicalText } from "./rejections-model.ts";
import { canonicalMemoryText, cleanText, countBy, objectFromCounts, truncate, uniqueStrings, workspaceRootHash } from "./text.ts";
import type { MemoryInspectionReadModel, NormalizedRejection } from "./types.ts";
export type AnswerabilityLevel = "supported" | "partial" | "inventory_only" | "not_instrumented";
export type {
AnswerabilityLevel,
ProducerBearingRecord,
ProducerVersionGroup,
VersionAvailability,
VersionBucketFacts,
VersionCoverage,
VersionedMechanismDiagnosticQuestion,
VersionedMechanismFacts,
VersionedMechanismInference,
VersionSampleAssessment,
} from "./quality-versioning.ts";
export type ProducerVersionGroup = "current" | "previous" | "unknown_unversioned";
export type VersionSampleAssessment =
| "observed"
| "not_observed_but_sample_small"
| "not_observed_with_sufficient_sample"
| "no_current_version_opportunities";
export type VersionAvailability = {
noProducerFields: number;
unknownProducerVersion: number;
emptyProducerVersion: number;
knownProducerVersion: number;
};
export type VersionCoverage = {
totalEvents: number;
currentVersionEvents: number;
previousVersionEvents: number;
unknownVersionEvents: number;
coveragePercent: number;
isTransitional: boolean;
};
export type VersionedMechanismInference = {
status:
| "current_recurrence_detected"
| "pattern_persists_across_versions"
| "no_current_evidence_observed"
| "no_current_evidence_sample_small"
| "no_current_version_opportunities"
| "no_previous_pattern_observed";
message: string;
caveat: "Version grouping is based only on producerVersion strings in evidence";
};
export type VersionBucketFacts<TFacts> = {
group: ProducerVersionGroup;
label: string;
opportunityCount: number;
observedPatternCount: number;
producerVersions: Record<string, number>;
versionAvailability: VersionAvailability;
answerabilityLevel: AnswerabilityLevel;
sampleAssessment: VersionSampleAssessment;
facts: TFacts;
};
export type VersionedMechanismDiagnosticQuestion = {
mechanism: "reinforcement_rule";
group: ProducerVersionGroup;
question: string;
evidence: string[];
};
export type VersionedMechanismFacts<TFacts> = {
currentPackageVersion: string;
opportunityName: string;
sampleThreshold: number;
buckets: Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
inference: VersionedMechanismInference;
diagnosticQuestions?: VersionedMechanismDiagnosticQuestion[];
};
export { hasKnownProducerVersion, producerVersionGroupFor } from "./quality-versioning.ts";
export type RejectionVersionFacts = {
totalRecords: number;
@@ -314,9 +281,6 @@ export type HeuristicFlag = {
const ACTIVE_MEMORY_FULL_TEXT_THRESHOLD = 40;
const REPRESENTATIVE_CANDIDATE_LIMIT = 10;
const RECENT_EVICTION_DAYS = 7;
const VERSION_ANALYSIS_SAMPLE_THRESHOLD = 5;
const VERSION_GROUPS: ProducerVersionGroup[] = ["current", "previous", "unknown_unversioned"];
const VERSION_GROUPING_CAVEAT = "Version grouping is based only on producerVersion strings in evidence" as const;
const KNOWN_MIGRATION_IDS = [
"2026-04-26-p0-cleanup",
@@ -571,9 +535,9 @@ function applyInstrumentedAnswerability(
report.reinforcementRules.currentSignals = uniqueStrings([
...report.reinforcementRules.currentSignals,
"exact block reasons",
"UTC day grouping",
"elapsed-window details when present",
]);
report.reinforcementRules.outputPermission = "Show exact block reasons and day grouping; causal fields exist but human content judgment is still required.";
report.reinforcementRules.outputPermission = "Show exact block reasons and elapsed-window details when present; causal fields exist but human content judgment is still required.";
}
const hasCapacitySnapshots = evictionFacts.recentCapacityRemovalsWithSnapshot > 0
@@ -617,7 +581,7 @@ function buildVersionedSystemMechanismFacts(
currentPackageVersion: string,
generatedAt: string,
): VersionedSystemMechanismFacts {
const versionCoverage = buildVersionCoverage(events, rejections, currentPackageVersion);
const versionCoverage = buildVersionCoverage([...events, ...rejections], currentPackageVersion);
return {
currentPackageVersion,
versionCoverage,
@@ -761,30 +725,6 @@ function buildVersionedEvictionFacts(
};
}
function buildVersionBuckets<TRecord extends ProducerBearingRecord, TFacts>(
records: TRecord[],
currentPackageVersion: string,
summarize: (records: TRecord[]) => { facts: TFacts; opportunityCount: number; observedPatternCount: number },
): Record<ProducerVersionGroup, VersionBucketFacts<TFacts>> {
const grouped = Object.fromEntries(VERSION_GROUPS.map(group => [group, []])) as Record<ProducerVersionGroup, TRecord[]>;
for (const record of records) grouped[producerVersionGroupFor(record, currentPackageVersion)].push(record);
return Object.fromEntries(VERSION_GROUPS.map(group => {
const bucketRecords = grouped[group];
const summary = summarize(bucketRecords);
return [group, {
group,
label: versionGroupLabel(group, currentPackageVersion),
opportunityCount: summary.opportunityCount,
observedPatternCount: summary.observedPatternCount,
producerVersions: producerVersionCounts(bucketRecords),
versionAvailability: buildVersionAvailability(bucketRecords),
answerabilityLevel: group === "current" && summary.opportunityCount > 0 ? "partial" : "inventory_only",
sampleAssessment: sampleAssessmentFor(group, summary.opportunityCount, summary.observedPatternCount, currentPackageVersion),
facts: summary.facts,
} satisfies VersionBucketFacts<TFacts>];
})) as Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
}
function buildEvictionVersionFacts(capacityEvents: EvidenceEventV1[], generatedAt: string): EvictionVersionFacts {
const recentCapacityEvents = capacityEvents.filter(event => isWithinDaysOf(event.createdAt, generatedAt, RECENT_EVICTION_DAYS));
const capacityEventsWithSnapshot = capacityEvents.filter(hasCapacitySnapshot);
@@ -808,145 +748,6 @@ function isReviewableRejectionCandidate(record: NormalizedRejection): boolean {
return label === "architecture_like_rejected_candidate" || label === "ambiguous_rejected_candidate";
}
function producerVersionCounts(records: ProducerBearingRecord[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const record of records) {
if (!hasKnownProducerVersion(record)) continue;
const version = String(record.producerVersion).trim();
counts[version] = (counts[version] ?? 0) + 1;
}
return counts;
}
function versionGroupLabel(group: ProducerVersionGroup, currentPackageVersion: string): string {
if (group === "current") return `current version ${currentPackageVersion}`;
if (group === "previous") return "previous versions";
return "unknown/unversioned";
}
function sampleAssessmentFor(
group: ProducerVersionGroup,
opportunityCount: number,
observedPatternCount: number,
currentPackageVersion: string,
): VersionSampleAssessment {
if (observedPatternCount > 0) return "observed";
if (group === "current" && (!isAssessableCurrentPackageVersion(currentPackageVersion) || opportunityCount === 0)) return "no_current_version_opportunities";
if (opportunityCount < VERSION_ANALYSIS_SAMPLE_THRESHOLD) return "not_observed_but_sample_small";
return "not_observed_with_sufficient_sample";
}
function isAssessableCurrentPackageVersion(currentPackageVersion: string): boolean {
const trimmed = currentPackageVersion.trim();
return trimmed.length > 0 && trimmed !== "unknown";
}
function computeVersionedInference<TFacts>(
mechanism: Omit<VersionedMechanismFacts<TFacts>, "inference">,
text: { observedPattern: string; patternName: string },
): VersionedMechanismInference {
const current = mechanism.buckets.current;
const previous = mechanism.buckets.previous;
const currentFact = `Current version: ${current.observedPatternCount} ${text.observedPattern} in ${current.opportunityCount} ${mechanism.opportunityName}.`;
const previousFact = `Previous versions: ${previous.observedPatternCount} ${text.observedPattern} in ${previous.opportunityCount} ${mechanism.opportunityName}.`;
const unknownUnversioned = mechanism.buckets.unknown_unversioned;
if (!isAssessableCurrentPackageVersion(mechanism.currentPackageVersion) || current.opportunityCount === 0) {
return inference("no_current_version_opportunities", "Current package version is unknown or has no events; cannot assess recurrence.");
}
if (current.observedPatternCount > 0 && previous.observedPatternCount === 0 && unknownUnversioned.observedPatternCount === 0) {
return inference("no_previous_pattern_observed", `${currentFact} No previous pattern observed — this is a new pattern, not a recurrence.`);
}
if (current.observedPatternCount > 0) {
if (previous.observedPatternCount > 0) {
return inference("pattern_persists_across_versions", `${currentFact} ${previousFact} Current recurrence detected — ${text.patternName} observed in current version. Pattern persists across versions.`);
}
// Current has signal, previous has none, but unknown/unversioned has signal
return inference("current_recurrence_detected", `${currentFact} No known previous-version pattern observed, but unknown/unversioned evidence shows ${unknownUnversioned.observedPatternCount} ${text.observedPattern}. Pattern may persist — version grouping cannot confirm or deny.`);
}
if (current.opportunityCount < mechanism.sampleThreshold) {
return inference("no_current_evidence_sample_small", `${currentFact} ${previousFact} No current evidence observed, but current-version opportunity count is ${current.opportunityCount} (<${mechanism.sampleThreshold}); do not infer absence.`);
}
return inference("no_current_evidence_observed", `${currentFact} ${previousFact} No recurrence observed with sufficient current-version sample.`);
}
function inference(status: VersionedMechanismInference["status"], message: string): VersionedMechanismInference {
return { status, message, caveat: VERSION_GROUPING_CAVEAT };
}
function hasProducerFields(record: Pick<EvidenceEventV1, "producerName" | "producerVersion" | "instrumentationVersion"> | Pick<NormalizedRejection, "producerName" | "producerVersion" | "instrumentationVersion">): boolean {
return typeof record.producerName === "string"
&& record.producerName.length > 0
&& typeof record.producerVersion === "string"
&& record.producerVersion.length > 0
&& typeof record.instrumentationVersion === "number";
}
type ProducerBearingRecord = Pick<EvidenceEventV1 | NormalizedRejection, "producerName" | "producerVersion" | "instrumentationVersion">;
export function hasKnownProducerVersion(record: ProducerBearingRecord): boolean {
if (typeof record.producerVersion !== "string") return false;
const producerVersion = record.producerVersion.trim();
return producerVersion.length > 0 && producerVersion !== "unknown";
}
export function producerVersionGroupFor(record: ProducerBearingRecord, currentPackageVersion: string): ProducerVersionGroup {
if (!hasKnownProducerVersion(record)) return "unknown_unversioned";
const producerVersion = String(record.producerVersion).trim();
const currentVersion = currentPackageVersion.trim();
if (currentVersion.length > 0 && currentVersion !== "unknown" && producerVersion === currentVersion) return "current";
return "previous";
}
function buildVersionAvailability(records: ProducerBearingRecord[]): VersionAvailability {
const availability: VersionAvailability = {
noProducerFields: 0,
unknownProducerVersion: 0,
emptyProducerVersion: 0,
knownProducerVersion: 0,
};
for (const record of records) {
const hasAnyProducerField = typeof record.producerName === "string"
|| typeof record.producerVersion === "string"
|| typeof record.instrumentationVersion === "number";
if (!hasAnyProducerField) {
availability.noProducerFields += 1;
continue;
}
if (typeof record.producerVersion !== "string" || record.producerVersion.trim().length === 0) {
availability.emptyProducerVersion += 1;
continue;
}
if (record.producerVersion.trim() === "unknown") {
availability.unknownProducerVersion += 1;
continue;
}
availability.knownProducerVersion += 1;
}
return availability;
}
function buildVersionCoverage(events: EvidenceEventV1[], rejections: NormalizedRejection[], currentPackageVersion: string): VersionCoverage {
const coverage: VersionCoverage = {
totalEvents: events.length + rejections.length,
currentVersionEvents: 0,
previousVersionEvents: 0,
unknownVersionEvents: 0,
coveragePercent: 0,
isTransitional: true,
};
for (const record of [...events, ...rejections]) {
const group = producerVersionGroupFor(record, currentPackageVersion);
if (group === "current") coverage.currentVersionEvents += 1;
if (group === "previous") coverage.previousVersionEvents += 1;
if (group === "unknown_unversioned") coverage.unknownVersionEvents += 1;
}
coverage.coveragePercent = coverage.totalEvents === 0
? 0
: Math.round(((coverage.currentVersionEvents + coverage.previousVersionEvents) / coverage.totalEvents) * 1000) / 10;
coverage.isTransitional = coverage.coveragePercent < 50;
return coverage;
}
function typeCountsFor(entries: LongTermMemoryEntry[]): Record<string, number> {
return Object.fromEntries(TYPES.map(type => [type, entries.filter(entry => entry.type === type).length]));
}
+236
View File
@@ -0,0 +1,236 @@
export type AnswerabilityLevel = "supported" | "partial" | "inventory_only" | "not_instrumented";
export type ProducerVersionGroup = "current" | "previous" | "unknown_unversioned";
export type ProducerBearingRecord = {
producerName?: string;
producerVersion?: string;
instrumentationVersion?: number;
};
export type VersionSampleAssessment =
| "observed"
| "not_observed_but_sample_small"
| "not_observed_with_sufficient_sample"
| "no_current_version_opportunities";
export type VersionAvailability = {
noProducerFields: number;
unknownProducerVersion: number;
emptyProducerVersion: number;
knownProducerVersion: number;
};
export type VersionCoverage = {
totalEvents: number;
currentVersionEvents: number;
previousVersionEvents: number;
unknownVersionEvents: number;
coveragePercent: number;
isTransitional: boolean;
};
export const VERSION_ANALYSIS_SAMPLE_THRESHOLD = 5;
export const VERSION_GROUPS: ProducerVersionGroup[] = ["current", "previous", "unknown_unversioned"];
export const VERSION_GROUPING_CAVEAT = "Version grouping is based only on producerVersion strings in evidence" as const;
export type VersionedMechanismInference = {
status:
| "current_recurrence_detected"
| "pattern_persists_across_versions"
| "no_current_evidence_observed"
| "no_current_evidence_sample_small"
| "no_current_version_opportunities"
| "no_previous_pattern_observed";
message: string;
caveat: typeof VERSION_GROUPING_CAVEAT;
};
export type VersionBucketFacts<TFacts> = {
group: ProducerVersionGroup;
label: string;
opportunityCount: number;
observedPatternCount: number;
producerVersions: Record<string, number>;
versionAvailability: VersionAvailability;
answerabilityLevel: AnswerabilityLevel;
sampleAssessment: VersionSampleAssessment;
facts: TFacts;
};
export type VersionedMechanismDiagnosticQuestion = {
mechanism: "reinforcement_rule";
group: ProducerVersionGroup;
question: string;
evidence: string[];
};
export type VersionedMechanismFacts<TFacts> = {
currentPackageVersion: string;
opportunityName: string;
sampleThreshold: number;
buckets: Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
inference: VersionedMechanismInference;
diagnosticQuestions?: VersionedMechanismDiagnosticQuestion[];
};
export function buildVersionBuckets<TRecord extends ProducerBearingRecord, TFacts>(
records: TRecord[],
currentPackageVersion: string,
summarize: (records: TRecord[]) => { facts: TFacts; opportunityCount: number; observedPatternCount: number },
): Record<ProducerVersionGroup, VersionBucketFacts<TFacts>> {
const grouped = Object.fromEntries(VERSION_GROUPS.map(group => [group, []])) as Record<ProducerVersionGroup, TRecord[]>;
for (const record of records) grouped[producerVersionGroupFor(record, currentPackageVersion)].push(record);
return Object.fromEntries(VERSION_GROUPS.map(group => {
const bucketRecords = grouped[group];
const summary = summarize(bucketRecords);
return [group, {
group,
label: versionGroupLabel(group, currentPackageVersion),
opportunityCount: summary.opportunityCount,
observedPatternCount: summary.observedPatternCount,
producerVersions: producerVersionCounts(bucketRecords),
versionAvailability: buildVersionAvailability(bucketRecords),
answerabilityLevel: group === "current" && summary.opportunityCount > 0 ? "partial" : "inventory_only",
sampleAssessment: sampleAssessmentFor(group, summary.opportunityCount, summary.observedPatternCount, currentPackageVersion),
facts: summary.facts,
} satisfies VersionBucketFacts<TFacts>];
})) as Record<ProducerVersionGroup, VersionBucketFacts<TFacts>>;
}
export function computeVersionedInference<TFacts>(
mechanism: Omit<VersionedMechanismFacts<TFacts>, "inference">,
text: { observedPattern: string; patternName: string },
): VersionedMechanismInference {
const current = mechanism.buckets.current;
const previous = mechanism.buckets.previous;
const currentFact = `Current version: ${current.observedPatternCount} ${text.observedPattern} in ${current.opportunityCount} ${mechanism.opportunityName}.`;
const previousFact = `Previous versions: ${previous.observedPatternCount} ${text.observedPattern} in ${previous.opportunityCount} ${mechanism.opportunityName}.`;
const unknownUnversioned = mechanism.buckets.unknown_unversioned;
if (!isAssessableCurrentPackageVersion(mechanism.currentPackageVersion) || current.opportunityCount === 0) {
return inference("no_current_version_opportunities", "Current package version is unknown or has no events; cannot assess recurrence.");
}
if (current.observedPatternCount > 0 && previous.observedPatternCount === 0 && unknownUnversioned.observedPatternCount === 0) {
return inference("no_previous_pattern_observed", `${currentFact} No previous pattern observed — this is a new pattern, not a recurrence.`);
}
if (current.observedPatternCount > 0) {
if (previous.observedPatternCount > 0) {
return inference("pattern_persists_across_versions", `${currentFact} ${previousFact} Current recurrence detected — ${text.patternName} observed in current version. Pattern persists across versions.`);
}
// Current has signal, previous has none, but unknown/unversioned has signal
return inference("current_recurrence_detected", `${currentFact} No known previous-version pattern observed, but unknown/unversioned evidence shows ${unknownUnversioned.observedPatternCount} ${text.observedPattern}. Pattern may persist — version grouping cannot confirm or deny.`);
}
if (current.opportunityCount < mechanism.sampleThreshold) {
return inference("no_current_evidence_sample_small", `${currentFact} ${previousFact} No current evidence observed, but current-version opportunity count is ${current.opportunityCount} (<${mechanism.sampleThreshold}); do not infer absence.`);
}
return inference("no_current_evidence_observed", `${currentFact} ${previousFact} No recurrence observed with sufficient current-version sample.`);
}
export function hasProducerFields(record: ProducerBearingRecord): boolean {
return typeof record.producerName === "string"
&& record.producerName.length > 0
&& typeof record.producerVersion === "string"
&& record.producerVersion.length > 0
&& typeof record.instrumentationVersion === "number";
}
export function hasKnownProducerVersion(record: ProducerBearingRecord): boolean {
if (typeof record.producerVersion !== "string") return false;
const producerVersion = record.producerVersion.trim();
return producerVersion.length > 0 && producerVersion !== "unknown";
}
export function producerVersionGroupFor(record: ProducerBearingRecord, currentPackageVersion: string): ProducerVersionGroup {
if (!hasKnownProducerVersion(record)) return "unknown_unversioned";
const producerVersion = String(record.producerVersion).trim();
const currentVersion = currentPackageVersion.trim();
if (currentVersion.length > 0 && currentVersion !== "unknown" && producerVersion === currentVersion) return "current";
return "previous";
}
export function buildVersionCoverage(records: ProducerBearingRecord[], currentPackageVersion: string): VersionCoverage {
const coverage: VersionCoverage = {
totalEvents: records.length,
currentVersionEvents: 0,
previousVersionEvents: 0,
unknownVersionEvents: 0,
coveragePercent: 0,
isTransitional: true,
};
for (const record of records) {
const group = producerVersionGroupFor(record, currentPackageVersion);
if (group === "current") coverage.currentVersionEvents += 1;
if (group === "previous") coverage.previousVersionEvents += 1;
if (group === "unknown_unversioned") coverage.unknownVersionEvents += 1;
}
coverage.coveragePercent = coverage.totalEvents === 0
? 0
: Math.round(((coverage.currentVersionEvents + coverage.previousVersionEvents) / coverage.totalEvents) * 1000) / 10;
coverage.isTransitional = coverage.coveragePercent < 50;
return coverage;
}
function producerVersionCounts(records: ProducerBearingRecord[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const record of records) {
if (!hasKnownProducerVersion(record)) continue;
const version = String(record.producerVersion).trim();
counts[version] = (counts[version] ?? 0) + 1;
}
return counts;
}
function versionGroupLabel(group: ProducerVersionGroup, currentPackageVersion: string): string {
if (group === "current") return `current version ${currentPackageVersion}`;
if (group === "previous") return "previous versions";
return "unknown/unversioned";
}
function sampleAssessmentFor(
group: ProducerVersionGroup,
opportunityCount: number,
observedPatternCount: number,
currentPackageVersion: string,
): VersionSampleAssessment {
if (observedPatternCount > 0) return "observed";
if (group === "current" && (!isAssessableCurrentPackageVersion(currentPackageVersion) || opportunityCount === 0)) return "no_current_version_opportunities";
if (opportunityCount < VERSION_ANALYSIS_SAMPLE_THRESHOLD) return "not_observed_but_sample_small";
return "not_observed_with_sufficient_sample";
}
function isAssessableCurrentPackageVersion(currentPackageVersion: string): boolean {
const trimmed = currentPackageVersion.trim();
return trimmed.length > 0 && trimmed !== "unknown";
}
function inference(status: VersionedMechanismInference["status"], message: string): VersionedMechanismInference {
return { status, message, caveat: VERSION_GROUPING_CAVEAT };
}
function buildVersionAvailability(records: ProducerBearingRecord[]): VersionAvailability {
const availability: VersionAvailability = {
noProducerFields: 0,
unknownProducerVersion: 0,
emptyProducerVersion: 0,
knownProducerVersion: 0,
};
for (const record of records) {
const hasAnyProducerField = typeof record.producerName === "string"
|| typeof record.producerVersion === "string"
|| typeof record.instrumentationVersion === "number";
if (!hasAnyProducerField) {
availability.noProducerFields += 1;
continue;
}
if (typeof record.producerVersion !== "string" || record.producerVersion.trim().length === 0) {
availability.emptyProducerVersion += 1;
continue;
}
if (record.producerVersion.trim() === "unknown") {
availability.unknownProducerVersion += 1;
continue;
}
availability.knownProducerVersion += 1;
}
return availability;
}
+6
View File
@@ -284,6 +284,9 @@ function buildEvidenceEvent(
}
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
// Evidence logs are JSONL append streams, not JSON store read-modify-write
// documents. Appends intentionally use appendFile so independent evidence
// writers do not need to share the JSON store lock path.
try {
await mkdir(dirname(path), { recursive: true });
await appendFile(path, `${line}\n`, "utf8");
@@ -294,6 +297,9 @@ async function safeAppendEvidenceLine(path: string, line: string): Promise<void>
}
async function maybePruneEvidenceLog(path: string): Promise<void> {
// Bounded pruning is a separate best-effort compaction of the append-only log.
// It rewrites the JSONL file only at configured append intervals and never
// routes through updateJSON because evidence is not a single JSON document.
const nextCount = (appendCounts.get(path) ?? 0) + 1;
appendCounts.set(path, nextCount);
if (nextCount % EVIDENCE_LOG_LIMITS.pruneEveryAppendCount !== 0) return;
-12
View File
@@ -397,18 +397,6 @@ function evaluateWorkspaceMemoryCandidate(
return { accepted: true, reasons: ["quality_gate_passed"] };
}
function shouldAcceptWorkspaceMemoryCandidate(
entry: {
type: LongTermType;
text: string;
},
options: {
fromMemoryTrigger?: boolean;
} & WorkspaceMemoryCandidateParseOptions = {},
): boolean {
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
}
function commandAttemptReason(line: string): string {
const normalized = line.replace(/^\s*-\s*/, "").trim();
const reinforceMatch = normalized.match(/^REINFORCE\s+(.+)$/i);
+1 -1
View File
@@ -6,7 +6,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
let cachedVersion: string | undefined;
const MEMORY_PRODUCER_NAME = "opencode-working-memory";
const MEMORY_INSTRUMENTATION_VERSION = 2;
const MEMORY_INSTRUMENTATION_VERSION = 3;
function producerVersion(): string {
if (cachedVersion) return cachedVersion;
+14
View File
@@ -0,0 +1,14 @@
import type { LongTermType } from "./types.ts";
// Current workspace-memory display/render order. This is intentionally a narrow
// shared constant, not a broader memory-kind policy registry.
export const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermType[];
export function emptyMemoryTypeGroups<T>(): Record<LongTermType, T[]> {
return {
feedback: [],
project: [],
decision: [],
reference: [],
};
}
+2 -2
View File
@@ -6,6 +6,7 @@ import { redactCredentials } from "./redaction.ts";
import type { LongTermMemoryEntry, PendingMemoryJournalStore, SessionState, WorkspaceMemoryStore } from "./types.ts";
import { LONG_TERM_LIMITS } from "./types.ts";
import { accountWorkspaceMemoryCompactionRefs, accountWorkspaceMemoryRender } from "./workspace-memory.ts";
import { MEMORY_TYPE_ORDER, emptyMemoryTypeGroups } from "./memory-kind-policy.ts";
export type MemoryVisibilityCommand = "status" | "list" | "help";
@@ -33,7 +34,6 @@ export type MemoryListModel = {
};
const MAX_PREVIEW_CHARS = 120;
const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const satisfies readonly LongTermMemoryEntry["type"][];
function safePreview(text: string | undefined, maxChars = MAX_PREVIEW_CHARS): string {
const clean = redactCredentials(text ?? "").replace(/\s+/g, " ").trim();
@@ -211,7 +211,7 @@ export function formatMemoryStatus(model: MemoryStatusModel): string {
}
function emptyMemoryListGroups(): MemoryListModel["groups"] {
return { feedback: [], project: [], decision: [], reference: [] };
return emptyMemoryTypeGroups<MemoryListItem>();
}
export async function getMemoryList(root: string): Promise<MemoryListModel> {
+137 -53
View File
@@ -3,21 +3,24 @@
*
* Architecture:
* - Layer 1: Stable Workspace Memory (frozen per session cache epoch, refreshed at compaction)
* - Layer 2: Hot Session State (active files, open errors, recent decisions, pending memories)
* - Layer 2: Frozen Hot Session State (active files, open errors, recent decisions, pending memories)
* - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction)
*
* Cache Epoch Model:
* - Each session creates a frozen workspace memory snapshot on first transform.
* - Normal turns reuse the exact rendered string (system[1] remains stable).
* - Compaction starts a new cache epoch: pending memories are promoted, the cache is cleared,
* and the next transform re-renders workspace memory.
* - Each session creates frozen workspace memory and hot session snapshots on first transform.
* - Normal turns reuse the exact rendered strings (pre-history system prompts remain stable).
* - Normal tool/user churn updates session storage but does not mutate pre-history prompts
* until compaction, session restart, or process restart starts a new epoch; conversation
* and tool history are the source of truth for newer events after epoch start.
* - Compaction starts a new cache epoch: pending memories are promoted, caches are cleared,
* and the next transform re-renders workspace memory and hot session state.
* - Explicit memory ("remember X") goes to SessionState.pendingMemories + durable journal,
* visible in ephemeral system[2+] for the current epoch, promoted to system[1] after compaction.
* visible in the hot snapshot only if processed before epoch creation, promoted after compaction.
*
* This plugin:
* - Caches frozen workspace memory per sessionID
* - Caches frozen workspace memory and hot session state per sessionID epoch
* - Processes explicit memory from latest user text once per message id
* - Injects frozen workspace memory and dynamic hot session state into system prompt
* - Injects frozen workspace memory and frozen hot session state into system prompt
* - Updates session state after tool execution
* - Augments compaction context with numbered memory refs, todos, and instruction
* - Parses compaction summaries for memory candidates and merges them
@@ -37,14 +40,13 @@ import {
import { assessMemoryQuality } from "./memory-quality.ts";
import {
loadWorkspaceMemory,
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
accountWorkspaceMemoryRender,
accountWorkspaceMemoryCompactionRefs,
workspaceMemoryExactKey,
workspaceMemoryIdentityKey,
} from "./workspace-memory.ts";
import { tryReinforceMemory } from "./retention.ts";
import { REINFORCEMENT_MAX_COUNT, tryReinforceMemory, type ReinforcementDecision } from "./retention.ts";
import {
appendPendingMemories,
clearPendingMemories,
@@ -259,6 +261,18 @@ export const MemoryV2Plugin: Plugin = async (input) => {
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
renderedPrompt: string;
loadedAt: number;
lastAccessedAt: number;
}
>();
// Cache for frozen hot session state per session epoch.
// Lifecycle is unified with frozenWorkspaceMemoryCache; do not clear independently.
const frozenHotSessionStateCache = new Map<
string,
{
renderedPrompt: string;
loadedAt: number;
lastAccessedAt: number;
}
>();
@@ -311,6 +325,21 @@ export const MemoryV2Plugin: Plugin = async (input) => {
};
}
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
return {
attemptedAtMs: decision.attemptedAt,
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
...(decision.lastReinforcedAt !== undefined ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
requiredElapsedMs: decision.requiredElapsedMs,
sameSession: decision.sameSession,
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
};
}
function replacementMemoryId(): string {
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
@@ -434,26 +463,28 @@ export const MemoryV2Plugin: Plugin = async (input) => {
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], {
memoryId: refSnapshot.memoryId,
blockReason: decision.blockReason,
attemptedAtMs: now,
attemptedAtIso: new Date(now).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...reinforcementDecisionTimingDetails(decision),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
}));
continue;
}
const reinforced = decision.memory;
workspaceMemory.entries[targetIndex] = reinforced;
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
const reasonCodes = ["numbered_ref_reinforce", "reinforcement_window_allowed"];
if (decision.reinforcementMode === "refresh_only") {
reasonCodes.push("reinforcement_saturation_refresh");
}
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", reasonCodes, {
memoryId: refSnapshot.memoryId,
reinforcementOutcome: "reinforced",
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
reinforcementMode: decision.reinforcementMode,
...reinforcementDecisionTimingDetails(decision),
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
reinforcementCount: decision.newReinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
}));
continue;
}
@@ -523,18 +554,39 @@ export const MemoryV2Plugin: Plugin = async (input) => {
})));
}
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
function clearFrozenPromptEpoch(sessionID: string): void {
frozenWorkspaceMemoryCache.delete(sessionID);
frozenHotSessionStateCache.delete(sessionID);
}
function pruneFrozenPromptEpochCaches(): void {
const lastAccessedAtBySession = new Map<string, number>();
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
frozenWorkspaceMemoryCache.delete(sessionID);
}
lastAccessedAtBySession.set(
sessionID,
Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt),
);
}
for (const [sessionID, cached] of frozenHotSessionStateCache) {
lastAccessedAtBySession.set(
sessionID,
Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt),
);
}
while (frozenWorkspaceMemoryCache.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
const oldest = [...frozenWorkspaceMemoryCache.entries()]
.sort((a, b) => a[1].loadedAt - b[1].loadedAt)[0]?.[0];
if (!oldest) break;
frozenWorkspaceMemoryCache.delete(oldest);
const sorted = [...lastAccessedAtBySession.entries()].sort((a, b) => a[1] - b[1]);
while (lastAccessedAtBySession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
const [oldestSessionID] = sorted.shift() ?? [];
if (!oldestSessionID) break;
lastAccessedAtBySession.delete(oldestSessionID);
clearFrozenPromptEpoch(oldestSessionID);
}
for (const sessionID of frozenWorkspaceMemoryCache.keys()) {
if (!lastAccessedAtBySession.has(sessionID)) frozenWorkspaceMemoryCache.delete(sessionID);
}
for (const sessionID of frozenHotSessionStateCache.keys()) {
if (!lastAccessedAtBySession.has(sessionID)) frozenHotSessionStateCache.delete(sessionID);
}
}
@@ -750,7 +802,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
});
return state;
});
clearFrozenWorkspaceMemoryCache(sessionID);
clearFrozenPromptEpoch(sessionID);
}
if (accounting.clearableKeys.size > 0) {
@@ -795,13 +847,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
renderedPrompt: string;
}> {
const now = Date.now();
pruneFrozenWorkspaceMemoryCache(now);
const cached = frozenWorkspaceMemoryCache.get(sessionID);
// Cache is valid for the current session cache epoch.
// It is intentionally invalidated after compaction so promoted memories
// become visible in the next compacted context (new epoch starts).
if (cached) {
cached.lastAccessedAt = now;
return { store: cached.store, renderedPrompt: cached.renderedPrompt };
}
@@ -812,16 +864,42 @@ export const MemoryV2Plugin: Plugin = async (input) => {
...event,
sessionHash: sessionID,
})));
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
pruneFrozenWorkspaceMemoryCache(now);
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now, lastAccessedAt: now });
pruneFrozenPromptEpochCaches();
return { store, renderedPrompt };
}
/**
* Clear frozen workspace memory cache (e.g., after compaction).
* Get frozen hot session state snapshot for a session.
* Loads and renders from disk once per prompt epoch, then reuses the exact rendered string.
*/
function clearFrozenWorkspaceMemoryCache(sessionID: string): void {
frozenWorkspaceMemoryCache.delete(sessionID);
async function getFrozenHotSessionStateSnapshot(
root: string,
sessionID: string,
): Promise<{ renderedPrompt: string }> {
const now = Date.now();
const cached = frozenHotSessionStateCache.get(sessionID);
if (cached) {
cached.lastAccessedAt = now;
return { renderedPrompt: cached.renderedPrompt };
}
const sessionState = await loadSessionState(root, sessionID);
const renderedPrompt = renderHotSessionState(sessionState, root);
frozenHotSessionStateCache.set(sessionID, { renderedPrompt, loadedAt: now, lastAccessedAt: now });
pruneFrozenPromptEpochCaches();
return { renderedPrompt };
}
async function promoteUnownedBacklogForEpochSnapshot(sessionID: string): Promise<void> {
if (frozenWorkspaceMemoryCache.has(sessionID) || frozenHotSessionStateCache.has(sessionID)) return;
if (!await hasPendingJournalEntries(directory)) return;
try {
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
} catch (error) {
await warnMemoryHook("chat.system.transform.promote_unowned", error, directory);
}
}
function sessionIDFromEventProperties(properties: unknown): string | undefined {
@@ -837,13 +915,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
}
return {
// Inject workspace memory and hot session state into system prompt
// Inject frozen workspace memory and frozen hot session state into system prompt
"experimental.chat.system.transform": async (hookInput, output) => {
const { sessionID } = hookInput;
if (!sessionID) return;
try {
pruneFrozenWorkspaceMemoryCache();
pruneFrozenPromptEpochCaches();
pruneProcessedUserMessagesCache();
// Sub-agents are short-lived - skip memory system
@@ -853,28 +931,33 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// sub-agent guard so child sessions never append to the parent journal.
await processLatestUserMessage(sessionID);
// Before first snapshot in this session, promote durable unowned backlog from
// prior sessions. Current-turn owned explicit memory remains pending and only
// appears in hot state for this transform.
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
// Before first snapshot in this session, best-effort promote durable
// unowned backlog from prior sessions. Current-turn owned explicit memory
// remains pending and appears in hot state only if the epoch snapshot is new.
await promoteUnownedBacklogForEpochSnapshot(sessionID);
let workspaceSnapshot: Awaited<ReturnType<typeof getFrozenWorkspaceMemorySnapshot>> | undefined;
try {
workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
} catch (error) {
await warnMemoryHook("chat.system.transform.workspace_snapshot", error, directory);
}
// Get frozen workspace memory snapshot (loaded and rendered once per session)
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
// Get current hot session state
const sessionState = await loadSessionState(directory, sessionID);
let hotSnapshot: Awaited<ReturnType<typeof getFrozenHotSessionStateSnapshot>> | undefined;
try {
hotSnapshot = await getFrozenHotSessionStateSnapshot(directory, sessionID);
} catch (error) {
await warnMemoryHook("chat.system.transform.hot_snapshot", error, directory);
}
// Inject frozen workspace memory snapshot
if (workspaceSnapshot.renderedPrompt) {
if (workspaceSnapshot?.renderedPrompt) {
output.system.push(workspaceSnapshot.renderedPrompt);
}
// Render and inject hot session state
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
output.system.push(hotPrompt);
// Inject frozen hot session state snapshot
if (hotSnapshot?.renderedPrompt) {
output.system.push(hotSnapshot.renderedPrompt);
}
} catch (error) {
await warnMemoryHook("chat.system.transform", error, directory);
@@ -1044,6 +1127,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
} finally {
await clearCompactionMemoryRefs(sessionID);
clearFrozenPromptEpoch(sessionID);
}
} catch (error) {
// Keep pending memories in session/journal for retry on next event/session.
@@ -1061,7 +1145,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
promoted = true;
if (promoted) {
frozenWorkspaceMemoryCache.delete(sessionID);
clearFrozenPromptEpoch(sessionID);
processedUserMessages.delete(sessionID);
sessionParentCache.delete(sessionID);
}
+69 -33
View File
@@ -1,19 +1,53 @@
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
export type ReinforcementBlockReason = "same_session" | "same_utc_day" | "min_interval" | "max_count";
export type ReinforcementBlockReason =
| "min_elapsed_window"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "same_session"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "same_utc_day"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "min_interval"
/** @deprecated Historical diagnostic literal; no longer produced by new policy. */
| "max_count";
export type ReinforcementMode = "increment" | "refresh_only";
type ReinforcementDecisionMetadata = {
attemptedAt: number;
lastReinforcedAt?: number;
elapsedMs?: number;
requiredElapsedMs: number;
sameSession: boolean;
legacyMissingTimestamp?: boolean;
};
export type ReinforcementDecision =
| { outcome: "reinforced"; memory: LongTermMemoryEntry; previousReinforcementCount: number; newReinforcementCount: number }
| { outcome: "blocked"; memory: LongTermMemoryEntry; blockReason: ReinforcementBlockReason; lastReinforcedAt?: number; reinforcementCount: number; maxReinforcementCount: number; minIntervalMs: number };
| ({
outcome: "reinforced";
memory: LongTermMemoryEntry;
previousReinforcementCount: number;
newReinforcementCount: number;
reinforcementMode: ReinforcementMode;
} & ReinforcementDecisionMetadata)
| ({
outcome: "blocked";
memory: LongTermMemoryEntry;
blockReason: ReinforcementBlockReason;
reinforcementCount: number;
maxReinforcementCount: number;
} & ReinforcementDecisionMetadata);
// Retention decay model constants (v1.5)
export const BASE_HALF_LIFE_DAYS = 45;
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
export const REINFORCEMENT_MAX_COUNT = 6;
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const DAY_MS = 24 * 60 * 60 * 1000;
export const REINFORCEMENT_MIN_ELAPSED_MS = 7 * DAY_MS;
/** @deprecated Compatibility constant; new policy uses REINFORCEMENT_MIN_ELAPSED_MS. */
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000;
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
export const DORMANT_DECAY_MULTIPLIER = 0.25;
export const DAY_MS = 24 * 60 * 60 * 1000;
export const TYPE_FACTOR = {
reference: 1.0,
@@ -114,42 +148,38 @@ export function calculateEffectiveAgeDays(
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
}
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
const d1 = new Date(ts1);
const d2 = new Date(ts2);
return d1.getUTCFullYear() === d2.getUTCFullYear()
&& d1.getUTCMonth() === d2.getUTCMonth()
&& d1.getUTCDate() === d2.getUTCDate();
}
export function tryReinforceMemory(
memory: LongTermMemoryEntry,
sessionId: string,
now: number,
): ReinforcementDecision {
const count = memory.reinforcementCount ?? 0;
const lastAt = memory.lastReinforcedAt ?? 0;
const lastAt = validLastReinforcedAt(memory.lastReinforcedAt);
const lastSession = memory.lastReinforcedSessionID;
const sameSession = lastSession === sessionId;
const legacyMissingTimestamp = count > 0 && lastAt === undefined;
const metadata: ReinforcementDecisionMetadata = {
attemptedAt: now,
...(lastAt !== undefined ? {
lastReinforcedAt: lastAt,
elapsedMs: now - lastAt,
} : {}),
requiredElapsedMs: REINFORCEMENT_MIN_ELAPSED_MS,
sameSession,
...(legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
};
if (lastSession === sessionId) {
return blockedDecision(memory, "same_session", count, lastAt);
}
if (count >= REINFORCEMENT_MAX_COUNT) {
return blockedDecision(memory, "max_count", count, lastAt);
}
if (lastAt > 0 && now < lastAt + REINFORCEMENT_MIN_INTERVAL_MS) {
return blockedDecision(memory, "min_interval", count, lastAt);
}
if (lastAt > 0 && isSameUTCCalendarDay(lastAt, now)) {
return blockedDecision(memory, "same_utc_day", count, lastAt);
if (lastAt !== undefined && now - lastAt < REINFORCEMENT_MIN_ELAPSED_MS) {
return blockedDecision(memory, "min_elapsed_window", count, metadata);
}
const reinforcementMode: ReinforcementMode = count >= REINFORCEMENT_MAX_COUNT
? "refresh_only"
: "increment";
const newReinforcementCount = reinforcementMode === "refresh_only" ? count : count + 1;
const reinforced: LongTermMemoryEntry = {
...memory,
reinforcementCount: count + 1,
reinforcementCount: newReinforcementCount,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
retentionClock: now,
@@ -158,23 +188,29 @@ export function tryReinforceMemory(
outcome: "reinforced",
memory: reinforced,
previousReinforcementCount: count,
newReinforcementCount: count + 1,
newReinforcementCount,
reinforcementMode,
...metadata,
};
}
function validLastReinforcedAt(value: unknown): number | undefined {
if (typeof value !== "number") return undefined;
return Number.isFinite(value) && value > 0 ? value : undefined;
}
function blockedDecision(
memory: LongTermMemoryEntry,
blockReason: ReinforcementBlockReason,
reinforcementCount: number,
lastReinforcedAt: number,
metadata: ReinforcementDecisionMetadata,
): ReinforcementDecision {
return {
outcome: "blocked",
memory,
blockReason,
...(lastReinforcedAt > 0 ? { lastReinforcedAt } : {}),
reinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
minIntervalMs: REINFORCEMENT_MIN_INTERVAL_MS,
...metadata,
};
}
+1 -1
View File
@@ -258,7 +258,7 @@ type HotStateRenderSection = {
items: HotStateRenderItem[];
};
const HOT_STATE_PREFIX = "Hot session state (current session):";
const HOT_STATE_PREFIX = "Hot session state snapshot (epoch start; conversation history may be newer):";
export function accountHotSessionStateRender(state: SessionState, workspaceRoot: string): HotSessionStateRenderAccounting {
const maxRenderedChars = HOT_STATE_LIMITS.maxRenderedChars;
+6
View File
@@ -164,6 +164,9 @@ async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
}
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
// Full-state overwrite primitive: callers must already own the complete next
// JSON document. Do not use this for read-modify-write updates that must
// preserve concurrent changes; use updateJSON for that contract instead.
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
@@ -175,6 +178,9 @@ export async function updateJSON<T>(
fallback: () => T,
updater: (current: T) => T | Promise<T>,
): Promise<T> {
// Locked read-modify-write path: serializes in-process callers and uses a
// filesystem lock for cross-process callers before reading, updating, and
// atomically replacing the JSON document.
const previous = fileLocks.get(path) ?? Promise.resolve();
let release: () => void = () => {};
const currentLock = new Promise<void>(resolve => {
+1 -2
View File
@@ -5,6 +5,7 @@ import {
renderMemoryCommand,
type MemoryVisibilityCommand,
} from "./memory-visibility.ts";
import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts";
type DialogContext = {
clear?: () => void;
@@ -245,8 +246,6 @@ function showMemoryHelp(api: TuiPluginApi): void {
showAlertFromMarkdown(api, formatMemoryHelp(), "Memory help", "medium");
}
const MEMORY_TYPE_ORDER = ["feedback", "project", "decision", "reference"] as const;
async function showMemoryList(api: TuiPluginApi): Promise<void> {
const dialogApi = getDialogApi(api);
if (!dialogApi) return;
+32 -23
View File
@@ -7,6 +7,7 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
import { redactCredentials } from "./redaction.ts";
import {
REINFORCEMENT_MAX_COUNT,
RETENTION_TYPE_MAX,
calculateRetentionStrength,
tryReinforceMemory,
@@ -14,6 +15,7 @@ import {
} from "./retention.ts";
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
import { appendEvidenceEvents } from "./evidence-log.ts";
import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts";
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
const MIN_ENVELOPE_LENGTH = 80;
@@ -768,7 +770,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
now,
);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
absorbed.push(consolidationEvent(dropped, reason, reinforced));
@@ -797,7 +799,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
now,
);
const reinforced = decision.memory;
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason, now);
const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason);
if (reinforcedEvent) evidence.push(reinforcedEvent);
if (reason === "superseded_existing") {
@@ -841,7 +843,6 @@ function reinforcementEvidence(
dropped: LongTermMemoryEntry,
decision: ReinforcementDecision,
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
attemptedAt: number,
): EvidenceEventInput | undefined {
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
if (decision.outcome === "blocked") {
@@ -859,21 +860,19 @@ function reinforcementEvidence(
memoryId: retained.id,
droppedMemoryId: dropped.id,
blockReason: decision.blockReason,
attemptedAtMs: attemptedAt,
attemptedAtIso: new Date(attemptedAt).toISOString(),
...(decision.lastReinforcedAt ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...reinforcementDecisionTimingDetails(decision),
reinforcementCount: decision.reinforcementCount,
maxReinforcementCount: decision.maxReinforcementCount,
minIntervalMs: decision.minIntervalMs,
},
textPreview: retained.text,
};
}
const reinforced = decision.memory;
const reasonCodes = [duplicateReason, "reinforcement_window_allowed"];
if (decision.reinforcementMode === "refresh_only") {
reasonCodes.push("reinforcement_saturation_refresh");
}
return {
type: "memory_reinforced",
phase: "reinforcement",
@@ -883,18 +882,37 @@ function reinforcementEvidence(
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
],
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
reasonCodes,
details: {
memoryId: reinforced.id,
droppedMemoryId: dropped.id,
reinforcementOutcome: "reinforced",
reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced",
reinforcementMode: decision.reinforcementMode,
...reinforcementDecisionTimingDetails(decision),
previousReinforcementCount: decision.previousReinforcementCount,
newReinforcementCount: decision.newReinforcementCount,
reinforcementCount: decision.newReinforcementCount,
maxReinforcementCount: REINFORCEMENT_MAX_COUNT,
},
textPreview: reinforced.text,
};
}
function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] {
return {
attemptedAtMs: decision.attemptedAt,
attemptedAtIso: new Date(decision.attemptedAt).toISOString(),
...(decision.lastReinforcedAt !== undefined ? {
lastReinforcedAtMs: decision.lastReinforcedAt,
lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(),
} : {}),
...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}),
requiredElapsedMs: decision.requiredElapsedMs,
sameSession: decision.sameSession,
...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}),
};
}
function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string {
return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe";
}
@@ -916,15 +934,6 @@ function compareLongTermMemoryForRetention(
return a.id.localeCompare(b.id);
}
function wouldFit(
lines: string[],
nextLine: string,
closingLine: string,
maxChars: number
): boolean {
return [...lines, nextLine, closingLine].join("\n").length <= maxChars;
}
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
return accountWorkspaceMemoryRender(store).prompt;
}
@@ -976,7 +985,7 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works
];
const rendered: LongTermMemoryEntry[] = [];
for (const type of ["feedback", "project", "decision", "reference"] as const) {
for (const type of MEMORY_TYPE_ORDER) {
const items = active.filter(entry => entry.type === type);
if (items.length === 0) continue;
@@ -1020,7 +1029,7 @@ export function accountWorkspaceMemoryCompactionRefs(store: WorkspaceMemoryStore
const refs: CompactionMemoryRef[] = [];
const capturedAt = Date.now();
for (const type of ["feedback", "project", "decision", "reference"] as const) {
for (const type of MEMORY_TYPE_ORDER) {
const items = active.filter(entry => entry.type === type);
if (items.length === 0) continue;
+24
View File
@@ -109,6 +109,30 @@ test("appendEvidenceEvent redacts text previews before writing", async () => {
}
});
test("concurrent evidence appends preserve independent JSONL records", async () => {
const root = await tempRoot();
try {
const count = 40;
await Promise.all(Array.from({ length: count }, (_, index) =>
appendEvidenceEvent(root, eventInput({ memory: { memoryId: `concurrent-${index}` } }))
));
const raw = await readLog(root);
const lines = raw.trim().split("\n");
const events = await queryEvidenceEvents(root);
const memoryIds = new Set(events.map(event => event.memory?.memoryId));
assert.equal(lines.length, count);
assert.equal(events.length, count);
for (let index = 0; index < count; index += 1) {
assert.equal(memoryIds.has(`concurrent-${index}`), true);
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("queryEvidenceEvents filters by type outcome and memory id", async () => {
const root = await tempRoot();
try {
+43 -1
View File
@@ -373,7 +373,7 @@ test("new evidence events include producer metadata and historical events remain
assert.equal(stored.producerName, "opencode-working-memory");
assert.equal(stored.producerVersion, packageJson.version);
assert.equal(stored.instrumentationVersion, 2);
assert.equal(stored.instrumentationVersion, 3);
const historical = event("evt-historical-no-producer", {
type: "render_selected",
@@ -747,6 +747,48 @@ test("quality surfaces same-session cross-day reinforcement as design diagnostic
assert.doesNotMatch(output, /definitely a bug/i);
});
test("quality treats elapsed-window and refresh-only reinforcement as new evidence without old same-session question", () => {
const report = buildQualityReviewBoard(inspectionModel([], [
reinforcementAttempt("evt-elapsed-window-block", packageJson.version, true, {
instrumentationVersion: 3,
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_min_elapsed_window"],
details: {
blockReason: "min_elapsed_window",
elapsedMs: 601_200_000,
requiredElapsedMs: 604_800_000,
sameSession: true,
attemptedAtIso: "2026-05-13T07:48:21.361Z",
lastReinforcedAtIso: "2026-05-06T08:48:21.361Z",
},
}),
reinforcementAttempt("evt-refresh-only", packageJson.version, false, {
instrumentationVersion: 3,
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed", "reinforcement_saturation_refresh"],
details: {
reinforcementMode: "refresh_only",
elapsedMs: 604_800_000,
requiredElapsedMs: 604_800_000,
sameSession: true,
},
}),
...Array.from({ length: 4 }, (_, index) => reinforcementAttempt(`evt-elapsed-ok-${index}`, packageJson.version, false, { instrumentationVersion: 3 })),
]), {}, generatedAt);
const facts = report.facts.systemMechanisms.reinforcementRules;
const versionedFacts = report.facts.systemMechanisms.versionedFacts?.reinforcementRules.buckets.current.facts;
const output = formatQualityReviewBoard(report, {});
assert.equal(facts.blocksByExactReason.min_elapsed_window, 1);
assert.equal(facts.blocksByExactReason.same_session, undefined);
assert.equal(facts.reinforcedEvents, 5);
assert.equal(facts.rejectedOrBlockedEvents, 1);
assert.equal(versionedFacts?.blocksByExactReason.min_elapsed_window, 1);
assert.equal(versionedFacts?.reinforcedEvents, 5);
assert.equal(versionedFacts?.rejectedOrBlockedEvents, 1);
assert.equal(report.facts.systemMechanisms.versionedFacts?.reinforcementRules.diagnosticQuestions, undefined);
assert.doesNotMatch(output, /Should same_session reinforcement blocking apply across UTC days/i);
assert.doesNotMatch(output, /same_session=1/);
});
test("versioned quality warns when current reinforcement sample is small", () => {
const events = [
reinforcementAttempt("evt-prev-small-block", "1.6.0", true),
+118 -9
View File
@@ -181,6 +181,85 @@ async function setupMemoryCommandDetailFixture(root: string): Promise<void> {
sessionHash: "command-session-two",
messageHash: "command-message-three",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "rejected",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_blocked", "reinforcement_block_min_elapsed_window"],
details: {
ref: "M3",
blockReason: "min_elapsed_window",
attemptedAtIso: "2026-05-20T00:05:00.000Z",
lastReinforcedAtIso: "2026-05-13T00:05:00.001Z",
elapsedMs: 604_799_999,
requiredElapsedMs: 604_800_000,
sameSession: true,
sessionID: "raw-session-secret",
lastReinforcedSessionID: "raw-last-session-secret",
},
sessionHash: "command-session-three",
messageHash: "command-message-four",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"],
details: {
ref: "M3",
reinforcementOutcome: "reinforced",
reinforcementMode: "increment",
attemptedAtIso: "2026-05-20T00:05:00.001Z",
lastReinforcedAtIso: "2026-05-13T00:05:00.001Z",
elapsedMs: 604_800_000,
requiredElapsedMs: 604_800_000,
sameSession: false,
sessionID: "raw-increment-session-secret",
},
sessionHash: "command-session-four",
messageHash: "command-message-five",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed", "reinforcement_saturation_refresh"],
details: {
ref: "M3",
reinforcementOutcome: "refreshed",
reinforcementMode: "refresh_only",
attemptedAtIso: "2026-05-27T00:05:00.001Z",
lastReinforcedAtIso: "2026-05-20T00:05:00.001Z",
elapsedMs: 604_800_000,
requiredElapsedMs: 604_800_000,
sameSession: true,
lastReinforcedSessionID: "raw-refresh-last-session-secret",
},
sessionHash: "command-session-five",
messageHash: "command-message-six",
}),
evidence({
type: "memory_reinforced",
phase: "reinforcement",
outcome: "reinforced",
memory: { memoryId: "mem-detail", type: "decision", source: "explicit", status: "active" },
reasonCodes: ["numbered_ref_reinforce", "reinforcement_window_allowed"],
details: {
ref: "M3",
reinforcementOutcome: "reinforced",
reinforcementMode: "increment",
attemptedAtIso: "2026-05-28T00:05:00.001Z",
requiredElapsedMs: 604_800_000,
sameSession: false,
legacyMissingTimestamp: true,
sessionID: "raw-legacy-session-secret",
},
sessionHash: "command-session-six",
messageHash: "command-message-seven",
}),
evidence({
type: "render_selected",
phase: "render",
@@ -482,16 +561,26 @@ test("memory-diag commands memory selector prints reinforcement detail", async (
assert.match(stdout, /status: active/);
assert.match(stdout, /render: rendered/);
assert.match(stdout, /Reinforcement summary:/);
assert.match(stdout, /attempts: 3/);
assert.match(stdout, /reinforced: 1/);
assert.match(stdout, /rejected\/blocked: 2/);
assert.match(stdout, /window blocked: 2/);
assert.match(stdout, /block reasons: same_session=1, unknown=1/);
assert.match(stdout, /attempts: 7/);
assert.match(stdout, /reinforced: 4/);
assert.match(stdout, /rejected\/blocked: 3/);
assert.match(stdout, /window blocked: 3/);
assert.match(stdout, /block reasons: min_elapsed_window=1, same_session=1, unknown=1/);
assert.match(stdout, /block details missing: 1/);
assert.match(stdout, /same-session cross UTC day blocks: 1/);
assert.match(stdout, /refs: M3/);
assert.match(stdout, /blockReason=min_elapsed_window/);
assert.match(stdout, /elapsedMs=604799999/);
assert.match(stdout, /requiredElapsedMs=604800000/);
assert.match(stdout, /sameSession=yes/);
assert.match(stdout, /sameSession=no/);
assert.match(stdout, /reinforcementMode=refresh_only/);
assert.match(stdout, /legacyMissingTimestamp=yes/);
assert.match(stdout, /crossUtcDay=yes/);
assert.doesNotMatch(stdout, /render_selected/);
assert.doesNotMatch(stdout, /raw-session-secret/);
assert.doesNotMatch(stdout, /raw-last-session-secret/);
assert.doesNotMatch(stdout, /raw-refresh-last-session-secret/);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
@@ -516,7 +605,18 @@ test("memory-diag commands memory selector emits stable JSON", async () => {
blockDetailsMissing: number;
sameSessionCrossUtcDayBlocks: number;
};
events: Array<{ eventId: string; outcome: string; blockReason?: string; crossUtcDay?: boolean | "unknown" }>;
events: Array<{
eventId: string;
outcome: string;
blockReason?: string;
crossUtcDay?: boolean | "unknown";
elapsedMs?: number;
requiredElapsedMs?: number;
sameSession?: boolean;
legacyMissingTimestamp?: boolean;
reinforcementMode?: string;
instrumentationVersion?: number;
}>;
};
assert.equal(parsed.version, 1);
@@ -524,18 +624,27 @@ test("memory-diag commands memory selector emits stable JSON", async () => {
assert.equal(parsed.current.present, true);
assert.equal(parsed.current.status, "active");
assert.equal(parsed.current.renderStatus, "rendered");
assert.equal(parsed.summary.attempts, 3);
assert.equal(parsed.summary.reinforced, 1);
assert.equal(parsed.summary.rejectedOrBlocked, 2);
assert.equal(parsed.summary.attempts, 7);
assert.equal(parsed.summary.reinforced, 4);
assert.equal(parsed.summary.rejectedOrBlocked, 3);
assert.equal(parsed.summary.blocksByReason.min_elapsed_window, 1);
assert.equal(parsed.summary.blocksByReason.same_session, 1);
assert.equal(parsed.summary.blocksByReason.unknown, 1);
assert.equal(parsed.summary.blockDetailsMissing, 1);
assert.equal(parsed.summary.sameSessionCrossUtcDayBlocks, 1);
assert.equal(parsed.events.some(event => event.blockReason === "min_elapsed_window" && event.elapsedMs === 604_799_999 && event.requiredElapsedMs === 604_800_000 && event.sameSession === true), true);
assert.equal(parsed.events.some(event => event.reinforcementMode === "increment" && event.elapsedMs === 604_800_000 && event.sameSession === false), true);
assert.equal(parsed.events.some(event => event.reinforcementMode === "refresh_only" && event.elapsedMs === 604_800_000 && event.sameSession === true), true);
assert.equal(parsed.events.some(event => event.legacyMissingTimestamp === true), true);
assert.equal(parsed.events.every(event => event.instrumentationVersion === 3), true);
assert.equal(parsed.events.some(event => event.blockReason === "same_session" && event.crossUtcDay === true), true);
assert.equal(parsed.events.some(event => event.blockReason === "unknown" && event.crossUtcDay === "unknown"), true);
assert.equal(JSON.stringify(parsed).includes("command-session"), false);
assert.equal(JSON.stringify(parsed).includes("command-message"), false);
assert.equal(JSON.stringify(parsed).includes("Detail drill-down memory remains current"), false);
assert.equal(JSON.stringify(parsed).includes("raw-session-secret"), false);
assert.equal(JSON.stringify(parsed).includes("raw-last-session-secret"), false);
assert.equal(JSON.stringify(parsed).includes("raw-refresh-last-session-secret"), false);
assertNoAttributionSafetyTerms(stdout);
} finally {
await rm(root, { recursive: true, force: true });
+4
View File
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { appendPendingMemories } from "../src/pending-journal.ts";
import { saveSessionState } from "../src/session-state.ts";
import { MEMORY_TYPE_ORDER } from "../src/memory-kind-policy.ts";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
import { saveWorkspaceMemory } from "../src/workspace-memory.ts";
@@ -154,6 +155,9 @@ test("formats current workspace memories grouped by type with display-local refs
assert.match(output, /project:\n- \[M\d+\]/);
assert.match(output, /decision:\n- \[M\d+\]/);
assert.match(output, /reference:\n- \[M\d+\]/);
const groupIndexes = MEMORY_TYPE_ORDER.map(type => output.indexOf(`${type}:`));
assert.equal(groupIndexes.every(index => index >= 0), true, "all memory type groups should render");
assert.deepEqual(groupIndexes, [...groupIndexes].sort((a, b) => a - b), "memory list groups should follow shared memory type order");
assert.match(output, /Shown: \d+ of \d+ active memories\./);
assert.match(output, /Shown: 4 of 4 active memories\./);
assert.match(output, /Omitted active memories: 0\./);
+38
View File
@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
formatPackageVersionMismatch,
packageLockReadErrorMessage,
packageVersionMismatches,
} from "../scripts/dev/check-package-integrity.ts";
test("package integrity accepts matching package and lockfile versions", () => {
const mismatches = packageVersionMismatches(
{ version: "1.6.4" },
{ version: "1.6.4", packages: { "": { version: "1.6.4" } } },
);
assert.deepEqual(mismatches, []);
});
test("package integrity reports both lockfile version mismatches", () => {
const mismatches = packageVersionMismatches(
{ version: "1.6.4" },
{ version: "1.6.3", packages: { "": { version: "1.6.2" } } },
);
assert.deepEqual(
mismatches.map(formatPackageVersionMismatch),
[
"package-lock.json version (1.6.3) does not match package.json version (1.6.4)",
"package-lock.json packages[\"\"].version (1.6.2) does not match package.json version (1.6.4)",
],
);
});
test("package integrity explains missing package-lock.json", () => {
assert.equal(
packageLockReadErrorMessage(Object.assign(new Error("missing"), { code: "ENOENT" })),
"package-lock.json not found; run npm install first",
);
});
+623 -30
View File
@@ -8,7 +8,7 @@ import { loadSessionState, saveSessionState } from "../src/session-state.ts";
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import type { CompactionMemoryRef, LongTermMemoryEntry, OpenError } from "../src/types.ts";
import { PROMOTION_RETRY_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "../src/types.ts";
import { sessionStatePath, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { sessionStatePath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts";
import { loadWorkspaceMemory, updateWorkspaceMemory, workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
import { queryEvidenceEvents } from "../src/evidence-log.ts";
@@ -100,6 +100,60 @@ function createSessionWithError(sessionID: string, error: OpenError) {
};
}
async function withMockedDateNow<T>(now: number, fn: () => Promise<T>): Promise<T> {
const originalDateNow = Date.now;
Date.now = () => now;
try {
return await fn();
} finally {
Date.now = originalDateNow;
}
}
async function withNumberedReinforceScenario(
options: { sessionID: string; nowMs: number; existing: LongTermMemoryEntry; summary?: string },
assertions: (context: {
workspace: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
events: Awaited<ReturnType<typeof queryEvidenceEvents>>;
}) => Promise<void> | void,
): Promise<void> {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push(options.existing);
return store;
});
await saveSessionState(tmpDir, {
version: 1,
sessionID: options.sessionID,
turn: 0,
updatedAt: new Date(options.nowMs).toISOString(),
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [],
compactionMemoryRefs: [compactionRefFor(options.existing)],
});
const plugin = await MemoryV2Plugin({
directory: tmpDir,
client: mockClientWithCompactionSummary(options.summary ?? "Memory candidates:\nREINFORCE [M1]"),
});
await withMockedDateNow(options.nowMs, async () => {
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: options.sessionID } },
});
});
await assertions({
workspace: await loadWorkspaceMemory(tmpDir),
events: await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] }),
});
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
test("tool.execute.after: undefined exitCode does NOT create open error", async () => {
// 1. Temp directory for isolated file I/O
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -515,6 +569,8 @@ Next steps: continue development.
assert.equal(candidates[1].type, "project");
});
// Compatibility-contract characterization: legacy compaction parser formats are
// still supported intentionally and should not be removed as brittle fixtures.
test("parseWorkspaceMemoryCandidates accepts legacy Workspace Memory Candidates section", async () => {
const summary = `
## Summary
@@ -702,6 +758,46 @@ test("explicit memory appended from user message is owned by session and not pro
}
});
test("explicit memory before first cache miss appears in frozen hot snapshot", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
let latestMessages: Array<Record<string, unknown>> = [{
info: { role: "user", id: "msg-before-cache-1" },
parts: [{ type: "text", text: "remember this: First epoch captures pending memory." }],
}];
const client = {
session: {
get: async () => ({ data: { parentID: null } }),
messages: async () => ({ data: latestMessages }),
todo: async () => ({ data: [] }),
},
};
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "first-cache-explicit-session", model: {} },
output,
);
const joined = output.system.join("\n");
assert.match(joined, /Hot session state/);
assert.match(joined, /pending_memories:/);
assert.match(joined, /First epoch captures pending memory/);
const state = await loadSessionState(tmpDir, "first-cache-explicit-session");
assert.ok(
state.pendingMemories.some(memory => /First epoch captures pending memory/.test(memory.text)),
"first-turn explicit memory should remain durable in session pending state",
);
latestMessages = [];
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session promotion does not clear another session's same-key pending journal entry", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -824,12 +920,21 @@ test("session.deleted clears caches even when session state file is already gone
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "deleted-missing-state-session",
args: { filePath: join(tmpDir, "src/delete-before.ts") },
},
{ output: "", exitCode: 0 },
);
const beforeOutput = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "deleted-missing-state-session", model: {} },
beforeOutput,
);
assert.match(beforeOutput.system.join("\n"), /Workspace memory before delete cleanup/);
assert.match(beforeOutput.system.join("\n"), /src\/delete-before\.ts/);
const ownedPending = {
id: "mem_delete_owned_journal",
@@ -861,6 +966,15 @@ test("session.deleted clears caches even when session state file is already gone
assert.equal(pendingAfter.entries.length, 0,
"clearable owned journal entry should be removed even when session state file is absent");
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "deleted-missing-state-session",
args: { filePath: join(tmpDir, "src/delete-after.ts") },
},
{ output: "", exitCode: 0 },
);
const afterOutput = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "deleted-missing-state-session", model: {} },
@@ -869,6 +983,11 @@ test("session.deleted clears caches even when session state file is already gone
const workspacePrompt = afterOutput.system.find((part: string) => part.startsWith("Workspace memory"));
assert.match(workspacePrompt ?? "", /Owned journal memory promotes during delete cleanup/,
"session.deleted should clear frozen cache after successful promotion");
const afterJoined = afterOutput.system.join("\n");
assert.match(afterJoined, /src\/delete-after\.ts/,
"session.deleted should clear the paired frozen hot cache after successful promotion");
assert.equal(afterJoined.includes("src/delete-before.ts"), false,
"new epoch after session deletion should not reuse the deleted session's hot snapshot");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
@@ -1032,8 +1151,9 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const now = new Date().toISOString();
const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000;
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const now = new Date(nowMs).toISOString();
const lastReinforcedAt = nowMs - 8 * 24 * 60 * 60 * 1000;
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-memory",
type: "decision",
@@ -1043,7 +1163,10 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: oldRetentionClock,
retentionClock: lastReinforcedAt,
reinforcementCount: 1,
lastReinforcedAt,
lastReinforcedSessionID: "numbered-reinforce-session",
};
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push(existing);
@@ -1065,28 +1188,174 @@ test("session.compacted applies numbered REINFORCE command to referenced memory"
directory: tmpDir,
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
});
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
await withMockedDateNow(nowMs, async () => {
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "numbered-reinforce-session" } },
});
});
const workspace = await loadWorkspaceMemory(tmpDir);
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
assert.equal(reinforced?.reinforcementCount, 1);
assert.equal(reinforced?.reinforcementCount, 2);
assert.equal(reinforced?.lastReinforcedSessionID, "numbered-reinforce-session");
assert.ok((reinforced?.retentionClock ?? 0) > oldRetentionClock);
assert.equal(reinforced?.lastReinforcedAt, nowMs);
assert.equal(reinforced?.retentionClock, nowMs);
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
assert.ok(events.some(event =>
const event = events.find(event =>
event.outcome === "reinforced" &&
event.reasonCodes.includes("numbered_ref_reinforce") &&
event.reasonCodes.includes("reinforcement_window_allowed") &&
event.memory?.memoryId === existing.id
));
);
assert.ok(event, "numbered REINFORCE should emit allowed evidence");
assert.equal(event.instrumentationVersion, 3);
assert.equal(event.details?.reinforcementOutcome, "reinforced");
assert.equal(event.details?.reinforcementMode, "increment");
assert.equal(event.details?.sameSession, true);
assert.equal(event.details?.elapsedMs, 8 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.attemptedAtMs, nowMs);
assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt);
assert.equal(event.details?.previousReinforcementCount, 1);
assert.equal(event.details?.newReinforcementCount, 2);
assert.equal(JSON.stringify(event.details).includes("numbered-reinforce-session"), false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session.compacted allows numbered REINFORCE at exact 7-day boundary", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000;
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-exact-window",
type: "decision",
text: "Use exact rolling windows for memory reinforcement.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: lastReinforcedAt,
reinforcementCount: 5,
lastReinforcedAt,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "exact-window-session", nowMs, existing }, ({ workspace, events }) => {
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
assert.equal(reinforced?.reinforcementCount, 6);
assert.equal(reinforced?.retentionClock, nowMs);
assert.ok(event, "exact 7-day boundary should emit reinforced evidence");
assert.equal(event.details?.reinforcementMode, "increment");
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.instrumentationVersion, 3);
});
});
test("session.compacted blocks different-session numbered REINFORCE below 7-day window", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 1);
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-below-window",
type: "feedback",
text: "User prefers below-window reinforcement blocks.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
reinforcementCount: 1,
lastReinforcedAt,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "different-session", nowMs, existing }, ({ workspace, events }) => {
const blocked = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "rejected" && event.memory?.memoryId === existing.id);
assert.equal(blocked?.reinforcementCount, 1);
assert.ok(event, "below-window different-session attempt should emit rejected evidence");
assert.ok(event.reasonCodes.includes("reinforcement_block_min_elapsed_window"));
assert.equal(event.details?.blockReason, "min_elapsed_window");
assert.equal(event.details?.sameSession, false);
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 1);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.instrumentationVersion, 3);
});
});
test("session.compacted refreshes saturated numbered REINFORCE after rolling window", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const lastReinforcedAt = nowMs - 7 * 24 * 60 * 60 * 1000;
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-refresh-only",
type: "decision",
text: "Use refresh-only mode for saturated reinforcement.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000,
reinforcementCount: 6,
lastReinforcedAt,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "refresh-only-session", nowMs, existing }, ({ workspace, events }) => {
const refreshed = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
assert.equal(refreshed?.reinforcementCount, 6);
assert.equal(refreshed?.retentionClock, nowMs);
assert.equal(refreshed?.lastReinforcedAt, nowMs);
assert.equal(refreshed?.lastReinforcedSessionID, "refresh-only-session");
assert.ok(event, "saturated after-window attempt should emit refreshed evidence");
assert.equal(event.details?.reinforcementOutcome, "refreshed");
assert.equal(event.details?.reinforcementMode, "refresh_only");
assert.equal(event.details?.previousReinforcementCount, 6);
assert.equal(event.details?.newReinforcementCount, 6);
assert.ok(event.reasonCodes.includes("reinforcement_saturation_refresh"));
assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false);
assert.equal(event.instrumentationVersion, 3);
});
});
test("session.compacted emits legacy missing timestamp details for numbered REINFORCE", async () => {
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
id: "numbered-reinforce-legacy-missing",
type: "feedback",
text: "User prefers legacy timestamp anomalies to be visible.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: new Date(nowMs).toISOString(),
updatedAt: new Date(nowMs).toISOString(),
retentionClock: nowMs - 30 * 24 * 60 * 60 * 1000,
reinforcementCount: 2,
lastReinforcedSessionID: "previous-session",
};
await withNumberedReinforceScenario({ sessionID: "legacy-missing-session", nowMs, existing }, ({ workspace, events }) => {
const reinforced = workspace.entries.find(entry => entry.id === existing.id);
const event = events.find(event => event.outcome === "reinforced" && event.memory?.memoryId === existing.id);
assert.equal(reinforced?.reinforcementCount, 3);
assert.equal(reinforced?.lastReinforcedAt, nowMs);
assert.equal(reinforced?.retentionClock, nowMs);
assert.equal(event?.details?.legacyMissingTimestamp, true);
assert.equal(event?.details?.reinforcementMode, "increment");
assert.equal(event?.instrumentationVersion, 3);
});
});
test("session.compacted rejects invalid unavailable and stale REINFORCE refs without mutation", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -1163,8 +1432,9 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const nowMs = Date.now();
const nowMs = Date.UTC(2026, 4, 15, 12, 0, 0);
const now = new Date(nowMs).toISOString();
const lastReinforcedAt = nowMs - (7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000);
const existing: LongTermMemoryEntry = {
id: "blocked-numbered-reinforce",
type: "feedback",
@@ -1176,7 +1446,7 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
updatedAt: now,
retentionClock: nowMs - 10 * 24 * 60 * 60 * 1000,
reinforcementCount: 1,
lastReinforcedAt: nowMs - 2 * 60 * 60 * 1000,
lastReinforcedAt,
lastReinforcedSessionID: "blocked-reinforce-session",
};
await updateWorkspaceMemory(tmpDir, store => {
@@ -1199,8 +1469,10 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
directory: tmpDir,
client: mockClientWithCompactionSummary("Memory candidates:\nREINFORCE [M1]"),
});
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
await withMockedDateNow(nowMs, async () => {
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "blocked-reinforce-session" } },
});
});
const workspace = await loadWorkspaceMemory(tmpDir);
@@ -1209,13 +1481,31 @@ test("session.compacted emits rejected evidence when numbered REINFORCE is block
assert.equal(blocked?.reinforcementCount, 1);
const events = await queryEvidenceEvents(tmpDir, { types: ["memory_reinforced"] });
assert.ok(events.some(event =>
const event = events.find(event =>
event.outcome === "rejected" &&
event.reasonCodes.includes("reinforcement_window_blocked") &&
event.reasonCodes.includes("reinforcement_block_min_elapsed_window") &&
event.memory?.memoryId === existing.id &&
event.relations?.some(relation => relation.role === "target" && relation.memory?.memoryId === existing.id) &&
!event.relations?.some(relation => relation.role === "reinforced")
));
);
assert.ok(event, "blocked numbered REINFORCE should emit elapsed-window evidence");
assert.equal(event.instrumentationVersion, 3);
assert.equal(event.reasonCodes.includes("reinforcement_block_same_session"), false);
assert.equal(event.reasonCodes.includes("reinforcement_block_same_utc_day"), false);
assert.equal(event.reasonCodes.includes("reinforcement_block_min_interval"), false);
assert.equal(event.reasonCodes.includes("reinforcement_block_max_count"), false);
assert.equal(event.details?.blockReason, "min_elapsed_window");
assert.equal(event.details?.elapsedMs, 7 * 24 * 60 * 60 * 1000 - 60 * 60 * 1000);
assert.equal(event.details?.requiredElapsedMs, 7 * 24 * 60 * 60 * 1000);
assert.equal(event.details?.sameSession, true);
assert.equal(event.details?.attemptedAtMs, nowMs);
assert.equal(event.details?.lastReinforcedAtMs, lastReinforcedAt);
assert.equal(event.details?.reinforcementCount, 1);
assert.equal(event.details?.maxReinforcementCount, 6);
assert.equal("minIntervalMs" in (event.details ?? {}), false);
assert.equal("reinforcementMode" in (event.details ?? {}), false);
assert.equal(JSON.stringify(event.details).includes("blocked-reinforce-session"), false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
@@ -2125,7 +2415,64 @@ test("integration: next session promotes prior unowned journal and leaves journa
}
});
test("same-session explicit memory does not mutate frozen system[1]", async () => {
test("unowned pending promotion failure does not block epoch snapshot creation", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
let lockPath: string | undefined;
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_existing_promotion_failure",
type: "project",
text: "Existing stable workspace memory survives promotion failure.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: now,
updatedAt: now,
});
return store;
});
await savePendingJournal(tmpDir, {
version: 1,
workspace: { root: tmpDir, key: await workspaceKey(tmpDir) },
updatedAt: now,
entries: [{
id: "mem_unowned_lock_failure",
type: "feedback",
text: "unowned pending memory text should not be treated as promoted after lock failure.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
}],
});
const workspacePath = await workspaceMemoryPath(tmpDir);
lockPath = `${workspacePath}.lock`;
await mkdir(dirname(workspacePath), { recursive: true });
await writeFile(lockPath, `${process.pid}\n${Date.now()}\n`);
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "promotion-failure-session", model: {} },
output,
);
const joined = output.system.join("\n");
assert.match(joined, /Existing stable workspace memory survives promotion failure|Hot session state/);
assert.equal(joined.includes("unowned pending memory text"), false,
"failed unowned backlog promotion should not be silently treated as promoted");
} finally {
if (lockPath) await rm(lockPath, { force: true });
await rm(tmpDir, { recursive: true, force: true });
}
});
test("same-session explicit memory after epoch creation persists without refreshing frozen prompts", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
@@ -2167,10 +2514,10 @@ test("same-session explicit memory does not mutate frozen system[1]", async () =
assert.match(firstSystem1 ?? "", /Existing stable workspace memory/,
"first transform should create a frozen workspace memory system[1]");
// 3. User says "remember X" in the same session.
// 3. User says "remember X" in the same session after the epoch exists.
latestMessages = [{
info: { role: "user", id: "msg-explicit-1" },
parts: [{ type: "text", text: "remember this: Same-session memory stays ephemeral." }],
parts: [{ type: "text", text: "remember this: Same-session memory stays durable but frozen prompt does not refresh." }],
}];
const output2 = { system: ["base header"] };
@@ -2179,25 +2526,28 @@ test("same-session explicit memory does not mutate frozen system[1]", async () =
output2,
);
// 4. Assert: workspace system[1] unchanged (frozen snapshot).
// 4. Assert: all plugin-added pre-history prompts are unchanged.
const secondSystem1 = output2.system.find((part: string) => part.startsWith("Workspace memory"));
assert.equal(secondSystem1, firstSystem1,
"frozen system[1] must not change after explicit memory in same session");
"frozen workspace prompt should remain unchanged after explicit memory in same session");
assert.deepEqual(output2.system, output1.system,
"same-session explicit memory after epoch creation must not mutate pre-history prompts");
// 5. Assert: hot state (system[2+]) contains the pending memory.
const hotState = output2.system.find((part: string) => part.includes("Hot session state"));
assert.ok(hotState, "hot session state should be rendered");
assert.match(hotState, /pending_memories:/,
"hot state should contain pending_memories section");
assert.match(hotState, /Same-session memory stays ephemeral/,
"hot state should contain the explicit memory text");
// 5. Assert: explicit memory remains durable without forcing prompt refresh.
const state = await loadSessionState(tmpDir, "frozen-cache-session");
assert.ok(
state.pendingMemories.some(memory => /Same-session memory stays durable/.test(memory.text)),
"explicit memory should remain durable in session pending state",
);
assert.equal(output2.system.join("\n").includes("Same-session memory stays durable"), false,
"new pending memory is already in conversation history and should not force system prompt refresh");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("chat system transform reloads frozen workspace snapshot after cache TTL expires", async () => {
test("chat system transform does not reload frozen epoch snapshots after TTL time passes", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
const originalNow = Date.now;
let now = originalNow();
@@ -2220,12 +2570,21 @@ test("chat system transform reloads frozen workspace snapshot after cache TTL ex
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "ttl-session",
args: { filePath: join(tmpDir, "src/before-ttl.ts") },
},
{ output: "", exitCode: 0 },
);
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "ttl-session", model: {} },
output1,
);
assert.match(output1.system.join("\n"), /Workspace memory before TTL expiry/);
assert.match(output1.system.join("\n"), /src\/before-ttl\.ts/);
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
@@ -2243,13 +2602,228 @@ test("chat system transform reloads frozen workspace snapshot after cache TTL ex
now += WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs + 1;
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "ttl-session",
args: { filePath: join(tmpDir, "src/after-ttl.ts") },
},
{ output: "", exitCode: 0 },
);
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "ttl-session", model: {} },
output2,
);
assert.match(output2.system.join("\n"), /Workspace memory after TTL expiry/);
const joined = output2.system.join("\n");
assert.match(joined, /Workspace memory before TTL expiry/);
assert.equal(joined.includes("Workspace memory after TTL expiry"), false);
assert.deepEqual(output2.system, output1.system,
"TTL time passage and hot-state churn must not refresh active frozen epoch prompts");
} finally {
Date.now = originalNow;
await rm(tmpDir, { recursive: true, force: true });
}
});
test("chat system transform keeps frozen prompts stable across active file churn", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "active-file-churn-session",
args: { filePath: join(tmpDir, "src/test.ts") },
},
{ output: "", exitCode: 0 },
);
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "active-file-churn-session", model: {} },
output1,
);
assert.match(output1.system.join("\n"), /src\/test\.ts \(edit, 1x\)/);
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "active-file-churn-session",
args: { filePath: join(tmpDir, "src/test.ts") },
},
{ output: "", exitCode: 0 },
);
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "active-file-churn-session", model: {} },
output2,
);
assert.deepEqual(output2.system, output1.system,
"active-file churn must not mutate pre-history prompts during the active epoch");
assert.equal(output2.system.join("\n").includes("Hot state deltas"), false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session compaction starts a new frozen prompt epoch including refreshed hot state", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "epoch-refresh-session",
args: { filePath: join(tmpDir, "src/before.ts") },
},
{ output: "", exitCode: 0 },
);
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "epoch-refresh-session", model: {} },
output1,
);
assert.match(output1.system.join("\n"), /src\/before\.ts/);
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "epoch-refresh-session",
args: { filePath: join(tmpDir, "src/after.ts") },
},
{ output: "", exitCode: 0 },
);
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "epoch-refresh-session", model: {} },
output2,
);
assert.equal(output2.system.join("\n").includes("src/after.ts"), false,
"normal turns should keep using the frozen hot prompt before compaction");
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "epoch-refresh-session" } },
});
const output3 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "epoch-refresh-session", model: {} },
output3,
);
assert.match(output3.system.join("\n"), /src\/after\.ts/,
"compaction should clear the frozen hot cache so the next epoch includes refreshed hot state");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("chat system transform keeps recently accessed frozen epoch under cache pressure", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
const originalNow = Date.now;
let now = originalNow();
Date.now = () => now;
try {
const timestamp = new Date(now).toISOString();
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_recency_cache_before",
type: "project",
text: "Workspace memory before recency cache pressure.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
return store;
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "active-session-0",
args: { filePath: join(tmpDir, "src/recency-before.ts") },
},
{ output: "", exitCode: 0 },
);
let activeOutput = { system: ["base header"] };
for (let i = 0; i < WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions; i += 1) {
const sessionID = i === 0 ? "active-session-0" : `inactive-session-${i}`;
now += 1;
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID, model: {} },
output,
);
if (sessionID === "active-session-0") activeOutput = output;
}
assert.match(activeOutput.system.join("\n"), /Workspace memory before recency cache pressure/);
assert.match(activeOutput.system.join("\n"), /src\/recency-before\.ts/);
now += 1;
const activeHitOutput = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "active-session-0", model: {} },
activeHitOutput,
);
assert.deepEqual(activeHitOutput.system, activeOutput.system,
"cache hit should update access recency without changing frozen prompt text");
now += 1;
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "pressure-session-extra", model: {} },
{ system: ["base header"] },
);
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_recency_cache_after",
type: "project",
text: "Workspace memory after recency cache pressure.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
return store;
});
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "active-session-0",
args: { filePath: join(tmpDir, "src/recency-after.ts") },
},
{ output: "", exitCode: 0 },
);
now += 1;
const outputAfterPressure = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "active-session-0", model: {} },
outputAfterPressure,
);
const joined = outputAfterPressure.system.join("\n");
assert.deepEqual(outputAfterPressure.system, activeOutput.system,
"recently accessed active session should keep its original frozen workspace and hot prompts under cache pressure");
assert.match(joined, /Workspace memory before recency cache pressure/);
assert.equal(joined.includes("Workspace memory after recency cache pressure"), false);
assert.match(joined, /src\/recency-before\.ts/);
assert.equal(joined.includes("src/recency-after.ts"), false);
} finally {
Date.now = originalNow;
await rm(tmpDir, { recursive: true, force: true });
@@ -2276,6 +2850,14 @@ test("chat system transform evicts oldest frozen snapshots when cache exceeds se
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "cache-size-session-0",
args: { filePath: join(tmpDir, "src/cache-before.ts") },
},
{ output: "", exitCode: 0 },
);
for (let i = 0; i <= WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions; i += 1) {
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: `cache-size-session-${i}`, model: {} },
@@ -2297,6 +2879,15 @@ test("chat system transform evicts oldest frozen snapshots when cache exceeds se
return store;
});
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "edit",
sessionID: "cache-size-session-0",
args: { filePath: join(tmpDir, "src/cache-after.ts") },
},
{ output: "", exitCode: 0 },
);
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "cache-size-session-0", model: {} },
@@ -2304,6 +2895,8 @@ test("chat system transform evicts oldest frozen snapshots when cache exceeds se
);
assert.match(output.system.join("\n"), /Workspace memory after cache pressure/);
assert.match(output.system.join("\n"), /src\/cache-after\.ts/,
"cache pressure should evict the paired frozen hot snapshot for the oldest session");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
+145 -37
View File
@@ -1,12 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
BASE_HALF_LIFE_DAYS,
REINFORCEMENT_MAX_COUNT,
REINFORCEMENT_MIN_INTERVAL_MS,
tryReinforceMemory,
} from "../src/retention.ts";
import type { LongTermMemoryEntry } from "../src/types.ts";
const DAY_MS = 24 * 60 * 60 * 1000;
const ROLLING_WINDOW_MS = 7 * DAY_MS;
const baseMemory = (overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemoryEntry => ({
id: "mem-retention",
type: "decision",
@@ -19,40 +22,77 @@ const baseMemory = (overrides: Partial<LongTermMemoryEntry> = {}): LongTermMemor
...overrides,
});
test("tryReinforceMemory blocks same session with exact reason", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
test("tryReinforceMemory allows same session after rolling 8 days", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + 8 * DAY_MS;
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: now - 2 * REINFORCEMENT_MIN_INTERVAL_MS,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
retentionClock: lastAt,
});
const decision = tryReinforceMemory(memory, "session-a", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "increment");
assert.equal(decision.previousReinforcementCount, 1);
assert.equal(decision.newReinforcementCount, 2);
assert.equal(decision.memory.reinforcementCount, 2);
assert.equal(decision.memory.retentionClock, now);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-a");
assert.equal(decision.sameSession, true);
assert.equal(decision.elapsedMs, 8 * DAY_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory allows exactly 7 rolling days after last reinforcement", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS;
const memory = baseMemory({
reinforcementCount: 5,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
retentionClock: lastAt,
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "increment");
assert.equal(decision.memory.reinforcementCount, 6);
assert.equal(decision.memory.retentionClock, now);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.sameSession, false);
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks 7 rolling days minus 1ms as min_elapsed_window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS - 1;
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-a", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "same_session");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.equal(decision.memory, memory);
});
test("tryReinforceMemory blocks different session on same UTC day", () => {
const lastAt = Date.UTC(2026, 4, 12, 0, 15, 0);
const now = Date.UTC(2026, 4, 12, 23, 30, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "same_utc_day");
assert.equal(decision.sameSession, true);
assert.equal(decision.lastReinforcedAt, lastAt);
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS - 1);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks min interval across UTC day boundary", () => {
const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0);
const now = Date.UTC(2026, 4, 13, 0, 15, 0);
test("tryReinforceMemory blocks different session below rolling window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + 3 * DAY_MS;
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
@@ -62,42 +102,110 @@ test("tryReinforceMemory blocks min interval across UTC day boundary", () => {
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_interval");
assert.equal(decision.minIntervalMs, REINFORCEMENT_MIN_INTERVAL_MS);
assert.equal(decision.blockReason, "min_elapsed_window");
assert.equal(decision.sameSession, false);
assert.equal(decision.elapsedMs, 3 * DAY_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks max count with exact reason", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
test("tryReinforceMemory blocks UTC midnight crossing below rolling window", () => {
const lastAt = Date.UTC(2026, 4, 12, 23, 45, 0);
const now = Date.UTC(2026, 4, 13, 2, 15, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.notEqual(decision.blockReason, "same_utc_day");
assert.equal(decision.elapsedMs, now - lastAt);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory refreshes saturated count after rolling window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS;
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0),
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
retentionClock: lastAt,
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "refresh_only");
assert.equal(decision.previousReinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.newReinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.notEqual(decision.memory, memory);
assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.memory.retentionClock, now);
assert.equal(decision.elapsedMs, ROLLING_WINDOW_MS);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory blocks saturated count below rolling window as min_elapsed_window", () => {
const lastAt = Date.UTC(2026, 4, 8, 12, 0, 0);
const now = lastAt + ROLLING_WINDOW_MS - 1;
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: lastAt,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "blocked");
assert.equal(decision.blockReason, "max_count");
assert.equal(decision.blockReason, "min_elapsed_window");
assert.notEqual(decision.blockReason, "max_count");
assert.equal(decision.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.maxReinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.requiredElapsedMs, ROLLING_WINDOW_MS);
});
test("tryReinforceMemory reinforces allowed memory and wrapper returns memory only", () => {
test("tryReinforceMemory normalizes missing legacy timestamp while incrementing", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: 1,
lastReinforcedAt: Date.UTC(2026, 4, 10, 12, 0, 0),
reinforcementCount: 2,
lastReinforcedSessionID: "session-a",
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.previousReinforcementCount, 1);
assert.equal(decision.newReinforcementCount, 2);
assert.notEqual(decision.memory, memory);
assert.equal(decision.memory.reinforcementCount, 2);
assert.equal(decision.reinforcementMode, "increment");
assert.equal(decision.legacyMissingTimestamp, true);
assert.equal(decision.memory.reinforcementCount, 3);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.lastReinforcedSessionID, "session-b");
assert.equal(decision.memory.retentionClock, now);
});
test("tryReinforceMemory normalizes invalid legacy timestamp while refresh-only saturated", () => {
const now = Date.UTC(2026, 4, 12, 12, 0, 0);
const memory = baseMemory({
reinforcementCount: REINFORCEMENT_MAX_COUNT,
lastReinforcedAt: Number.NaN,
lastReinforcedSessionID: "session-a",
retentionClock: Date.UTC(2026, 4, 8, 12, 0, 0),
});
const decision = tryReinforceMemory(memory, "session-b", now);
assert.equal(decision.outcome, "reinforced");
assert.equal(decision.reinforcementMode, "refresh_only");
assert.equal(decision.legacyMissingTimestamp, true);
assert.equal(decision.memory.reinforcementCount, REINFORCEMENT_MAX_COUNT);
assert.equal(decision.memory.lastReinforcedAt, now);
assert.equal(decision.memory.retentionClock, now);
});
test("BASE_HALF_LIFE_DAYS remains 45", () => {
assert.equal(BASE_HALF_LIFE_DAYS, 45);
});
+5 -4
View File
@@ -19,6 +19,7 @@ const accountHotSessionStateRender = (
const { createEmptySessionState, loadSessionState, renderHotSessionState, saveSessionState } = sessionStateModule;
const root = "/repo";
const HOT_STATE_PREFIX = "Hot session state snapshot (epoch start; conversation history may be newer):";
function state(overrides: Partial<SessionState> = {}): SessionState {
return {
@@ -113,7 +114,7 @@ test("accountHotSessionStateRender renders hot-state sections in stable order",
pendingMemories: [memory("mem-1", "Promote useful fact")],
}), root);
assert.ok(accounting.prompt.startsWith("Hot session state (current session):"));
assert.ok(accounting.prompt.startsWith(HOT_STATE_PREFIX));
assert.ok(accounting.prompt.indexOf("active_files:") < accounting.prompt.indexOf("open_errors:"));
assert.ok(accounting.prompt.indexOf("open_errors:") < accounting.prompt.indexOf("recent_decisions:"));
assert.ok(accounting.prompt.indexOf("recent_decisions:") < accounting.prompt.indexOf("pending_memories:"));
@@ -165,7 +166,7 @@ test("accountHotSessionStateRender omits over-budget entries without cutting ren
}), root);
assert.equal(accounting.prompt, [
"Hot session state (current session):",
HOT_STATE_PREFIX,
"active_files:",
"- src/short.ts (read, 1x)",
].join("\n"));
@@ -177,7 +178,7 @@ test("accountHotSessionStateRender omits over-budget entries without cutting ren
test("accountHotSessionStateRender includes exact 700-char prompt but omits one additional character", () => {
const fixedPrompt = [
"Hot session state (current session):",
HOT_STATE_PREFIX,
"pending_memories:",
"- [decision] ",
].join("\n");
@@ -223,7 +224,7 @@ test("renderHotSessionState delegates to accounted renderer prompt for empty and
test("accountHotSessionStateRender counts newline separators in the 700-char budget", () => {
const fixedPrompt = [
"Hot session state (current session):",
HOT_STATE_PREFIX,
"recent_decisions:",
"- ",
].join("\n");
+86 -5
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -15,18 +15,26 @@ function executable(name: "npm" | "npx"): string {
return process.platform === "win32" ? `${name}.cmd` : name;
}
function binPath(root: string, name: string): string {
return join(root, "node_modules", ".bin", process.platform === "win32" ? `${name}.cmd` : name);
}
test("packed memory-diag bin runs from a temp consumer project", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "opencode-memory-diag-packaging-"));
const packDir = join(tempRoot, "pack");
const consumerDir = join(tempRoot, "consumer");
const cacheDir = join(tempRoot, "npm-cache");
try {
await mkdir(packDir, { recursive: true });
await mkdir(consumerDir, { recursive: true });
await mkdir(cacheDir, { recursive: true });
const packResult = await execFileAsync(executable("npm"), [
"pack",
repoRoot,
"--cache",
cacheDir,
"--pack-destination",
packDir,
"--silent",
@@ -35,11 +43,18 @@ test("packed memory-diag bin runs from a temp consumer project", async () => {
assert.ok(tarballName, `npm pack did not report a tarball name. stdout:\n${packResult.stdout}\nstderr:\n${packResult.stderr}`);
const tarballPath = join(packDir, tarballName);
const runResult = await execFileAsync(executable("npx"), [
"--yes",
"--package",
await execFileAsync(executable("npm"), [
"install",
"--cache",
cacheDir,
tarballPath,
"memory-diag",
"--legacy-peer-deps",
"--prefix",
consumerDir,
"--silent",
], { cwd: tempRoot, maxBuffer });
const runResult = await execFileAsync(binPath(consumerDir, "memory-diag"), [
"--help",
], { cwd: consumerDir, maxBuffer });
@@ -49,3 +64,69 @@ test("packed memory-diag bin runs from a temp consumer project", async () => {
await rm(tempRoot, { recursive: true, force: true });
}
});
test("packed plugin runtime imports from node_modules JavaScript entries", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "opencode-working-memory-packaging-"));
const packDir = join(tempRoot, "pack");
const consumerDir = join(tempRoot, "consumer");
const cacheDir = join(tempRoot, "npm-cache");
try {
await mkdir(packDir, { recursive: true });
await mkdir(consumerDir, { recursive: true });
await mkdir(cacheDir, { recursive: true });
const packResult = await execFileAsync(executable("npm"), [
"pack",
repoRoot,
"--cache",
cacheDir,
"--pack-destination",
packDir,
"--silent",
], { cwd: tempRoot, maxBuffer });
const tarballName = packResult.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
assert.ok(tarballName, `npm pack did not report a tarball name. stdout:\n${packResult.stdout}\nstderr:\n${packResult.stderr}`);
const tarballPath = join(packDir, tarballName);
await execFileAsync(executable("npm"), [
"install",
"--cache",
cacheDir,
"--prefix",
consumerDir,
tarballPath,
"--legacy-peer-deps",
"--silent",
], { cwd: tempRoot, maxBuffer });
const packageJsonPath = join(consumerDir, "node_modules", "opencode-working-memory", "package.json");
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as {
main?: unknown;
exports?: Record<string, unknown>;
};
assert.equal(packageJson.main, "dist/index.js");
assert.equal(packageJson.exports?.["."], "./dist/index.js");
assert.equal(packageJson.exports?.["./server"], "./dist/index.js");
assert.equal(packageJson.exports?.["./tui"], "./dist/src/tui-plugin.js");
const importResult = await execFileAsync(process.execPath, [
"-e",
[
"const plugin = await import('opencode-working-memory');",
"const server = await import('opencode-working-memory/server');",
"const tui = await import('opencode-working-memory/tui');",
"console.log([plugin.default.id, server.default.id, tui.default.id].join('\\n'));",
].join(" "),
], { cwd: consumerDir, maxBuffer });
assert.deepEqual(importResult.stdout.trim().split(/\r?\n/), [
"working-memory",
"working-memory",
"working-memory-tui",
]);
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
+19 -2
View File
@@ -1,11 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, 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 { readJSON, updateJSON } from "../src/storage.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "../src/storage.ts";
import { queryEvidenceEvents } from "../src/evidence-log.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
@@ -24,6 +24,23 @@ test("updateJSON serializes concurrent increments", async () => {
}
});
test("atomicWriteJSON is a full-state overwrite primitive", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-atomic-overwrite-"));
try {
const path = join(root, "store.json");
await atomicWriteJSON(path, { retained: true, removed: true });
await atomicWriteJSON(path, { retained: true });
const raw = await readFile(path, "utf8");
assert.deepEqual(JSON.parse(raw), { retained: true });
assert.equal(raw.includes("removed"), false, "atomic overwrite should not merge with previous state");
assert.equal(existsSync(`${path}.lock`), false, "atomic overwrite should not create the RMW lock file");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON does not replace corrupt JSON with fallback", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-corrupt-"));
try {
+186 -41
View File
@@ -46,6 +46,8 @@ test("default prompt budgets use calibrated conservative character caps", () =>
});
test("retention type caps use v1.6 decision headroom without changing other caps", () => {
// Policy-contract characterization: these caps are intentionally brittle so
// retention policy changes must update the expected values deliberately.
assert.equal(RETENTION_TYPE_MAX.feedback, 10);
assert.equal(RETENTION_TYPE_MAX.decision, 12);
assert.equal(RETENTION_TYPE_MAX.project, 8);
@@ -72,6 +74,16 @@ function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function withMockedDateNow<T>(now: number, fn: () => T): T {
const originalDateNow = Date.now;
Date.now = () => now;
try {
return fn();
} finally {
Date.now = originalDateNow;
}
}
/** Create an entry with a createdAt offset from now (negative = in the past) */
function agedEntry(
id: string,
@@ -169,6 +181,41 @@ test("renderWorkspaceMemory returns empty for no entries", () => {
assert.equal(rendered, "");
});
test("renderWorkspaceMemory groups active entries in current prompt order", () => {
// Wave 2 characterization: lock the externally visible prompt grouping before
// any future memory-kind policy extraction or render-order refactor.
const now = "2026-05-15T12:00:00.000Z";
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [
{ ...entry("mem-reference", "Docs live under docs/.", "reference"), createdAt: now, updatedAt: now },
{ ...entry("mem-decision", "Keep health waves behavior-preserving.", "decision"), createdAt: now, updatedAt: now },
{ ...entry("mem-project", "This repository uses Node's built-in test runner.", "project"), createdAt: now, updatedAt: now },
{ ...entry("mem-feedback", "Prefer concise verification summaries.", "feedback"), createdAt: now, updatedAt: now },
{ ...entry("mem-superseded", "Superseded entries stay out of prompts.", "feedback"), createdAt: now, updatedAt: now, status: "superseded" as const },
],
updatedAt: now,
lastActivityAt: now,
};
const rendered = renderWorkspaceMemory(store);
assert.equal(rendered, [
"Workspace memory (cross-session, verify if stale):",
"feedback:",
"- Prefer concise verification summaries.",
"project:",
"- This repository uses Node's built-in test runner.",
"decision:",
"- Keep health waves behavior-preserving.",
"reference:",
"- Docs live under docs/.",
].join("\n"));
assert.equal(rendered.includes("Superseded entries stay out of prompts."), false);
});
test("accountWorkspaceMemoryCompactionRefs returns empty prompt and refs for no entries", () => {
const store: WorkspaceMemoryStore = {
version: 1,
@@ -580,7 +627,7 @@ test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for str
assert.deepEqual(result.kept.map(memory => memory.id), ["reinforced-old", "fresh"]);
});
test("reinforceMemory enforces session interval and max guards", () => {
test("reinforceMemory enforces rolling elapsed window and saturation refresh", () => {
const now = Date.UTC(2026, 3, 29);
const base = entry("reinforce", "Durable memory should reinforce only when gated");
const reinforced = tryReinforceMemory(base, "session-a", now).memory;
@@ -594,18 +641,26 @@ test("reinforceMemory enforces session interval and max guards", () => {
assert.equal(tryReinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000).memory, reinforced);
assert.equal(tryReinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000).memory, reinforced);
const reinforcedAfterWindow = tryReinforceMemory(reinforced, "session-a", now + 7 * DAY_MS).memory;
assert.notEqual(reinforcedAfterWindow, reinforced);
assert.equal(reinforcedAfterWindow.reinforcementCount, 2);
const atMax: LongTermMemoryEntry = {
...base,
reinforcementCount: 6,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
lastReinforcedAt: now - 7 * DAY_MS,
};
assert.equal(tryReinforceMemory(atMax, "session-c", now).memory, atMax);
const refreshedAtMax = tryReinforceMemory(atMax, "session-c", now).memory;
assert.notEqual(refreshedAtMax, atMax);
assert.equal(refreshedAtMax.reinforcementCount, 6);
assert.equal(refreshedAtMax.retentionClock, now);
});
test("reinforceMemory requires distinct UTC calendar days between reinforcements", () => {
test("reinforceMemory uses rolling elapsed window instead of UTC calendar days", () => {
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 afterWindow = firstReinforcedAt + 7 * DAY_MS;
const base: LongTermMemoryEntry = {
...entry("calendar-day-gated", "Reinforcement requires distinct UTC calendar days", "decision"),
reinforcementCount: 1,
@@ -614,13 +669,14 @@ test("reinforceMemory requires distinct UTC calendar days between reinforcements
};
assert.equal(tryReinforceMemory(base, "session-b", sameUtcDayMuchLater).memory, base);
assert.equal(tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory, base);
const reinforcedNextDay = tryReinforceMemory(base, "session-b", nextUtcDayAfterInterval).memory;
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 reinforcedAfterWindow = tryReinforceMemory(base, "session-b", afterWindow).memory;
assert.notEqual(reinforcedAfterWindow, base);
assert.equal(reinforcedAfterWindow.reinforcementCount, 2);
assert.equal(reinforcedAfterWindow.lastReinforcedAt, afterWindow);
assert.equal(reinforcedAfterWindow.lastReinforcedSessionID, "session-b");
assert.equal(reinforcedAfterWindow.retentionClock, afterWindow);
const atMax: LongTermMemoryEntry = {
...base,
@@ -716,64 +772,75 @@ test("reinforced memory with same initial strength and age ranks above unreinfor
assert.deepEqual(kept.map(memory => memory.id), ["reinforced", "unreinforced"]);
});
test("dedupe reinforcement does not increment for same session", () => {
const now = Date.now();
test("dedupe reinforcement allows same session after rolling 8 days with increment evidence", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing", "Use pnpm for package management", "decision"),
source: "manual",
pendingOwnerSessionID: "same-session",
reinforcementCount: 1,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
lastReinforcedAt: now - 8 * DAY_MS,
lastReinforcedSessionID: "same-session",
retentionClock: now - 8 * DAY_MS,
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate", "use pnpm for package management!!!", "decision"),
pendingOwnerSessionID: "same-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing");
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.reinforcementCount, 2);
assert.equal(retained.lastReinforcedSessionID, "same-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "same_session"));
assert.equal(retained.retentionClock, now);
assert.ok(reinforced, "same-session after window should emit reinforced evidence");
assert.equal(reinforced.details?.reinforcementOutcome, "reinforced");
assert.equal(reinforced.details?.reinforcementMode, "increment");
assert.equal(reinforced.details?.sameSession, true);
assert.equal(reinforced.details?.elapsedMs, 8 * DAY_MS);
assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS);
assert.equal(reinforced.details?.attemptedAtMs, now);
assert.equal(reinforced.details?.lastReinforcedAtMs, now - 8 * DAY_MS);
assert.equal(reinforced.details?.previousReinforcementCount, 1);
assert.equal(reinforced.details?.newReinforcementCount, 2);
assert.equal(JSON.stringify(reinforced.details).includes("same-session"), false);
});
test("dedupe blocked reinforcement emits exact block reason details", () => {
const now = Date.now();
test("dedupe reinforcement allows exactly 7 rolling days", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing-blocked", "Prefer deterministic consolidation accounting", "feedback"),
...entry("existing-exact-window", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 1,
lastReinforcedAt: now - 30 * 60 * 1000,
reinforcementCount: 5,
lastReinforcedAt: now - 7 * DAY_MS,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-blocked", "prefer deterministic consolidation accounting!!!", "feedback"),
...entry("duplicate-exact-window", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing-exact-window");
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.ok(blocked, "blocked duplicate reinforcement should emit diagnostic evidence");
assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked"));
assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_interval"));
assert.equal(blocked.details?.blockReason, "min_interval");
assert.equal(blocked.details?.reinforcementCount, 1);
assert.equal(blocked.details?.maxReinforcementCount, 6);
assert.equal(blocked.details?.minIntervalMs, 60 * 60 * 1000);
assert.equal(retained?.reinforcementCount, 6);
assert.ok(reinforced, "exact 7-day window should emit reinforced evidence");
assert.equal(reinforced.details?.reinforcementMode, "increment");
assert.equal(reinforced.details?.elapsedMs, 7 * DAY_MS);
assert.equal(reinforced.details?.requiredElapsedMs, 7 * DAY_MS);
});
test("dedupe reinforcement does not increment under one hour", () => {
const now = Date.now();
test("dedupe reinforcement blocks 7 rolling days minus 1ms with elapsed details", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 1,
lastReinforcedAt: now - 30 * 60 * 1000,
lastReinforcedAt: now - 7 * DAY_MS + 1,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
@@ -781,31 +848,109 @@ test("dedupe reinforcement does not increment under one hour", () => {
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing");
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "old-session");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "min_interval"));
assert.ok(blocked, "below-window duplicate reinforcement should emit rejected evidence");
assert.ok(blocked.reasonCodes.includes("reinforcement_window_blocked"));
assert.ok(blocked.reasonCodes.includes("reinforcement_block_min_elapsed_window"));
assert.equal(blocked.reasonCodes.includes("reinforcement_block_min_interval"), false);
assert.equal(blocked.details?.blockReason, "min_elapsed_window");
assert.equal(blocked.details?.sameSession, false);
assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 1);
assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS);
assert.equal(blocked.details?.reinforcementCount, 1);
assert.equal(blocked.details?.maxReinforcementCount, 6);
assert.equal("minIntervalMs" in (blocked.details ?? {}), false);
assert.equal("reinforcementMode" in (blocked.details ?? {}), false);
});
test("dedupe reinforcement does not emit evidence at max reinforcement count", () => {
test("dedupe reinforcement blocks same session at 6d23h as min elapsed window", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing-same-session-window", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
pendingOwnerSessionID: "same-session-window",
reinforcementCount: 1,
lastReinforcedAt: now - (7 * DAY_MS - 60 * 60 * 1000),
lastReinforcedSessionID: "same-session-window",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-same-session-window", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "same-session-window",
};
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const blocked = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "rejected");
assert.ok(blocked, "6d23h same-session attempt should be rejected");
assert.equal(blocked.details?.blockReason, "min_elapsed_window");
assert.equal(blocked.details?.elapsedMs, 7 * DAY_MS - 60 * 60 * 1000);
assert.equal(blocked.details?.requiredElapsedMs, 7 * DAY_MS);
assert.equal(blocked.details?.sameSession, true);
assert.equal(blocked.reasonCodes.includes("reinforcement_block_same_session"), false);
});
test("dedupe reinforcement refreshes saturated count after rolling window", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const oldRetentionClock = now - 30 * DAY_MS;
const existing: LongTermMemoryEntry = {
...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 6,
lastReinforcedAt: now - 7 * DAY_MS,
lastReinforcedSessionID: "old-session",
retentionClock: oldRetentionClock,
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing-max");
const refreshed = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.equal(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "reinforced"), false);
assert.ok(result.evidence.some(event => event.type === "memory_reinforced" && event.outcome === "rejected" && event.details?.blockReason === "max_count"));
assert.equal(retained?.reinforcementCount, 6);
assert.equal(retained?.retentionClock, now);
assert.equal(retained?.lastReinforcedAt, now);
assert.equal(retained?.lastReinforcedSessionID, "new-session");
assert.ok(refreshed, "saturated after-window attempt should emit refreshed evidence");
assert.equal(refreshed.details?.reinforcementOutcome, "refreshed");
assert.equal(refreshed.details?.reinforcementMode, "refresh_only");
assert.equal(refreshed.details?.previousReinforcementCount, 6);
assert.equal(refreshed.details?.newReinforcementCount, 6);
assert.ok(refreshed.reasonCodes.includes("reinforcement_saturation_refresh"));
assert.equal(refreshed.reasonCodes.includes("reinforcement_block_max_count"), false);
});
test("dedupe reinforcement reports legacy missing timestamp and normalizes it", () => {
const now = Date.UTC(2026, 4, 15, 12, 0, 0);
const existing: LongTermMemoryEntry = {
...entry("existing-legacy-missing", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 2,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate-legacy-missing", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = withMockedDateNow(now, () => dedupeLongTermEntriesWithAccounting([existing, duplicate]));
const retained = result.kept.find(memory => memory.id === "existing-legacy-missing");
const reinforced = result.evidence.find(event => event.type === "memory_reinforced" && event.outcome === "reinforced");
assert.equal(retained?.reinforcementCount, 3);
assert.equal(retained?.lastReinforcedAt, now);
assert.equal(retained?.retentionClock, now);
assert.equal(reinforced?.details?.legacyMissingTimestamp, true);
assert.equal(reinforced?.details?.reinforcementMode, "increment");
});
test("enforceLongTermLimits orders entries by retention strength", () => {
+2 -10
View File
@@ -10,18 +10,10 @@
"rewriteRelativeImportExtensions": true,
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts", "src/**/*.ts"],
"include": ["index.ts", "scripts/memory-diag.ts", "scripts/memory-diag/**/*.ts", "src/**/*.ts"],
"exclude": [
"node_modules",
"dist",
"tests",
"src/plugin.ts",
"src/tui-plugin.ts",
"src/opencode.ts",
"src/session-state.ts",
"src/extractors.ts",
"src/pending-journal.ts",
"src/promotion-accounting.ts",
"src/memory-visibility.ts"
"tests"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true
}
}