mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c6e143f4b | |||
| 73384ca0a4 | |||
| 04233f8452 | |||
| ffb612226c | |||
| 4097815f3e | |||
| bb7e4e2927 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user