Compare commits

...

6 Commits

Author SHA1 Message Date
Ralph Chang 1c6e143f4b chore(release): prepare v1.5.0 2026-04-29 16:56:47 +08:00
Ralph Chang 73384ca0a4 feat(memory): add retention model test gaps and health diagnostics
Wave 1 - P0 Test Gaps:
- Add hard stale prune removed regression test
- Add dormant overlap tests (entry created during dormancy)
- Add invalid timestamp NaN protection test
- Add reinforcement ordering test with reference type
- Add dedupe same-session/under-1hr guard tests
- Fix NaN handling with Number.isFinite check

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

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

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

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

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

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

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

A1 - Retention clock reset on reinforcement

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

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

Tests: 219 → 222, all pass
2026-04-29 14:32:39 +08:00
Ralph Chang 4097815f3e feat(memory): implement retention decay model with strength-based ordering
- Add retention model constants (45-day half-life, 6.0 safety factor)
- Add TYPE_MAX caps (feedback:10, decision:10, project:8, reference:6)
- Add strength calculation: initialStrength × 2^(-age/halfLife)
- Integrate strength-based sorting into enforceLongTermLimits
- Safety-critical entries bypass type caps
- Add fields: retentionClock, reinforcementCount, userImportance, safetyCritical
2026-04-29 14:18:51 +08:00
Ralph Chang bb7e4e2927 feat: add maintainer diagnostics for memory quality calibration
- health: inspect workspace memory store, pending journal, offline quality checks
- rejections: review extraction rejection log with origin inference
- audit: review migration logs with risky supersede heuristic
- Maintainer-only, offline, no telemetry, no API calls
2026-04-29 10:25:35 +08:00
18 changed files with 1993 additions and 182 deletions
+7 -7
View File
@@ -111,7 +111,7 @@ export type LongTermSource = "explicit" | "compaction" | "manual";
// ✅ USE: const assertions for limits
export const LONG_TERM_LIMITS = {
maxRenderedChars: 5200,
maxRenderedChars: 3600,
maxEntries: 28,
} as const;
```
@@ -140,8 +140,8 @@ const maxEntries = 28;
async function loadWorkspaceMemory() { }
// ✅ REQUIRED: SCREAMING_SNAKE_CASE for constants
const LONG_TERM_LIMITS = { maxRenderedChars: 5200, maxEntries: 28 };
const HOT_STATE_LIMITS = { maxRenderedChars: 1200 };
const LONG_TERM_LIMITS = { maxRenderedChars: 3600, maxEntries: 28 };
const HOT_STATE_LIMITS = { maxRenderedChars: 700 };
// ✅ REQUIRED: PascalCase for types
type WorkspaceMemoryStore = { ... };
@@ -236,7 +236,7 @@ export default {
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
- **Schema**: See `src/types.ts:WorkspaceMemoryStore`
- **Limits**: 5200 chars, 28 entries max
- **Limits**: 3600 chars, 28 entries max
### Session State Files
@@ -299,9 +299,9 @@ Extracts workspace memory candidates from conversation, applies quality gate and
## Performance Considerations
- **Workspace memory budget**: 5200 chars injected into system prompt
- **Session state budget**: 1200 chars injected into system prompt
- **Total overhead**: ~1500-6000 chars per message (minimal)
- **Workspace memory budget**: 3600 chars injected into system prompt
- **Session state budget**: 700 chars injected into system prompt
- **Total overhead**: typically well below configured maximums
- **Storage footprint**: ~2-5 KB per workspace for memory, ~1-3 KB per session
## Contributing
+32
View File
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.0] - 2026-04-29
### Added
- Strength-based workspace memory retention using exponential decay instead of additive priority scoring.
- Per-type rendered caps for workspace memory candidates: feedback 10, decision 10, project 8, and reference 6.
- Safety-critical memory weighting and type-cap exemption so important entries survive type floods while still competing under the global rendered cap.
- Dormant-workspace effective age: after 14 days without activity, additional dormant time counts at 0.25x for retention decay.
- Reinforcement tracking for repeated memories, with same-session and one-hour guards to prevent accidental reinforcement spam.
- Memory health diagnostics for stored vs rendered counts, type caps, global cap overflow, dormancy, retention monitoring, and strength-ranked top/weakest entries.
- CLI smoke tests and regression fixtures covering retention decay, stale-prune removal, type caps, reinforcement, invalid timestamps, and diagnostics.
### Changed
- Workspace memory rendering now ranks entries by retention strength, not the previous priority/penalty model.
- Confidence is retained for compatibility but no longer affects retention scoring.
- Old or stale-marked memories are no longer hard-pruned; they remain stored and only fall out of rendered context through strength and cap competition.
- Existing duplicate promotion and dedupe paths now reinforce the surviving memory instead of only absorbing the duplicate.
- Health output now separates stored active memories from rendered candidates to make cap behavior easier to understand.
- Default prompt budgets are lower after calibration against observed rendered output: workspace memory is 3600 characters and hot session state is 700 characters.
### Fixed
- Invalid `updatedAt` or `retentionClock` values no longer produce `NaN` retention strength or unstable sorting.
- Dormant age calculation only discounts the dormant overlap since an entry was created, so new memories do not inherit old workspace dormancy.
- Type max totals above the global cap are handled correctly: the global rendered limit still wins.
### Not Included Yet
- Delete tombstones and explicit `supersedes` chain enforcement remain deferred follow-up work.
- Hot/warm/cold tiered storage remains a future v1.6 direction.
## [1.4.0] - 2026-04-28
### Added
+34 -10
View File
@@ -5,9 +5,9 @@
Automatic memory for OpenCode agents.
OpenCode Working Memory helps your agent keep useful context across compactions and sessions: project decisions, preferences, important references, active files, and unresolved errors.
Working memory is context that **remembers what matters, fades what changes, and stays out of the way.**
It works automatically, without manual memory tools or extra LLM/API calls.
OpenCode Working Memory preserves project decisions, preferences, and references across compactions and sessions, while keeping active files and unresolved errors fresh for the current session — with no manual tools or extra LLM/API calls.
## Why This Exists
@@ -31,6 +31,7 @@ Use it when you want your agent to remember things like:
- **Compaction-based extraction** — memory extraction piggybacks on OpenCode’s existing compaction flow.
- **No manual tools** — memory is injected automatically into the system prompt.
- **Quality guards** — filters noisy memories, temporary progress snapshots, stack traces, raw errors, and credentials.
- **Retention decay** — keeps the strongest memories in prompt context while older or weaker memories fade out naturally; important and reinforced memories decay more slowly.
## Installation
@@ -121,6 +122,27 @@ Memory types:
- `decision` — important implementation or architecture decisions
- `reference` — useful paths, commands, or configuration references
### Retention Decay
> **Memory should fade, so the agent can keep learning.**
>
> Important memories decay more slowly, but every memory must leave room for newer project reality.
Memories decay over time. The strongest stay visible in the prompt; weaker ones fade from context without being deleted.
```text
strength
│
██ │╲____ reinforced: slower decline
│ ╲______
▒▒ │ ╲__ ordinary memory
│ ╲
├ ─ ─ ─ ─ ─ ─ ─ ─╲─ dynamic cap competition zone
░░ │ ╲ easier for new memories to replace
│ ↑ still stored, not deleted
└──────────────────────────────→ time / sessions
```
## Explicit Memory Triggers
You can explicitly ask the agent to remember durable facts.
@@ -167,13 +189,15 @@ It includes guards for:
- Credential redaction
- Duplicate memory cleanup
- Superseding older decisions with newer ones
- Consolidation accounting so promoted, absorbed, superseded, and rejected memories are handled differently
- Accounting for promoted, absorbed, superseded, and rejected memories
- Strength-based retention so useful memories stay visible without hard age pruning
- Filtering stack traces, git hashes, raw errors, and noisy path-heavy facts
- Rejecting temporary project progress snapshots
The goal is to remember durable facts, not every detail.
**Good memory is selective memory.**
Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y".
For local development cleanup, use:
@@ -191,21 +215,21 @@ OpenCode Working Memory works out of the box.
Default behavior:
- Workspace memory budget: 5200 characters
- Workspace memory budget: 3600 characters (~900 tokens)
- Workspace memory limit: 28 entries
- Hot session state budget: 1200 characters
- Hot session state budget: 700 characters (~175 tokens)
- Active files shown: 8
- Open errors shown: 3
See [Configuration](docs/configuration.md) for customization options.
## Ongoing Work
## Roadmap
Current focus:
- Improve memory recording quality so only durable, useful facts are kept.
- Strengthen deduplication and supersession so stale memories do not pile up.
- Add better forgetting behavior for obsolete decisions, preferences, and project facts.
- Add explicit delete tombstones so removed memories do not get re-extracted.
- Enforce explicit `supersedes` chains for safer replacement of obsolete memories.
- Explore tiered hot/warm/cold storage after the retention model has more real-world data.
## Documentation
+101
View File
@@ -1,5 +1,106 @@
# Release Notes
## 1.5.0 (2026-04-29)
### Retention Decay Model
This release changes workspace memory retention from hard stale pruning and additive priority scoring to a strength-based decay model.
Think of it like a forgetting curve: memories fade over time, but important, reinforced, and safety-critical memories decay slower. Weak entries fall out of rendered prompt context by cap competition, not hard deletion.
> **Memory should fade, so the agent can keep learning.**
> Important memories decay slower, but every memory must leave room for newer project reality and avoid long-term memory pollution.
```text
strength
│
██ │╲____ reinforced: slower decline
│ ╲______
▒▒ │ ╲__ ordinary memory
│ ╲
├ ─ ─ ─ ─ ─ ─ ─ ─╲─ dynamic cap competition zone
░░ │ ╲ easier for new memories to replace
│ ↑ still stored, not deleted
└──────────────────────────────→ time / sessions
```
### What Changed
- **Strength-based retention**: workspace memory now uses exponential decay: initial strength × age decay.
- **Better initial strength**: type, source, user importance, and safety-critical status now determine how strong a memory starts.
- **No confidence scoring**: confidence remains in stored data for compatibility, but it no longer affects retention ranking.
- **Type caps**: rendered workspace memory now caps feedback, decisions, project facts, and references separately so one type cannot monopolize all 28 slots.
- **Safety-critical protection**: safety-critical entries get stronger retention and are exempt from per-type caps, while still competing under the global rendered cap.
- **Dormant-aware age**: after 14 inactive days, additional dormant workspace time counts at 0.25x so paused projects do not forget too aggressively.
- **Reinforcement**: repeated matching memories reinforce the survivor and slow future decay, with same-session and one-hour guards to avoid accidental spam.
- **No hard stale pruning**: old or stale-marked memories are no longer automatically dropped by age; they lose rendered space only through cap competition.
- **Calibrated prompt budgets**: observed rendered output was typically under ~2000 characters for workspace memory and ~500 characters for hot session state, so defaults were reduced to 3600 and 700 characters to keep overhead lower while retaining buffer.
- **Clearer health output**: `memory-diag health` now reports stored vs rendered counts, type caps, global cap overflow, dormancy, retention monitoring, and strength-ranked top/weakest entries.
### Why This Helps
- User preferences and explicit memories are less likely to disappear just because inferred project facts are newer.
- Feedback, decisions, project facts, and references share prompt space more fairly.
- Returning to an old workspace is less punishing because dormant time decays more slowly.
- Maintainers can see why memories are rendered or capped instead of guessing from a single active-memory count.
- Stale entries can fade out of prompt context without destructive cleanup.
### Diagnostics
Maintainers can inspect retention behavior with:
```bash
bun scripts/memory-diag.ts health
```
The health output now includes sections like:
```txt
Stored active memories: 28
Rendered candidates: 20
By type:
feedback stored=17 rendered=10 typeCap=10
decision stored=11 rendered=10 typeCap=10
Retention caps:
type-capped entries: 8
global-cap overflow: 0
Dormancy:
dormant discount active: no
Retention monitoring:
high_importance_ratio: 0.0% (alert > 30%)
```
### Not Included Yet
- Delete tombstones are not implemented in this release.
- Explicit `supersedes` chain enforcement is still deferred.
- Hot/warm/cold tiered storage remains future work.
### Upgrade Notes
- No configuration changes required.
- Existing workspace memory files remain compatible.
- Existing entries without a `retentionClock` fall back safely to existing timestamps.
- The OpenCode config entry stays the same:
```json
{
"plugin": ["opencode-working-memory"]
}
```
### Validation
- `npm run typecheck`
- `npm test` — 237 tests passing
- `bun scripts/memory-diag.ts health`
---
## 1.4.0 (2026-04-28)
### Memory Quality Cleanup
+41 -11
View File
@@ -10,7 +10,7 @@ OpenCode Working Memory implements a **three-layer memory architecture** designe
│ • Persistent storage: ~/.local/share/opencode-working-... │
│ • Types: feedback | project | decision | reference │
│ • Sources: explicit | compaction | manual │
│ • Limits: 5200 chars / 28 entries │
│ • Render limits: 3600 chars / 28 entries │
│ • Survives: session reset, compaction (same workspace) │
└─────────────────────────────────────────────────────────────┘
↓
@@ -48,8 +48,9 @@ Long-term memory that persists across sessions within the same workspace. Perfec
{
version: 1,
workspace: { root: string, key: string },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
limits: { maxRenderedChars: 3600, maxEntries: 28 },
entries: LongTermMemoryEntry[],
lastActivityAt?: string,
updatedAt: string
}
```
@@ -90,18 +91,47 @@ Memory candidates:
- Path-heavy facts (>50% paths)
- Very short text (<20 chars)
### Consolidation and Deduplication
### Consolidation, Deduplication, and Retention
Memories are deduplicated and consolidated with accounting:
1. Normalize exact text: lowercase, strip punctuation, collapse whitespace.
2. Group project/reference entries by identity where possible.
3. Group decisions and feedback by topic where possible.
4. Keep the best surviving entry by source, confidence, type, and freshness rules.
3. Keep decision and feedback entries on exact canonical matching to avoid broad semantic merges.
4. Keep the best surviving entry by source, confidence, specificity, and freshness tie-breakers.
5. Emit accounting events so pending memories can be classified as promoted, absorbed, superseded, or rejected.
This prevents absorbed or superseded pending memories from retrying forever while still preserving the active surviving memory.
Retention then decides which active memories are rendered into prompt context. It does not hard-delete old memories by age.
```typescript
strength = initialStrength * 2 ** (-effectiveAgeDays / effectiveHalfLifeDays)
```
Initial strength is based on memory type, source, optional user importance, and safety-critical status. Confidence remains stored for compatibility but is not part of retention scoring.
Rendered candidates are selected in this order:
1. Exclude `status: "superseded"` entries.
2. Compute current retention strength.
3. Sort by strength descending.
4. Apply per-type caps, with safety-critical entries exempt from type caps.
5. Keep the top 28 rendered entries under the workspace memory character budget.
Default type caps:
| Type | Rendered cap |
|------|--------------|
| `feedback` | 10 |
| `decision` | 10 |
| `project` | 8 |
| `reference` | 6 |
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.
### System Prompt Injection
Workspace memory is injected at the top of every message:
@@ -241,7 +271,7 @@ Applies quality gate, redaction, migration, consolidation accounting, deduplicat
- `session.compacted`: Promote session decisions to workspace memory
- `session.deleted`: Clean up session state files
Promotion uses accounting results from workspace memory normalization. Pending memories that are kept are promoted; duplicate memories are absorbed; obsolete same-topic memories are superseded; stale or over-capacity compaction memories are rejected.
Promotion uses accounting results from workspace memory normalization. Pending memories that are kept are promoted; duplicate memories are absorbed; exact decision replacements can be superseded; over-capacity compaction memories are rejected. Stale-marked memories are not hard-pruned by age; they lose rendered space through retention strength and cap competition.
## Quality Guarantees
@@ -319,14 +349,14 @@ const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
| Layer | Max Chars | Max Entries |
|-------|-----------|-------------|
| Workspace Memory | 5200 | 28 |
| Hot Session State | 1200 | 8 files, 3 errors |
| Workspace Memory | 3600 | 28 |
| Hot Session State | 700 | 8 files, 3 errors |
### Injection Overhead
- Workspace memory: ~200-500 chars per message
- Hot session state: ~200-400 chars per message
- Total: ~400-900 chars per message (minimal)
- Workspace memory: usually under ~2000 chars in observed rendered output
- Hot session state: usually under ~500 chars in observed rendered output
- Total: typically well below the configured maximums
### Storage Footprint
+48 -11
View File
@@ -8,8 +8,8 @@ OpenCode Working Memory works out-of-the-box with sensible defaults. Configurati
```typescript
const LONG_TERM_LIMITS = {
maxRenderedChars: 5200, // Maximum characters in system prompt
targetRenderedChars: 4200, // Target characters (leave buffer)
maxRenderedChars: 3600, // Maximum characters in system prompt
targetRenderedChars: 3000, // Target characters (leave buffer)
maxEntries: 28, // Maximum number of entries
maxEntryTextChars: 260, // Maximum characters per entry text
maxRationaleChars: 180, // Maximum characters per entry rationale
@@ -18,14 +18,40 @@ const LONG_TERM_LIMITS = {
**Recommendations**:
- Keep `maxRenderedChars` under 5500 to avoid context bloat
- Defaults are calibrated from observed rendered usage that was typically under ~2000 characters
- `maxEntries` of 28 provides good coverage without overwhelming
- Entry text limits ensure entries stay concise
## Retention Model Defaults
Workspace memory retention uses strength-based decay. These constants live in `src/workspace-memory.ts`:
```typescript
const BASE_HALF_LIFE_DAYS = 45;
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
const REINFORCEMENT_MAX_COUNT = 6;
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
const DORMANT_DECAY_MULTIPLIER = 0.25;
```
Initial strength uses type, source, user importance, and safety-critical factors. Confidence is stored for compatibility but is not used for retention scoring.
Rendered type caps prevent one type from filling all workspace memory slots:
| Type | Rendered cap |
|------|--------------|
| `feedback` | 10 |
| `decision` | 10 |
| `project` | 8 |
| `reference` | 6 |
Safety-critical memories are exempt from type caps but still compete under the global `maxEntries` limit. Old or stale-marked memories are not hard-pruned by age; they lose rendered space through strength and cap competition.
## Hot Session State Limits
```typescript
const HOT_STATE_LIMITS = {
maxRenderedChars: 1200, // Maximum characters in system prompt
maxRenderedChars: 700, // Maximum characters in system prompt
maxActiveFilesStored: 20, // Maximum files tracked in state
maxActiveFilesRendered: 8, // Maximum files shown in prompt
maxOpenErrorsStored: 5, // Maximum errors tracked
@@ -36,6 +62,7 @@ const HOT_STATE_LIMITS = {
**Recommendations**:
- Keep `maxRenderedChars` under 1500 for fast prompts
- Defaults are calibrated from observed rendered usage around ~500 characters or less
- `maxActiveFilesRendered` of 8 provides good context coverage
- `maxOpenErrorsRendered` of 3 avoids overwhelming error lists
@@ -43,12 +70,12 @@ const HOT_STATE_LIMITS = {
### Long-Term Memory Types
| Type | Purpose | Stale After (days) |
|------|---------|---------------------|
| `feedback` | User preferences for workspace | 90 |
| `project` | Project-level information | 60 |
| `decision` | Important decisions | 45 |
| `reference` | Key references | 90 |
| Type | Purpose | Rendered cap |
|------|---------|--------------|
| `feedback` | User preferences for workspace | 10 |
| `project` | Project-level information | 8 |
| `decision` | Important decisions | 10 |
| `reference` | Key references | 6 |
### Memory Sources
@@ -114,7 +141,7 @@ To customize limits, edit the constants in `src/types.ts`:
```typescript
// Example: Increase workspace memory limit
export const LONG_TERM_LIMITS = {
maxRenderedChars: 6000, // Increased from 5200
maxRenderedChars: 6000, // Increased from 3600
maxEntries: 35, // Increased from 28
// ...
};
@@ -144,7 +171,7 @@ const HOT_STATE_LIMITS = {
// Preserve more context
const LONG_TERM_LIMITS = {
maxEntries: 40, // Increased
targetRenderedChars: 5000, // Increased
targetRenderedChars: 5000, // Increased
};
```
@@ -175,6 +202,16 @@ cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json |
cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
```
### Inspect Retention Health
From a source checkout, maintainers can inspect stored vs rendered memory behavior:
```bash
bun scripts/memory-diag.ts health
```
The health output includes stored active memories, rendered candidates, type caps, global cap overflow, dormancy status, retention monitoring alerts, and strength-ranked top/weakest entries.
### Clear Workspace Memory
```bash
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-working-memory",
"version": "1.4.0",
"version": "1.5.0",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
+718
View File
@@ -0,0 +1,718 @@
#!/usr/bin/env bun
/**
* Maintainer-only offline diagnostics for memory quality calibration.
* Does not send telemetry, make API calls, or affect plugin runtime behavior.
*/
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { assessMemoryQuality, HARD_QUALITY_REASONS } from "../src/memory-quality.ts";
import { redactCredentials } from "../src/redaction.ts";
import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts";
import { calculateRetentionStrength, renderWorkspaceMemory } from "../src/workspace-memory.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts";
type Command = "health" | "rejections" | "audit";
type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
type CliOptions = {
raw: boolean;
workspace?: string;
all?: boolean;
softOnly?: boolean;
triggerOnly?: boolean;
since?: string;
migration?: string;
};
type RejectionLogRecord = {
timestamp?: string;
workspaceKey?: string;
workspaceRoot?: string;
type?: LongTermType;
source?: LongTermSource | string;
origin?: string;
fromTrigger?: boolean;
text?: string;
reasons?: string[];
};
type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
workspaceKey?: string;
workspaceRoot?: string;
source?: string;
origin: Origin;
fromTrigger: boolean;
};
type MigrationLogRecord = {
migrationId?: string;
timestamp?: string;
workspaceKey?: string;
workspaceRoot?: string;
entryId?: string;
type?: LongTermType;
source?: LongTermSource | string;
text?: string;
reasons?: string[];
hardReasons?: string[];
beforeStatus?: string;
afterStatus?: string;
};
const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
const TYPE_MAX_FOR_DIAG: Record<LongTermType, number> = {
feedback: 10,
decision: 10,
project: 8,
reference: 6,
};
const WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG = 14;
const DORMANT_DECAY_MULTIPLIER_FOR_DIAG = 0.25;
const SUSPICIOUS_REASONS = [
"progress_snapshot",
"active_file_snapshot",
"commit_or_ci_snapshot",
"temporary_status",
"raw_error",
"code_or_api_signature",
] as const;
const ALLOWED_ORIGINS = new Set<Origin>([
"explicit_trigger",
"compaction_candidate",
"manual",
"migration_check",
"unknown",
]);
function usage(): string {
return `Usage:
bun scripts/memory-diag.ts health [--workspace <path>] [--all] [--raw]
bun scripts/memory-diag.ts rejections [--soft-only] [--trigger-only] [--since 14d] [--raw]
bun scripts/memory-diag.ts audit [--migration <id>] [--raw]
`;
}
function die(message: string): never {
console.error(message);
console.error(usage());
process.exit(1);
}
function parseArgs(argv: string[]): { command: Command; options: CliOptions } {
const [command, ...rest] = argv;
if (!command || command === "--help" || command === "-h") {
console.log(usage());
process.exit(0);
}
if (command !== "health" && command !== "rejections" && command !== "audit") {
die(`Unknown subcommand: ${command}`);
}
const options: CliOptions = { raw: false };
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (arg === "--raw") options.raw = true;
else if (arg === "--all") options.all = true;
else if (arg === "--soft-only") options.softOnly = true;
else if (arg === "--trigger-only") options.triggerOnly = true;
else if (arg === "--workspace") {
const value = rest[++i];
if (!value) die("--workspace requires a path");
options.workspace = value;
} else if (arg === "--since") {
const value = rest[++i];
if (!value) die("--since requires a duration or ISO timestamp");
options.since = value;
} else if (arg === "--migration") {
const value = rest[++i];
if (!value) die("--migration requires an id");
options.migration = value;
} else {
die(`Unknown option: ${arg}`);
}
}
if (command === "health") {
if (options.all && options.workspace) die("Use either --all or --workspace, not both");
} else {
if (options.all || options.workspace) die(`${command} does not accept --all or --workspace`);
}
if (command !== "rejections" && (options.softOnly || options.triggerOnly || options.since)) {
die(`${command} does not accept rejection filters`);
}
if (command !== "audit" && options.migration) {
die(`${command} does not accept --migration`);
}
return { command, options };
}
function countBy<T extends string>(items: T[]): Map<T, number> {
const counts = new Map<T, number>();
for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1);
return counts;
}
function sortedCounts<T extends string>(counts: Map<T, number>): Array<[T, number]> {
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
function workspaceRootHash(root: string): string {
return createHash("sha256").update(root).digest("hex").slice(0, 16);
}
function redactAbsolutePaths(text: string): string {
return text.replace(/(?:^|[\s"'`(=:\[])(\/(?:Users|home|private|tmp|var|opt|Volumes|[^\s"'`)\],;:]+)\/[^\s"'`)\],;:]*)/g, (match, path) => match.replace(path, "<path>"));
}
function cleanText(text: string, raw: boolean): string {
if (raw) return text;
return redactAbsolutePaths(redactCredentials(text));
}
function cleanPath(path: string, raw: boolean): string {
return raw ? path : "<path>";
}
function formatWorkspaceIdentity(workspaceKeyValue: string | undefined, workspaceRoot: string | undefined, raw: boolean): string {
const parts: string[] = [];
if (workspaceKeyValue) parts.push(`workspaceKey=${workspaceKeyValue}`);
if (workspaceRoot) {
parts.push(raw ? `workspaceRoot=${workspaceRoot}` : `workspaceRootHash=${workspaceRootHash(workspaceRoot)}`);
}
return parts.join(" ");
}
function truncate(text: string, max = 120): string {
const collapsed = text.replace(/\s+/g, " ").trim();
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
}
async function readJSONFile<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch {
return null;
}
}
async function readJSONLFile<T>(path: string): Promise<{ records: T[]; invalidLines: number }> {
let content = "";
try {
content = await readFile(path, "utf8");
} catch {
return { records: [], invalidLines: 0 };
}
const records: T[] = [];
let invalidLines = 0;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
records.push(JSON.parse(trimmed) as T);
} catch {
invalidLines += 1;
}
}
return { records, invalidLines };
}
function canonicalMemoryText(text: string): string {
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[\s\p{P}]+/gu, " ")
.trim();
}
function ageDays(entry: LongTermMemoryEntry): number | null {
const time = new Date(entry.createdAt).getTime();
if (Number.isNaN(time)) return null;
return Math.floor((Date.now() - time) / 86_400_000);
}
function formatStrength(value: number): string {
return Number.isFinite(value) ? value.toFixed(3) : "0.000";
}
function daysSinceIso(value: string | undefined, now = Date.now()): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
if (!Number.isFinite(ms)) return null;
return Math.max(0, (now - ms) / 86_400_000);
}
function formatPercent(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
type RetentionDiagItem = {
entry: LongTermMemoryEntry;
strength: number;
};
function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean {
return entry.safetyCritical === true;
}
function retentionCandidatesForDiag(store: WorkspaceMemoryStore): {
sorted: RetentionDiagItem[];
rendered: RetentionDiagItem[];
typeCapped: RetentionDiagItem[];
globalCapped: RetentionDiagItem[];
} {
const now = Date.now();
const active = store.entries.filter(entry => entry.status !== "superseded");
const sorted = active
.map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) }))
.sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id));
const rendered: RetentionDiagItem[] = [];
const typeCapped: RetentionDiagItem[] = [];
const globalCapped: RetentionDiagItem[] = [];
const typeCounts: Partial<Record<LongTermType, number>> = {};
for (const item of sorted) {
if (!isSafetyCriticalForDiag(item.entry)) {
const count = typeCounts[item.entry.type] ?? 0;
const max = TYPE_MAX_FOR_DIAG[item.entry.type] ?? Infinity;
if (count >= max) {
typeCapped.push(item);
continue;
}
typeCounts[item.entry.type] = count + 1;
}
if (rendered.length < LONG_TERM_LIMITS.maxEntries) {
rendered.push(item);
} else {
globalCapped.push(item);
}
}
return { sorted, rendered, typeCapped, globalCapped };
}
function promotionLimit(source: LongTermSource): number {
if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts;
return PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
}
function emptyStore(root: string, key: string): WorkspaceMemoryStore {
return {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [],
migrations: [],
updatedAt: new Date(0).toISOString(),
};
}
function normalizedStore(store: WorkspaceMemoryStore | null, root: string, key: string): WorkspaceMemoryStore {
const fallback = emptyStore(root, key);
return {
...fallback,
...(store ?? {}),
workspace: store?.workspace ?? fallback.workspace,
limits: {
maxRenderedChars: store?.limits?.maxRenderedChars ?? fallback.limits.maxRenderedChars,
maxEntries: store?.limits?.maxEntries ?? fallback.limits.maxEntries,
},
entries: Array.isArray(store?.entries) ? store.entries : [],
migrations: Array.isArray(store?.migrations) ? store.migrations : [],
};
}
function normalizedJournal(journal: PendingMemoryJournalStore | null): PendingMemoryJournalStore {
return {
version: 1,
workspace: journal?.workspace ?? { root: "", key: "" },
entries: Array.isArray(journal?.entries) ? journal.entries : [],
updatedAt: journal?.updatedAt ?? new Date(0).toISOString(),
};
}
async function runHealth(options: CliOptions): Promise<void> {
if (options.all) {
const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 });
console.log("Workspace memory health");
console.log("");
if (scan.results.length === 0) {
console.log("No workspace stores found.");
return;
}
for (let i = 0; i < scan.results.length; i += 1) {
const result = scan.results[i];
if (i > 0) console.log("");
await printWorkspaceHealth({
root: result.root,
key: result.workspaceKey,
memoryPath: join(result.workspaceDir, "workspace-memory.json"),
pendingPath: join(result.workspaceDir, "workspace-pending-journal.json"),
raw: options.raw,
});
}
return;
}
const root = options.workspace ?? process.cwd();
const key = await workspaceKey(root);
await printWorkspaceHealth({
root,
key,
memoryPath: await workspaceMemoryPath(root),
pendingPath: await workspacePendingJournalPath(root),
raw: options.raw,
includeTitle: true,
});
}
async function printWorkspaceHealth(input: {
root?: string;
key: string;
memoryPath: string;
pendingPath: string;
raw: boolean;
includeTitle?: boolean;
}): Promise<void> {
if (input.includeTitle) {
console.log("Workspace memory health");
console.log("");
}
const rawStore = await readJSONFile<WorkspaceMemoryStore>(input.memoryPath);
const storeRoot = rawStore?.workspace?.root ?? input.root ?? "";
const storeKey = rawStore?.workspace?.key ?? input.key;
const store = normalizedStore(rawStore, storeRoot, storeKey);
const journal = normalizedJournal(await readJSONFile<PendingMemoryJournalStore>(input.pendingPath));
const identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw);
if (identity) console.log(identity);
console.log(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`);
console.log(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`);
if (!rawStore) console.log("memory store: missing or unreadable (treated as empty)");
if (!existsSync(input.pendingPath)) console.log("pending journal: missing (treated as empty)");
console.log("");
const active = store.entries.filter(entry => entry.status !== "superseded");
const superseded = store.entries.filter(entry => entry.status === "superseded");
const retention = retentionCandidatesForDiag(store);
const renderedEntries = retention.rendered.map(item => item.entry);
const renderedEstimate = renderWorkspaceMemory(store).length;
console.log(`Stored active memories: ${active.length}`);
console.log(`Superseded memories: ${superseded.length}`);
console.log(`Rendered candidates: ${renderedEntries.length}`);
console.log(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
console.log("");
const pendingEntries = journal.entries;
const retryable = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) < promotionLimit(entry.source)).length;
const nearRetryLimit = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) >= promotionLimit(entry.source) - 1).length;
const pendingBySource = countBy(pendingEntries.map(entry => entry.source));
console.log("Pending journal:");
console.log(` total: ${pendingEntries.length}`);
console.log(` retryable: ${retryable}`);
console.log(` near retry limit: ${nearRetryLimit}`);
console.log(" by source:");
for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) {
console.log(` ${source}: ${pendingBySource.get(source) ?? 0}`);
}
console.log("");
console.log("By type:");
for (const type of TYPES) {
const storedCount = active.filter(entry => entry.type === type).length;
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
const supersededCount = superseded.filter(entry => entry.type === type).length;
console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${TYPE_MAX_FOR_DIAG[type]} superseded=${supersededCount}`);
}
console.log("");
console.log("Retention caps:");
console.log(` type-capped entries: ${retention.typeCapped.length}`);
console.log(` global-cap overflow: ${retention.globalCapped.length}`);
console.log("");
const olderThan30 = active.filter(entry => (ageDays(entry) ?? 0) > 30).length;
const olderThan90 = active.filter(entry => (ageDays(entry) ?? 0) > 90).length;
const staleMarked = active.filter(entry => {
const days = ageDays(entry);
return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays);
}).length;
console.log("Age:");
console.log(` stale-marked: ${staleMarked}`);
console.log(` older than 30d: ${olderThan30}`);
console.log(` older than 90d: ${olderThan90}`);
console.log("");
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt);
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG;
const dormantDaysPastGrace = wallDaysSinceActivity === null
? 0
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG);
console.log("Dormancy:");
console.log(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
console.log(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
console.log(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
console.log(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER_FOR_DIAG}`);
console.log("");
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
const highImportanceAlert = highImportanceRatio > 0.3;
const safetyCriticalAlert = safetyCriticalCount > 5;
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
console.log("Retention monitoring:");
console.log(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
console.log(` safety_critical_count: ${safetyCriticalCount} (alert > 5)${safetyCriticalAlert ? " ALERT" : ""}`);
console.log(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
console.log("");
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
console.log("Quality warnings:");
console.log(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`);
console.log(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`);
console.log(` duplicate-ish exact canonical text: ${duplicateExtras}`);
console.log(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`);
console.log("");
console.log("Suspicious active memories:");
for (const reason of SUSPICIOUS_REASONS) {
console.log(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`);
}
const failingQuality = qualityByEntry.filter(item => !item.quality.accepted);
if (failingQuality.length > 0) {
console.log("");
console.log("Active memories failing offline quality checks:");
for (const item of failingQuality.slice(0, 8)) {
console.log(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`);
}
}
console.log("");
console.log("Top rendered candidates:");
const top = retention.rendered.slice(0, 5);
if (top.length === 0) {
console.log(" (none)");
} else {
for (const item of top) {
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
console.log("");
console.log("Weakest active memories:");
const weakest = retention.sorted.slice(-5).reverse();
if (weakest.length === 0) {
console.log(" (none)");
} else {
for (const item of weakest) {
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
}
function inferOrigin(record: RejectionLogRecord): Origin {
if (record.origin && ALLOWED_ORIGINS.has(record.origin as Origin)) return record.origin as Origin;
if (record.source === "compaction") return "compaction_candidate";
if (record.source === "explicit") return "explicit_trigger";
if (record.source === "manual") return "manual";
return "unknown";
}
function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | null {
if (!record.text || !Array.isArray(record.reasons)) return null;
const origin = inferOrigin(record);
return {
timestamp: record.timestamp ?? "",
workspaceKey: record.workspaceKey,
workspaceRoot: record.workspaceRoot,
type: record.type ?? "project",
source: record.source,
origin,
fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger",
text: record.text,
reasons: record.reasons,
};
}
function sinceCutoff(rawSince: string | undefined): number | null {
if (!rawSince) return null;
const relative = rawSince.match(/^(\d+)([dhm])$/i);
if (relative) {
const amount = Number(relative[1]);
const unit = relative[2].toLowerCase();
const multiplier = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : 60_000;
return Date.now() - amount * multiplier;
}
const timestamp = new Date(rawSince).getTime();
if (Number.isNaN(timestamp)) die(`Invalid --since value: ${rawSince}`);
return timestamp;
}
function hasSoftReason(record: NormalizedRejection): boolean {
return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason));
}
async function runRejections(options: CliOptions): Promise<void> {
const path = extractionRejectionLogPath();
const { records, invalidLines } = await readJSONLFile<RejectionLogRecord>(path);
const cutoff = sinceCutoff(options.since);
let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null);
if (cutoff !== null) {
normalized = normalized.filter(record => {
const timestamp = new Date(record.timestamp).getTime();
return !Number.isNaN(timestamp) && timestamp >= cutoff;
});
}
if (options.softOnly) normalized = normalized.filter(hasSoftReason);
if (options.triggerOnly) normalized = normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger");
console.log("Extraction rejection summary");
console.log("");
console.log(`logPath=${cleanPath(path, options.raw)}`);
if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`);
console.log("");
console.log(`Total rejected: ${normalized.length}`);
console.log("");
console.log("By reason:");
const byReason = sortedCounts(countBy(normalized.flatMap(record => record.reasons)));
if (byReason.length === 0) console.log(" (none)");
else for (const [reason, count] of byReason) console.log(` ${reason.padEnd(24)} ${count}`);
console.log("");
console.log("By origin:");
const byOrigin = sortedCounts(countBy(normalized.map(record => record.origin)));
if (byOrigin.length === 0) console.log(" (none)");
else for (const [origin, count] of byOrigin) console.log(` ${origin.padEnd(24)} ${count}`);
console.log("");
console.log("Trigger-origin rejections (high priority for v1.5):");
const triggerReasons = sortedCounts(countBy(normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons)));
if (triggerReasons.length === 0) console.log(" (none)");
else for (const [reason, count] of triggerReasons) console.log(` ${reason.padEnd(24)} ${count}`);
console.log("");
console.log("Recent suspicious soft rejects:");
const suspicious = normalized
.filter(hasSoftReason)
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
.slice(0, 8);
if (suspicious.length === 0) {
console.log(" (none)");
} else {
for (const record of suspicious) {
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
console.log(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, options.raw)))}`);
console.log(` reasons: ${record.reasons.join(",")}`);
console.log(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`);
}
}
}
function migrationLogsRoot(): string {
return join(dataHome(), "opencode-working-memory", "migration-logs");
}
async function migrationLogPaths(options: CliOptions): Promise<string[]> {
if (options.migration) return [migrationLogPath(options.migration)];
const root = migrationLogsRoot();
let entries: string[] = [];
try {
entries = await readdir(root);
} catch {
return [];
}
return entries.filter(entry => entry.endsWith(".jsonl")).sort().map(entry => join(root, entry));
}
function migrationIdFromPath(path: string): string {
return path.split("/").pop()?.replace(/\.jsonl$/, "") ?? "unknown";
}
function riskySupersedeReasons(record: MigrationLogRecord): string[] {
const reasons: string[] = [];
const hardReasonsMissing = !Array.isArray(record.hardReasons);
const hardReasons = Array.isArray(record.hardReasons) ? record.hardReasons : [];
const qualityReasons = Array.isArray(record.reasons) ? record.reasons : [];
const text = record.text ?? "";
if (hardReasonsMissing || hardReasons.length === 0) reasons.push("missing_or_empty_hardReasons");
if (qualityReasons.length > 0 && hardReasons.length === 0) reasons.push("soft_reasons_without_hardReasons");
if (/\b(?:User|user|prefers|requires|wants|insists)\b|用戶|使用者|偏好|要求|不要|不刪除/u.test(text)) reasons.push("user_preference_marker");
if (/\b(?:must|should|do not|never|is|are|follows)\b|必須|應該|採用|維持|需支援/iu.test(text)) reasons.push("durable_rule_marker");
if ((record.type === "feedback" || record.type === "decision") && hardReasons.length === 1 && hardReasons[0] === "path_heavy") {
reasons.push("feedback_or_decision_path_heavy_only");
}
return reasons;
}
async function runAudit(options: CliOptions): Promise<void> {
const paths = await migrationLogPaths(options);
console.log("Migration audit report");
console.log("");
if (paths.length === 0) {
console.log("No migration logs found.");
return;
}
for (let i = 0; i < paths.length; i += 1) {
const path = paths[i];
const migrationId = options.migration ?? migrationIdFromPath(path);
const { records, invalidLines } = await readJSONLFile<MigrationLogRecord>(path);
const superseded = records.filter(record => !record.afterStatus || record.afterStatus === "superseded");
const hardReasons = superseded.flatMap(record => {
if (Array.isArray(record.hardReasons)) return record.hardReasons;
return Array.isArray(record.reasons) ? record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)) : [];
});
const risky = superseded
.map(record => ({ record, reasons: riskySupersedeReasons(record) }))
.filter(item => item.reasons.length > 0);
if (i > 0) console.log("");
console.log(`Migration: ${migrationId}`);
console.log(`logPath=${cleanPath(path, options.raw)}`);
if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`);
console.log(`Superseded entries: ${superseded.length}`);
console.log("");
console.log("By hard reason:");
const byHardReason = sortedCounts(countBy(hardReasons));
if (byHardReason.length === 0) console.log(" (none)");
else for (const [reason, count] of byHardReason) console.log(` ${reason.padEnd(24)} ${count}`);
console.log("");
console.log("Potentially risky supersedes:");
console.log(` ${risky.length}`);
for (const item of risky.slice(0, 10)) {
const record = item.record;
const hard = Array.isArray(record.hardReasons) ? record.hardReasons : [];
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
console.log(` - [${record.type ?? "unknown"}] hardReasons=${JSON.stringify(hard)} risk=${item.reasons.join(",")} ${JSON.stringify(truncate(cleanText(record.text ?? "", options.raw)))}`);
if (identity) console.log(` ${identity}`);
}
}
}
const { command, options } = parseArgs(process.argv.slice(2));
if (command === "health") await runHealth(options);
else if (command === "rejections") await runRejections(options);
else await runAudit(options);
+6 -2
View File
@@ -68,7 +68,8 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
/(?:^|\n)\s*(?:my preference is|i prefer)[::,,]?\s*(.+)$/gim,
];
const now = new Date().toISOString();
const nowMs = Date.now();
const now = new Date(nowMs).toISOString();
const entries: LongTermMemoryEntry[] = [];
const seen = new Set<string>();
@@ -101,6 +102,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: nowMs,
staleAfterDays: staleAfterDaysFor(type),
});
}
@@ -317,7 +319,8 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
const block = extractCandidateBlock(summary);
if (!block) return [];
const now = new Date().toISOString();
const nowMs = Date.now();
const now = new Date(nowMs).toISOString();
const entries: LongTermMemoryEntry[] = [];
for (const line of block.split("\n")) {
@@ -348,6 +351,7 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: nowMs,
staleAfterDays: staleAfterDaysFor(type),
});
}
+27 -8
View File
@@ -36,6 +36,7 @@ import {
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
renderWorkspaceMemory,
reinforceMemory,
} from "./workspace-memory.ts";
import {
appendPendingMemories,
@@ -311,17 +312,35 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
beforeEntries = [...workspaceMemory.entries];
const existingKeys = new Set(
workspaceMemory.entries
.filter(memory => memory.status !== "superseded")
.map(memory => memoryKey(memory)),
);
const existingByKey = new Map<string, { memory: typeof workspaceMemory.entries[number]; index: number }>();
workspaceMemory.entries.forEach((memory, index) => {
if (memory.status === "superseded") return;
existingByKey.set(memoryKey(memory), { memory, index });
});
const promotedAt = Date.now();
for (const memory of pending) {
const key = memoryKey(memory);
if (!existingKeys.has(key)) {
workspaceMemory.entries.push(memory);
existingKeys.add(key);
const existing = existingByKey.get(key);
if (existing) {
const reinforced = reinforceMemory(
existing.memory,
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
promotedAt,
);
if (reinforced !== existing.memory) {
workspaceMemory.entries[existing.index] = reinforced;
existingByKey.set(key, { memory: reinforced, index: existing.index });
}
} else {
workspaceMemory.entries.push({
...memory,
retentionClock: memory.retentionClock ?? promotedAt,
});
existingByKey.set(key, {
memory: workspaceMemory.entries[workspaceMemory.entries.length - 1],
index: workspaceMemory.entries.length - 1,
});
}
}
+2 -5
View File
@@ -59,7 +59,7 @@ export function accountPendingPromotions(input: {
continue;
}
if (terminal.reason === "rejected_capacity" || terminal.reason === "rejected_stale") {
if (terminal.reason === "rejected_capacity") {
rejectedKeys.add(key);
continue;
}
@@ -80,10 +80,7 @@ export function accountPendingPromotions(input: {
...input.pending
.filter(memory => {
const terminal = terminalEventByKey.get(memoryKey(memory));
return memory.source === "compaction" && (
terminal?.reason === "rejected_capacity" ||
terminal?.reason === "rejected_stale"
);
return memory.source === "compaction" && terminal?.reason === "rejected_capacity";
})
.map(memory => memoryKey(memory)),
]);
+10 -3
View File
@@ -20,6 +20,12 @@ export type LongTermMemoryEntry = {
promotionAttempts?: number;
lastPromotionAttemptAt?: string;
lastPromotionFailureReason?: string;
retentionClock?: number; // Unix timestamp when retention started
reinforcementCount?: number; // Number of times this memory was reinforced
lastReinforcedAt?: number; // Unix timestamp of last reinforcement
lastReinforcedSessionID?: string;
userImportance?: "low" | "normal" | "high";
safetyCritical?: boolean;
};
export type WorkspaceMemoryStore = {
@@ -35,6 +41,7 @@ export type WorkspaceMemoryStore = {
entries: LongTermMemoryEntry[];
migrations?: string[];
updatedAt: string;
lastActivityAt?: string;
};
export type PendingMemoryJournalStore = {
@@ -88,15 +95,15 @@ export type SessionState = {
};
export const LONG_TERM_LIMITS = {
maxRenderedChars: 5200,
targetRenderedChars: 4200,
maxRenderedChars: 3600,
targetRenderedChars: 3000,
maxEntries: 28,
maxEntryTextChars: 260,
maxRationaleChars: 180,
} as const;
export const HOT_STATE_LIMITS = {
maxRenderedChars: 1200,
maxRenderedChars: 700,
maxActiveFilesStored: 20,
maxActiveFilesRendered: 8,
maxOpenErrorsStored: 5,
+208 -54
View File
@@ -12,13 +12,149 @@ const MIN_ENVELOPE_LENGTH = 80;
const MIGRATION_ID = "2026-04-26-p0-cleanup";
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
// Retention decay model constants (v1.5)
const BASE_HALF_LIFE_DAYS = 45;
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
const REINFORCEMENT_MAX_COUNT = 6;
const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
const DORMANT_DECAY_MULTIPLIER = 0.25;
const DAY_MS = 24 * 60 * 60 * 1000;
const TYPE_FACTOR = {
reference: 1.0,
project: 1.25,
feedback: 2.25,
decision: 2.5,
} as const;
const SOURCE_FACTOR = {
compaction: 1.0,
manual: 1.4,
explicit: 2.0,
} as const;
const USER_IMPORTANCE_FACTOR = {
low: 0.7,
normal: 1.0,
high: 1.5,
} as const;
const SAFETY_CRITICAL_FACTOR = 6.0;
const TYPE_MAX = {
feedback: 10,
decision: 10,
project: 8,
reference: 6,
} as const;
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
const safetyFactor = memory.safetyCritical ? SAFETY_CRITICAL_FACTOR : 1.0;
return typeFactor * sourceFactor * importanceFactor * safetyFactor;
}
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
const reinforcementCount = Math.min(
memory.reinforcementCount ?? 0,
REINFORCEMENT_MAX_COUNT,
);
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
return BASE_HALF_LIFE_DAYS / factor;
}
function timestampMs(value: unknown, fallback: number): number {
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
return Number.isFinite(ms) ? ms : fallback;
}
export function calculateRetentionStrength(
memory: LongTermMemoryEntry,
now: number,
lastActivityAt?: string,
): number {
const initialStrength = calculateInitialStrength(memory);
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
// Use retentionClock if available, fallback to updatedAt.
const retentionStart = Number.isFinite(memory.retentionClock)
? memory.retentionClock
: memory.updatedAt ?? memory.createdAt;
const createdAtMs = timestampMs(retentionStart, now);
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
// Calculate strength using exponential decay.
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
}
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
const lastActivity = store.lastActivityAt
? new Date(store.lastActivityAt).getTime()
: now;
if (!Number.isFinite(lastActivity)) return 0;
const daysSinceActivity = (now - lastActivity) / DAY_MS;
return Math.max(0, daysSinceActivity);
}
export function calculateEffectiveAgeDays(
entryStartMs: number,
now: number,
lastActivityAt?: string,
): number {
const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS);
if (!lastActivityAt) return wallAgeDays;
const lastActivityMs = new Date(lastActivityAt).getTime();
if (!Number.isFinite(lastActivityMs)) return wallAgeDays;
const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS;
const overlapStartMs = Math.max(entryStartMs, dormantStartMs);
const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS);
const activeDays = wallAgeDays - dormantOverlapDays;
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
}
export function reinforceMemory(
memory: LongTermMemoryEntry,
sessionId: string,
now: number,
): LongTermMemoryEntry {
if (memory.lastReinforcedSessionID === sessionId) {
return memory;
}
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
return memory;
}
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
return memory;
}
return {
...memory,
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
lastReinforcedAt: now,
lastReinforcedSessionID: sessionId,
retentionClock: now,
};
}
export type MemoryConsolidationReason =
| "promoted"
| "absorbed_exact"
| "absorbed_identity"
| "superseded_existing"
| "rejected_capacity"
| "rejected_stale";
| "rejected_capacity";
export type MemoryConsolidationEvent = {
memoryKey: string;
@@ -57,6 +193,7 @@ export type QualityCleanupMigrationLogEntry = {
};
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
@@ -66,7 +203,8 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
},
entries: [],
migrations: [],
updatedAt: new Date().toISOString(),
updatedAt: nowIso,
lastActivityAt: nowIso,
};
}
@@ -85,6 +223,7 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
lastActivityAt: loaded.lastActivityAt ?? loaded.updatedAt ?? fallback.lastActivityAt,
};
// Always normalize on load so redaction/migrations are always-on.
@@ -114,6 +253,7 @@ function hasSecurityOrMigrationChange(
const beforeMigrations = JSON.stringify(before.migrations ?? []);
const afterMigrations = JSON.stringify(after.migrations ?? []);
if ((before.lastActivityAt ?? "") !== (after.lastActivityAt ?? "")) return true;
return beforeMigrations !== afterMigrations;
}
@@ -221,12 +361,13 @@ export async function normalizeWorkspaceMemoryWithAccounting(
// archived as superseded records in this wave.
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
const accounting = enforceLongTermLimitsWithAccounting(activeEntries);
const accounting = enforceLongTermLimitsWithAccounting(activeEntries, result);
const normalizedStore = {
...result,
entries: [...accounting.kept, ...supersededEntries],
updatedAt: nowIso,
lastActivityAt: nowIso,
};
return {
@@ -447,19 +588,6 @@ function consolidationEvent(
};
}
/** Check if entry should be pruned by age (for compaction/manual entries only) */
function isPrunableByAge(entry: LongTermMemoryEntry, now: number): boolean {
// Never prune feedback or explicit entries
if (entry.type === "feedback") return false;
if (entry.source === "explicit") return false;
if (!entry.staleAfterDays) return false;
const createdAt = new Date(entry.createdAt).getTime();
const ageDays = (now - createdAt) / 86400000;
const grace = 30; // 30-day grace period
return ageDays > entry.staleAfterDays + grace;
}
/** Choose better memory when identity/topic keys conflict */
function chooseBetterMemory(
a: LongTermMemoryEntry,
@@ -495,24 +623,25 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
return enforceLongTermLimitsWithAccounting(entries).kept;
}
export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
export function enforceLongTermLimitsWithAccounting(
entries: LongTermMemoryEntry[],
store?: WorkspaceMemoryStore,
): LongTermLimitResult {
const now = Date.now();
const staleDropped: MemoryConsolidationEvent[] = [];
const lastActivityAt = store?.lastActivityAt;
// Phase 1: filter active, prune by age
// Phase 1: filter active entries and trim text. Retention removal is by
// strength/cap competition, not hard stale pruning.
const phase1: LongTermMemoryEntry[] = [];
for (const entry of entries) {
if (entry.status === "superseded") continue;
if (isPrunableByAge(entry, now)) {
staleDropped.push(consolidationEvent(entry, "rejected_stale"));
continue;
}
phase1.push({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) });
}
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
const sorted = [...dedupeResult.kept].sort(compareLongTermMemoryForRetention);
const kept = sorted.slice(0, LONG_TERM_LIMITS.maxEntries);
const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, lastActivityAt));
const capped = applyTypeMaxCaps(sorted);
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
const keptIds = new Set(kept.map(entry => entry.id));
const capacityDropped = sorted
.filter(entry => !keptIds.has(entry.id))
@@ -520,13 +649,35 @@ export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry
return {
kept,
dropped: [...staleDropped, ...dedupeResult.dropped, ...capacityDropped],
dropped: [...dedupeResult.dropped, ...capacityDropped],
absorbed: dedupeResult.absorbed,
superseded: dedupeResult.superseded,
};
}
function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
const capped: LongTermMemoryEntry[] = [];
const typeCounts: Partial<Record<LongTermMemoryEntry["type"], number>> = {};
for (const entry of entries) {
if (entry.safetyCritical) {
capped.push(entry);
continue;
}
const count = typeCounts[entry.type] ?? 0;
const max = TYPE_MAX[entry.type] ?? Infinity;
if (count >= max) continue;
capped.push(entry);
typeCounts[entry.type] = count + 1;
}
return capped;
}
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
const now = Date.now();
const absorbed: MemoryConsolidationEvent[] = [];
const superseded: MemoryConsolidationEvent[] = [];
@@ -547,12 +698,14 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "absorbed_identity" as const;
const reinforced = reinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
absorbed.push(consolidationEvent(dropped, reason, retained));
if (retained === entry) {
entityDeduped.set(key, entry);
}
absorbed.push(consolidationEvent(dropped, reason, reinforced));
entityDeduped.set(key, reinforced);
}
}
@@ -571,16 +724,19 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "superseded_existing" as const;
const reinforced = reinforceMemory(
retained,
reinforcementSessionId(retained, dropped),
now,
);
if (reason === "superseded_existing") {
superseded.push(consolidationEvent(dropped, reason, retained));
superseded.push(consolidationEvent(dropped, reason, reinforced));
} else {
absorbed.push(consolidationEvent(dropped, reason, retained));
absorbed.push(consolidationEvent(dropped, reason, reinforced));
}
if (retained === entry) {
decisionDeduped.set(key, entry);
}
decisionDeduped.set(key, reinforced);
}
}
@@ -598,10 +754,20 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
};
}
function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMemoryEntry): number {
const pA = priority(a);
const pB = priority(b);
if (pB !== pA) return pB - pA;
function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string {
return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe";
}
function compareLongTermMemoryForRetention(
a: LongTermMemoryEntry,
b: LongTermMemoryEntry,
now: number,
lastActivityAt?: string,
): number {
const strengthA = calculateRetentionStrength(a, now, lastActivityAt);
const strengthB = calculateRetentionStrength(b, now, lastActivityAt);
if (strengthB !== strengthA) return strengthB - strengthA;
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
if (sourceDiff !== 0) return sourceDiff;
const createdDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
@@ -609,18 +775,6 @@ function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMe
return a.id.localeCompare(b.id);
}
function priority(entry: LongTermMemoryEntry): number {
const typeWeight = {
feedback: 400,
decision: 300,
project: 200,
reference: 100,
}[entry.type];
const sourceWeight = entry.source === "explicit" ? 1000 : 0;
return sourceWeight + typeWeight + entry.confidence * 10;
}
function wouldFit(
lines: string[],
nextLine: string,
@@ -631,7 +785,7 @@ function wouldFit(
}
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
const active = enforceLongTermLimits(store.entries);
const active = enforceLongTermLimitsWithAccounting(store.entries, store).kept;
if (active.length === 0) return "";
const maxChars = Math.min(
+8
View File
@@ -91,9 +91,13 @@ test("extractExplicitMemories does not treat always as memory trigger", () => {
});
test("extractExplicitMemories still captures going forward", () => {
const before = Date.now();
const items = extractExplicitMemories("going forward: use pnpm instead of npm");
const after = Date.now();
assert.equal(items.length, 1);
assert.match(items[0].text, /pnpm/);
assert.ok(typeof items[0].retentionClock === "number");
assert.ok(items[0].retentionClock >= before && items[0].retentionClock <= after);
});
test("extractExplicitMemories captures from now on", () => {
@@ -200,14 +204,18 @@ test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
});
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
const before = Date.now();
const summary = `
## Memory Candidates
- [decision] Use pnpm instead of npm for package management
`;
const items = parseWorkspaceMemoryCandidates(summary);
const after = Date.now();
assert.equal(items.length, 1);
assert.equal(items[0].type, "decision");
assert.match(items[0].text, /pnpm/);
assert.ok(typeof items[0].retentionClock === "number");
assert.ok(items[0].retentionClock >= before && items[0].retentionClock <= after);
});
test("parseWorkspaceMemoryCandidates accepts valid project info", () => {
+143
View File
@@ -0,0 +1,143 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
const execFileAsync = promisify(execFile);
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
function entry(id: string, text: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
id,
type,
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
};
}
async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[], options: { lastActivityAt?: string; omitLastActivityAt?: boolean } = {}): Promise<void> {
const key = await workspaceKey(root);
const path = await workspaceMemoryPath(root);
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries,
migrations: [],
updatedAt: now,
};
if (!options.omitLastActivityAt) store.lastActivityAt = options.lastActivityAt ?? now;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
async function runMemoryDiagHealth(root: string): Promise<string> {
const { stdout } = await execFileAsync(process.execPath, [
"--experimental-strip-types",
"scripts/memory-diag.ts",
"health",
"--workspace",
root,
], { cwd: repoRoot });
return stdout;
}
test("memory health reports stored vs rendered retention counts", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 17 }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference for memory health ${i}`, "feedback")),
...Array.from({ length: 11 }, (_, i) => entry(`decision-${i}`, `Unique durable decision for memory health ${i}`, "decision")),
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Stored active memories:/);
assert.match(stdout, /Rendered candidates:/);
assert.match(stdout, /feedback\s+stored=17\s+rendered=10/);
assert.match(stdout, /Top rendered candidates:\n\s+- strength=/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports dormancy and retention monitoring alerts", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const lastActivityAt = new Date(Date.now() - 19 * 24 * 60 * 60 * 1000).toISOString();
const entries = Array.from({ length: 10 }, (_, i) => ({
...entry(`monitoring-${i}`, `Unique monitoring memory ${i} for retention health`, i % 2 === 0 ? "feedback" : "decision"),
userImportance: i < 4 ? "high" as const : "normal" as const,
safetyCritical: i < 6,
reinforcementCount: i < 2 ? 6 : 0,
}));
await writeWorkspaceStore(root, entries, { lastActivityAt });
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Dormancy:/);
assert.match(stdout, /wall days since activity: 19\.0/);
assert.match(stdout, /dormant discount active: yes/);
assert.match(stdout, /dormant days past grace: 5\.0/);
assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
assert.match(stdout, /safety_critical_count: 6 .* ALERT/);
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports global cap overflow separately from type caps", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 10 }, (_, i) => entry(`global-feedback-${i}`, `Unique global feedback preference ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`global-decision-${i}`, `Unique global durable decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`global-project-${i}`, `Unique global project fact ${i}`, "project")),
...Array.from({ length: 6 }, (_, i) => entry(`global-reference-${i}`, `Unique global reference fact ${i}`, "reference")),
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Rendered candidates: 28/);
assert.match(stdout, /type-capped entries: 0/);
assert.match(stdout, /global-cap overflow: 6/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports missing dormancy and non-alert monitoring defaults", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
await writeWorkspaceStore(root, [], { omitLastActivityAt: true });
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /lastActivityAt: \(missing\)/);
assert.match(stdout, /wall days since activity: unknown/);
assert.match(stdout, /dormant discount active: no/);
assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
assert.match(stdout, /safety_critical_count: 0 \(alert > 5\)\n/);
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+167 -8
View File
@@ -772,6 +772,69 @@ test("session.compacted promotes pending memories to workspace memory and clears
const workspacePrompt = after.system.find((part: string) => part.startsWith("Workspace memory"));
assert.match(workspacePrompt ?? "", /Use frozen rendered snapshots for cache stability/);
const workspace = await loadWorkspaceMemory(tmpDir);
const promoted = workspace.entries.find(entry => entry.id === "mem_pending_1");
assert.ok(typeof promoted?.retentionClock === "number",
"legacy pending memory should receive a retention clock when promoted");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session.compacted reinforces existing exact workspace memory", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const now = new Date().toISOString();
const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000;
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "existing-memory",
type: "decision",
text: "Use frozen rendered snapshots for cache stability.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: oldRetentionClock,
});
return store;
});
await saveSessionState(tmpDir, {
version: 1,
sessionID: "reinforce-existing-session",
turn: 1,
updatedAt: now,
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [{
id: "pending-duplicate",
type: "decision",
text: "Use frozen rendered snapshots for cache stability.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
}],
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "reinforce-existing-session" } },
});
const workspace = await loadWorkspaceMemory(tmpDir);
const existing = workspace.entries.find(entry => entry.id === "existing-memory");
assert.equal(workspace.entries.filter(entry => /frozen rendered/.test(entry.text)).length, 1);
assert.equal(existing?.reinforcementCount, 1);
assert.equal(existing?.lastReinforcedSessionID, "reinforce-existing-session");
assert.ok((existing?.retentionClock ?? 0) > oldRetentionClock);
assert.equal((await loadSessionState(tmpDir, "reinforce-existing-session")).pendingMemories.length, 0);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
@@ -1422,9 +1485,9 @@ test("session.compacted clears compaction pending memory rejected by workspace e
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
for (let i = 0; i < 28; i += 1) {
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_${i}`,
id: `mem_high_feedback_${i}`,
type: "feedback",
text: `High priority user feedback memory ${i} that should outrank low priority references.`,
source: "explicit",
@@ -1434,6 +1497,30 @@ test("session.compacted clears compaction pending memory rejected by workspace e
updatedAt: now,
});
}
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_decision_${i}`,
type: "decision",
text: `High priority decision memory ${i} that should outrank low priority references.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
for (let i = 0; i < 8; i += 1) {
store.entries.push({
id: `mem_high_project_${i}`,
type: "project",
text: `High priority project memory ${i} that should outrank low priority references.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
@@ -1476,9 +1563,9 @@ test("session.compacted keeps explicit pending memory rejected by workspace entr
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
for (let i = 0; i < 28; i += 1) {
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_explicit_reject_${i}`,
id: `mem_high_explicit_reject_feedback_${i}`,
type: "feedback",
text: `Pinned high priority feedback for explicit reject ${i}.`,
source: "explicit",
@@ -1488,6 +1575,30 @@ test("session.compacted keeps explicit pending memory rejected by workspace entr
updatedAt: now,
});
}
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_explicit_reject_decision_${i}`,
type: "decision",
text: `Pinned high priority decision for explicit reject ${i}.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
for (let i = 0; i < 8; i += 1) {
store.entries.push({
id: `mem_high_explicit_reject_project_${i}`,
type: "project",
text: `Pinned high priority project for explicit reject ${i}.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
@@ -1531,9 +1642,9 @@ test("explicit capacity rejection records bounded retry metadata", async () => {
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
for (let i = 0; i < 28; i += 1) {
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_bounded_reject_${i}`,
id: `mem_high_bounded_reject_feedback_${i}`,
type: "feedback",
text: `Pinned high priority feedback for bounded rejection ${i}.`,
source: "explicit",
@@ -1543,6 +1654,30 @@ test("explicit capacity rejection records bounded retry metadata", async () => {
updatedAt: now,
});
}
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_bounded_reject_decision_${i}`,
type: "decision",
text: `Pinned high priority decision for bounded rejection ${i}.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
for (let i = 0; i < 8; i += 1) {
store.entries.push({
id: `mem_high_bounded_reject_project_${i}`,
type: "project",
text: `Pinned high priority project for bounded rejection ${i}.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
@@ -1614,9 +1749,9 @@ test("session.compacted clears compaction pending memories when all rejected by
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
for (let i = 0; i < 28; i += 1) {
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_all_rejected_${i}`,
id: `mem_high_all_rejected_feedback_${i}`,
type: "feedback",
text: `Pinned high priority feedback ${i} that keeps the workspace entry cap full.`,
source: "explicit",
@@ -1626,6 +1761,30 @@ test("session.compacted clears compaction pending memories when all rejected by
updatedAt: now,
});
}
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_all_rejected_decision_${i}`,
type: "decision",
text: `Pinned high priority decision ${i} that keeps the workspace entry cap full.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
for (let i = 0; i < 8; i += 1) {
store.entries.push({
id: `mem_high_all_rejected_project_${i}`,
type: "project",
text: `Pinned high priority project ${i} that keeps the workspace entry cap full.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
-17
View File
@@ -229,20 +229,3 @@ test("accountPendingPromotions marks manual capacity rejection as retryable", ()
assert.equal(result.clearableKeys.size, 0);
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
});
test("accountPendingPromotions clears compaction stale rejection from accounting", () => {
const pending = [mem("pending_stale", "Stale compaction reference should be terminal.", {
type: "reference",
source: "compaction",
})];
const result = accountPendingPromotions({
pending,
before: [],
after: [],
events: [event(pending[0], "rejected_stale")],
});
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
});
+440 -45
View File
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS } from "../src/types.ts";
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
import {
renderWorkspaceMemory,
@@ -19,12 +19,26 @@ import {
loadWorkspaceMemory,
saveWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
calculateInitialStrength,
calculateEffectiveHalfLife,
calculateRetentionStrength,
calculateDormantDays,
calculateEffectiveAgeDays,
reinforceMemory,
} from "../src/workspace-memory.ts";
import { redactCredentials } from "../src/redaction.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
import { REAL_WORKSPACE_FIXTURES } from "./fixtures/real-workspaces-snapshot.ts";
const DAY_MS = 24 * 60 * 60 * 1000;
test("default prompt budgets use calibrated conservative character caps", () => {
assert.equal(LONG_TERM_LIMITS.maxRenderedChars, 3600);
assert.equal(LONG_TERM_LIMITS.targetRenderedChars, 3000);
assert.equal(HOT_STATE_LIMITS.maxRenderedChars, 700);
});
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
@@ -256,6 +270,394 @@ test("enforceLongTermLimits respects maxEntries limit", () => {
assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`);
});
test("calculateInitialStrength multiplies type, source, importance, and safety factors", () => {
const memory: LongTermMemoryEntry = {
...entry("strength", "Never store raw credentials", "reference"),
source: "explicit",
userImportance: "high",
safetyCritical: true,
};
assert.equal(calculateInitialStrength(memory), 18);
});
test("calculateEffectiveHalfLife clamps reinforcement count at configured maximum", () => {
const unreinforced = entry("unreinforced", "Use pnpm for this project");
const overReinforced: LongTermMemoryEntry = {
...entry("over-reinforced", "Use npm scripts for verification"),
reinforcementCount: 99,
};
assert.equal(calculateEffectiveHalfLife(unreinforced), 45);
assert.equal(
calculateEffectiveHalfLife(overReinforced),
45 / Math.pow(0.85, 6),
);
});
test("calculateRetentionStrength slows decay for dormancy after activity grace", () => {
const now = Date.UTC(2026, 3, 29);
const nineteenDaysAgo = now - 19 * DAY_MS;
const memory: LongTermMemoryEntry = {
...entry("retention-clock", "Use TypeScript for plugin code", "reference"),
source: "compaction",
retentionClock: nineteenDaysAgo,
updatedAt: new Date(now).toISOString(),
};
// 19 wall-clock days since last activity: 14 active days + 5 dormant days at 25% = 15.25 effective days.
const lastActivityAt = new Date(nineteenDaysAgo).toISOString();
const expectedEffectiveAgeDays = 14 + 5 * 0.25;
const expected = Math.pow(2, -expectedEffectiveAgeDays / 45);
assert.equal(calculateRetentionStrength(memory, now, lastActivityAt), expected);
});
test("calculateEffectiveAgeDays matches plan worked dormant example", () => {
const now = Date.UTC(2026, 3, 29);
const twentyEightDaysAgo = now - 28 * DAY_MS;
assert.equal(
calculateEffectiveAgeDays(twentyEightDaysAgo, now, new Date(twentyEightDaysAgo).toISOString()),
17.5,
);
});
test("calculateRetentionStrength falls back to updatedAt when retentionClock is absent", () => {
const now = Date.UTC(2026, 3, 29);
const memory: LongTermMemoryEntry = {
...entry("updated-at", "Prefer concise verification summaries", "feedback"),
source: "manual",
updatedAt: new Date(now - 45 * DAY_MS).toISOString(),
};
const initialStrength = 2.25 * 1.4;
assert.equal(calculateRetentionStrength(memory, now), initialStrength / 2);
});
test("calculateEffectiveAgeDays does not charge old dormancy to fresh entries", () => {
const now = Date.UTC(2026, 3, 29);
const lastActivityAt = new Date(now - 180 * DAY_MS).toISOString();
assert.equal(calculateEffectiveAgeDays(now, now, lastActivityAt), 0);
});
test("calculateEffectiveAgeDays discounts dormant overlap since entry creation", () => {
const now = Date.UTC(2026, 3, 29);
const entryCreatedSevenDaysAgo = now - 7 * DAY_MS;
const lastActivityAt = new Date(now - 180 * DAY_MS).toISOString();
assert.equal(
calculateEffectiveAgeDays(entryCreatedSevenDaysAgo, now, lastActivityAt),
1.75,
);
});
test("calculateRetentionStrength returns finite value for invalid updatedAt fallback", () => {
const now = Date.UTC(2026, 3, 29);
const memory: LongTermMemoryEntry = {
...entry("bad-updated-at", "Invalid timestamps should not corrupt sorting", "feedback"),
updatedAt: "not-a-date",
retentionClock: undefined,
};
const strength = calculateRetentionStrength(memory, now);
assert.equal(Number.isFinite(strength), true);
assert.ok(strength >= 0);
});
test("calculateRetentionStrength returns finite value for invalid retentionClock", () => {
const now = Date.UTC(2026, 3, 29);
const memory: LongTermMemoryEntry = {
...entry("bad-retention-clock", "Invalid retention clocks should fall back safely", "decision"),
retentionClock: Number.NaN,
updatedAt: new Date(now - 45 * DAY_MS).toISOString(),
};
const strength = calculateRetentionStrength(memory, now);
assert.equal(Number.isFinite(strength), true);
assert.ok(strength >= 0);
});
test("calculateDormantDays applies fourteen day workspace activity grace", () => {
const now = Date.UTC(2026, 3, 29);
const activeWithinGrace: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [],
updatedAt: new Date(now).toISOString(),
lastActivityAt: new Date(now - 13 * DAY_MS).toISOString(),
};
const dormantPastGrace: WorkspaceMemoryStore = {
...activeWithinGrace,
lastActivityAt: new Date(now - 20 * DAY_MS).toISOString(),
};
assert.equal(calculateDormantDays(activeWithinGrace, now), 13);
assert.equal(calculateDormantDays(dormantPastGrace, now), 20);
});
test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for strength ordering", async () => {
const now = Date.now();
const reinforcedOldReference: LongTermMemoryEntry = {
...entry("reinforced-old", "Project uses the legacy local plugin architecture", "project"),
retentionClock: now - 100 * DAY_MS,
reinforcementCount: 6,
};
const freshReference: LongTermMemoryEntry = {
...entry("fresh", "Fresh docs live at https://example.com/fresh", "reference"),
retentionClock: now,
};
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [freshReference, reinforcedOldReference],
updatedAt: new Date(now).toISOString(),
lastActivityAt: new Date(now - (14 + 1000) * DAY_MS).toISOString(),
};
const result = await normalizeWorkspaceMemoryWithAccounting("/repo", store);
assert.deepEqual(result.kept.map(memory => memory.id), ["reinforced-old", "fresh"]);
});
test("reinforceMemory enforces session interval and max guards", () => {
const now = Date.UTC(2026, 3, 29);
const base = entry("reinforce", "Durable memory should reinforce only when gated");
const reinforced = reinforceMemory(base, "session-a", now);
assert.notEqual(reinforced, base);
assert.equal(reinforced.reinforcementCount, 1);
assert.equal(reinforced.lastReinforcedAt, now);
assert.equal(reinforced.lastReinforcedSessionID, "session-a");
assert.equal(reinforced.retentionClock, now);
assert.equal(reinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000), reinforced);
assert.equal(reinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000), reinforced);
const atMax: LongTermMemoryEntry = {
...base,
reinforcementCount: 6,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
};
assert.equal(reinforceMemory(atMax, "session-c", now), atMax);
});
test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => {
const now = Date.now();
const retained: LongTermMemoryEntry = {
...entry("retained", "Use pnpm for package management", "decision"),
retentionClock: now - 10 * 24 * 60 * 60 * 1000,
createdAt: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(),
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate", "use pnpm for package management!!!", "decision"),
pendingOwnerSessionID: "reinforce-session",
createdAt: new Date(now).toISOString(),
updatedAt: new Date(now).toISOString(),
};
const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]);
assert.equal(result.kept.length, 1);
assert.equal(result.kept[0].id, "duplicate");
assert.equal(result.kept[0].reinforcementCount, 1);
assert.equal(result.kept[0].lastReinforcedSessionID, "reinforce-session");
assert.ok(typeof result.kept[0].retentionClock === "number");
});
test("reinforced memory with same initial strength and age ranks above unreinforced memory", () => {
const now = Date.now();
const age = now - 120 * DAY_MS;
const unreinforced: LongTermMemoryEntry = {
...entry("unreinforced", "Always run typecheck before commit", "feedback"),
retentionClock: age,
createdAt: new Date(age).toISOString(),
updatedAt: new Date(age).toISOString(),
};
const reinforced: LongTermMemoryEntry = {
...entry("reinforced", "Prefer functional composition over inheritance", "feedback"),
retentionClock: age,
createdAt: new Date(age).toISOString(),
updatedAt: new Date(age).toISOString(),
reinforcementCount: 6,
};
const kept = enforceLongTermLimits([unreinforced, reinforced]);
assert.deepEqual(kept.map(memory => memory.id), ["reinforced", "unreinforced"]);
});
test("dedupe reinforcement does not increment for same session", () => {
const now = Date.now();
const existing: LongTermMemoryEntry = {
...entry("existing", "Use pnpm for package management", "decision"),
source: "manual",
pendingOwnerSessionID: "same-session",
reinforcementCount: 1,
lastReinforcedAt: now - 2 * 60 * 60 * 1000,
lastReinforcedSessionID: "same-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate", "use pnpm for package management!!!", "decision"),
pendingOwnerSessionID: "same-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const retained = result.kept.find(memory => memory.id === "existing");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "same-session");
});
test("dedupe reinforcement does not increment under one hour", () => {
const now = Date.now();
const existing: LongTermMemoryEntry = {
...entry("existing", "Prefer deterministic consolidation accounting", "feedback"),
source: "manual",
reinforcementCount: 1,
lastReinforcedAt: now - 30 * 60 * 1000,
lastReinforcedSessionID: "old-session",
};
const duplicate: LongTermMemoryEntry = {
...entry("duplicate", "prefer deterministic consolidation accounting!!!", "feedback"),
pendingOwnerSessionID: "new-session",
};
const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]);
const retained = result.kept.find(memory => memory.id === "existing");
assert.ok(retained, "existing manual memory should be retained");
assert.equal(retained.reinforcementCount, 1);
assert.equal(retained.lastReinforcedSessionID, "old-session");
});
test("enforceLongTermLimits orders entries by retention strength", () => {
const now = Date.now();
const freshFeedback: LongTermMemoryEntry = {
...entry("fresh-feedback", "User prefers direct concise answers", "feedback"),
source: "compaction",
updatedAt: new Date(now).toISOString(),
};
const oldExplicitReference: LongTermMemoryEntry = {
...entry("old-explicit-reference", "Legacy docs are at https://example.com/old", "reference"),
source: "explicit",
updatedAt: new Date(now - 365 * 24 * 60 * 60 * 1000).toISOString(),
};
const kept = enforceLongTermLimits([oldExplicitReference, freshFeedback]);
assert.deepEqual(kept.map(memory => memory.id), ["fresh-feedback", "old-explicit-reference"]);
});
test("enforceLongTermLimits applies per-type caps after strength sorting", () => {
const entries = Array.from({ length: 12 }, (_, i) =>
entry(`feedback_${i}`, `Unique user feedback preference ${i}`, "feedback")
);
const kept = enforceLongTermLimits(entries);
assert.equal(kept.length, 10);
assert.equal(kept.filter(memory => memory.type === "feedback").length, 10);
});
test("enforceLongTermLimits exempts safety-critical entries from type caps", () => {
const ordinaryFeedback = Array.from({ length: 12 }, (_, i) =>
entry(`feedback_${i}`, `Unique safe ordinary feedback preference ${i}`, "feedback")
);
const safetyCriticalFeedback: LongTermMemoryEntry = {
...entry("safety-feedback", "Never persist raw credentials in memory", "feedback"),
safetyCritical: true,
};
const kept = enforceLongTermLimits([safetyCriticalFeedback, ...ordinaryFeedback]);
assert.equal(kept.length, 11);
assert.ok(kept.some(memory => memory.id === "safety-feedback"));
assert.equal(kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10);
});
test("mixed retention scenario applies caps, safety exemption, and reinforcement ordering", () => {
const now = Date.now();
const oldAge = now - 120 * DAY_MS;
const ordinaryFeedback = Array.from({ length: 17 }, (_, i) =>
entry(`mixed-feedback-${i}`, `Unique mixed ordinary feedback preference ${i}`, "feedback")
);
const decisions = Array.from({ length: 11 }, (_, i) =>
entry(`mixed-decision-${i}`, `Unique mixed durable decision ${i}`, "decision")
);
const safetyCriticalFeedback: LongTermMemoryEntry = {
...entry("mixed-safety-feedback", "Never store production credentials in memory", "feedback"),
safetyCritical: true,
};
const oldReinforcedReference: LongTermMemoryEntry = {
...entry("old-reinforced", "Legacy reinforced reference lives at https://example.com/reinforced", "reference"),
retentionClock: oldAge,
createdAt: new Date(oldAge).toISOString(),
updatedAt: new Date(oldAge).toISOString(),
reinforcementCount: 6,
};
const oldUnreinforcedReference: LongTermMemoryEntry = {
...entry("old-unreinforced", "Legacy unreinforced reference lives at https://example.com/unreinforced", "reference"),
retentionClock: oldAge,
createdAt: new Date(oldAge).toISOString(),
updatedAt: new Date(oldAge).toISOString(),
};
const entries = [
...ordinaryFeedback,
...decisions,
safetyCriticalFeedback,
oldReinforcedReference,
oldUnreinforcedReference,
];
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries,
updatedAt: new Date(now).toISOString(),
lastActivityAt: new Date(now).toISOString(),
};
assert.ok(entries.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length > 10);
assert.ok(entries.filter(memory => memory.type === "decision" && !memory.safetyCritical).length > 10);
const result = enforceLongTermLimitsWithAccounting(entries, store);
assert.ok(result.kept.length <= 28);
assert.ok(result.kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length <= 10);
assert.ok(result.kept.filter(memory => memory.type === "decision" && !memory.safetyCritical).length <= 10);
assert.ok(result.kept.some(memory => memory.safetyCritical));
assert.equal(result.kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10);
assert.equal(result.kept.filter(memory => memory.type === "feedback" && memory.safetyCritical).length, 1);
assert.equal(result.kept.filter(memory => memory.type === "feedback").length, 11);
const reinforcedIndex = result.kept.findIndex(memory => memory.id === "old-reinforced");
const unreinforcedIndex = result.kept.findIndex(memory => memory.id === "old-unreinforced");
assert.ok(reinforcedIndex >= 0, "old reinforced reference should be kept");
assert.ok(unreinforcedIndex >= 0, "old unreinforced reference should be kept");
assert.ok(reinforcedIndex < unreinforcedIndex);
});
test("type max sum above global cap still respects maxEntries", () => {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 10 }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`decision-${i}`, `Unique durable decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`project-${i}`, `Unique project fact ${i}`, "project")),
...Array.from({ length: 6 }, (_, i) => entry(`reference-${i}`, `Unique reference fact ${i}`, "reference")),
];
const kept = enforceLongTermLimits(entries);
assert.equal(entries.length, 34);
assert.equal(kept.length, LONG_TERM_LIMITS.maxEntries);
});
test("normalization ordering is deterministic for retention ties", () => {
const createdAt = "2026-04-28T00:00:00.000Z";
const a = {
@@ -378,16 +780,12 @@ test("dedupeLongTermEntriesWithAccounting does not report heuristic topic supers
test("enforceLongTermLimitsWithAccounting reports capacity drops", () => {
const now = new Date().toISOString();
const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, i) => ({
id: `mem_${i}`,
type: "reference" as const,
text: `Unique low priority reference ${i}`,
source: "compaction" as const,
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
}));
const entries = [
...Array.from({ length: 10 }, (_, i) => entry(`feedback_${i}`, `Capacity feedback ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`decision_${i}`, `Capacity decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`project_${i}`, `Capacity project ${i}`, "project")),
...Array.from({ length: 2 }, (_, i) => entry(`reference_${i}`, `Capacity overflow reference ${i}`, "reference")),
].map(memory => ({ ...memory, createdAt: now, updatedAt: now }));
const result = enforceLongTermLimitsWithAccounting(entries);
@@ -445,21 +843,18 @@ test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounti
test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => {
const root = "/repo";
const now = new Date().toISOString();
const entries = [
...Array.from({ length: 10 }, (_, i) => entry(`overflow_feedback_${i}`, `Overflow feedback ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`overflow_decision_${i}`, `Overflow decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`overflow_project_${i}`, `Overflow project ${i}`, "project")),
entry("overflow_reference", "Overflow low strength reference", "reference"),
].map(memory => ({ ...memory, createdAt: now, updatedAt: now }));
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-26-p0-cleanup"],
entries: Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
id: `overflow_${i}`,
type: "reference" as const,
text: `Overflow reference ${i}`,
source: "compaction" as const,
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
})),
entries,
updatedAt: now,
};
@@ -470,7 +865,7 @@ test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", a
assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 1);
});
test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async () => {
test("normalizeWorkspaceMemoryWithAccounting retains stale entries for cap competition", async () => {
const root = "/repo";
const now = new Date().toISOString();
const stale = agedEntry(
@@ -490,10 +885,10 @@ test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.kept.length, 0);
assert.equal(result.store.entries.length, 0);
assert.deepEqual(result.dropped.map(event => event.reason), ["rejected_stale"]);
assert.equal(result.dropped[0].memory.id, "stale_normalize");
assert.equal(result.kept.length, 1);
assert.equal(result.store.entries.length, 1);
assert.equal(result.dropped.length, 0);
assert.equal(result.kept[0].id, "stale_normalize");
});
test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted updates", async () => {
@@ -506,16 +901,13 @@ test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted
try {
const now = new Date().toISOString();
const result = await updateWorkspaceMemoryWithAccounting(root, store => {
store.entries.push(...Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
id: `persisted_${i}`,
type: "reference" as const,
text: `Persisted accounting reference ${i}`,
source: "compaction" as const,
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
})));
store.entries.push(
...Array.from({ length: 10 }, (_, i) => entry(`persisted_feedback_${i}`, `Persisted feedback ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`persisted_decision_${i}`, `Persisted decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`persisted_project_${i}`, `Persisted project ${i}`, "project")),
entry("persisted_reference", "Persisted low strength reference", "reference"),
);
store.entries = store.entries.map(memory => ({ ...memory, createdAt: now, updatedAt: now }));
return store;
});
@@ -651,14 +1043,13 @@ test("enforceLongTermLimits feedback: exact canonical duplicates still collapse"
assert.equal(feedbackEntries.length, 1, "Exact canonical feedback duplicates should still collapse");
});
test("enforceLongTermLimits stale: compaction entry older than staleAfterDays+grace is pruned", () => {
// decision with staleAfterDays=45, 76 days old (> 45+30 grace=75)
test("enforceLongTermLimits stale: old compaction entry remains eligible for cap competition", () => {
const entries = [
agedEntry("stale", "Compaction output contract changed from XML to HTML comments to avoid Markdown rendering issues", "decision", { daysAgo: 76, staleAfterDays: 45 }),
];
const kept = enforceLongTermLimits(entries);
assert.equal(kept.length, 0, "Stale compaction entry should be pruned");
assert.equal(kept.length, 1, "Stale compaction entry should decay but not be hard-pruned");
});
test("enforceLongTermLimits stale: explicit entry is retained even if old", () => {
@@ -1366,7 +1757,7 @@ test("renderWorkspaceMemory excludes superseded entries", () => {
assert.doesNotMatch(rendered, /Waves 1-5 已完成/);
});
test("loadWorkspaceMemory does not rewrite an already normalized store", async () => {
test("loadWorkspaceMemory records activity for an already normalized store", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-normalized-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
@@ -1400,11 +1791,12 @@ test("loadWorkspaceMemory does not rewrite an already normalized store", async (
const before = (await stat(storePath)).mtimeMs;
await sleep(20);
await loadWorkspaceMemory(root);
const loaded = await loadWorkspaceMemory(root);
await loadWorkspaceMemory(root);
const after = (await stat(storePath)).mtimeMs;
assert.equal(after, before, "normalized loads should not touch the store file");
assert.ok(after > before, "normalized loads should update workspace activity timestamp");
assert.ok(loaded.lastActivityAt, "load should expose last activity timestamp");
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
@@ -1415,7 +1807,7 @@ test("loadWorkspaceMemory does not rewrite an already normalized store", async (
}
});
test("loadWorkspaceMemory does not persist pure ordering normalization", async () => {
test("loadWorkspaceMemory preserves ordering while recording activity", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-ordering-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
@@ -1466,7 +1858,7 @@ test("loadWorkspaceMemory does not persist pure ordering normalization", async (
const after = (await stat(storePath)).mtimeMs;
assert.deepEqual(loaded.entries.map(memory => memory.id), ["feedback-first", "reference-second"]);
assert.equal(after, before, "order-only normalization should not write during load");
assert.ok(after > before, "load should write updated workspace activity timestamp");
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
@@ -1527,7 +1919,10 @@ test("loadWorkspaceMemory persists redaction changes and is stable afterward", a
await sleep(20);
await loadWorkspaceMemory(root);
const afterSecondLoad = (await stat(storePath)).mtimeMs;
assert.equal(afterSecondLoad, beforeSecondLoad, "second load should not rewrite redacted content");
assert.ok(afterSecondLoad > beforeSecondLoad, "second load should update workspace activity timestamp");
const persistedAfterSecondLoad = await readFile(storePath, "utf-8");
assert.equal(persistedAfterSecondLoad.includes("sk-test-123"), false);
assert.equal(persistedAfterSecondLoad.includes("sushi"), false);
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;