Compare commits

...

18 Commits

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

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

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

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

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

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

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

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

A1 - Retention clock reset on reinforcement

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

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

Tests: 219 → 222, all pass
2026-04-29 14:32:39 +08:00
Ralph Chang 4097815f3e feat(memory): implement retention decay model with strength-based ordering
- Add retention model constants (45-day half-life, 6.0 safety factor)
- Add TYPE_MAX caps (feedback:10, decision:10, project:8, reference:6)
- Add strength calculation: initialStrength × 2^(-age/halfLife)
- Integrate strength-based sorting into enforceLongTermLimits
- Safety-critical entries bypass type caps
- Add fields: retentionClock, reinforcementCount, userImportance, safetyCritical
2026-04-29 14:18:51 +08:00
Ralph Chang bb7e4e2927 feat: add maintainer diagnostics for memory quality calibration
- health: inspect workspace memory store, pending journal, offline quality checks
- rejections: review extraction rejection log with origin inference
- audit: review migration logs with risky supersede heuristic
- Maintainer-only, offline, no telemetry, no API calls
2026-04-29 10:25:35 +08:00
sdwolf4103 d700f4877f Merge pull request #4 from sdwolf4103/feat/memory-quality-cleanup
Release v1.4.0 memory quality cleanup
2026-04-28 14:53:12 +08:00
Ralph Chang c0a083ddaf fix(memory): isolate test workspace cleanup 2026-04-28 14:50:30 +08:00
Ralph Chang 8e07bfe3c1 fix(memory): address quality cleanup audit findings 2026-04-28 14:29:28 +08:00
Ralph Chang c7088a8a6e docs(memory): document conservative quality cleanup migration 2026-04-28 14:19:18 +08:00
Ralph Chang efed9e5585 test(memory): add real workspace quality cleanup regression fixture 2026-04-28 14:17:43 +08:00
Ralph Chang 7de10c5808 feat(memory): add local quality cleanup audit logs 2026-04-28 14:17:17 +08:00
Ralph Chang 12eddc2f8c fix(memory): make quality cleanup migration conservative 2026-04-28 14:15:34 +08:00
Ralph Chang 5e85d098d8 chore: prepare v1.4.0 release 2026-04-28 13:37:14 +08:00
Ralph Chang 99c6b97c96 fix: unify all memory quality rules in single module 2026-04-28 13:34:33 +08:00
Ralph Chang 83dcfb479c fix: auto-supersede low-quality compaction memories 2026-04-28 13:29:28 +08:00
Ralph Chang ed6005f6cf fix: tighten compaction memory candidate prompt 2026-04-28 13:24:43 +08:00
Ralph Chang 069ec8ecbb fix: unify workspace memory quality gate 2026-04-28 13:21:15 +08:00
30 changed files with 3839 additions and 354 deletions
+4
View File
@@ -51,3 +51,7 @@ pnpm-lock.yaml
# Superpowers local planning artifacts
docs/superpowers/plans/
# Local dev/admin script inputs
scripts/dev/run-migration-roots.local.txt
scripts/dev/dry-run-roots.local.txt
+7 -7
View File
@@ -111,7 +111,7 @@ export type LongTermSource = "explicit" | "compaction" | "manual";
// ✅ USE: const assertions for limits
export const LONG_TERM_LIMITS = {
maxRenderedChars: 5200,
maxRenderedChars: 3600,
maxEntries: 28,
} as const;
```
@@ -140,8 +140,8 @@ const maxEntries = 28;
async function loadWorkspaceMemory() { }
// ✅ REQUIRED: SCREAMING_SNAKE_CASE for constants
const LONG_TERM_LIMITS = { maxRenderedChars: 5200, maxEntries: 28 };
const HOT_STATE_LIMITS = { maxRenderedChars: 1200 };
const LONG_TERM_LIMITS = { maxRenderedChars: 3600, maxEntries: 28 };
const HOT_STATE_LIMITS = { maxRenderedChars: 700 };
// ✅ REQUIRED: PascalCase for types
type WorkspaceMemoryStore = { ... };
@@ -236,7 +236,7 @@ export default {
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
- **Schema**: See `src/types.ts:WorkspaceMemoryStore`
- **Limits**: 5200 chars, 28 entries max
- **Limits**: 3600 chars, 28 entries max
### Session State Files
@@ -299,9 +299,9 @@ Extracts workspace memory candidates from conversation, applies quality gate and
## Performance Considerations
- **Workspace memory budget**: 5200 chars injected into system prompt
- **Session state budget**: 1200 chars injected into system prompt
- **Total overhead**: ~1500-6000 chars per message (minimal)
- **Workspace memory budget**: 3600 chars injected into system prompt
- **Session state budget**: 700 chars injected into system prompt
- **Total overhead**: typically well below configured maximums
- **Storage footprint**: ~2-5 KB per workspace for memory, ~1-3 KB per session
## Contributing
+55
View File
@@ -5,6 +5,61 @@ 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
- Local migration audit log for the `2026-04-28-quality-cleanup` migration:
`~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`.
- Local extraction rejection log for rejected compaction memory candidates:
`~/.local/share/opencode-working-memory/extraction-rejections.jsonl`.
- Sanitized real-workspace regression fixtures for memory cleanup migration behavior.
- Safe workspace residue cleanup tooling that dry-runs by default and quarantines definite temp/test workspace stores instead of deleting them.
### Changed
- Unified memory quality rules in a shared quality gate for compaction memory candidates and cleanup checks.
- Rewritten compaction memory prompt to reduce over-production of low-quality memories.
- Changed quality cleanup migration to be conservative: it supersedes only high-confidence garbage patterns, including progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries.
- Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, user workflow preferences, and architecture decisions.
- Isolated test runs under a temporary `XDG_DATA_HOME` so test workspaces no longer pollute real local workspace memory data.
### Recovery note
The cleanup migration changes matching entries to `status: "superseded"`; it does not delete the entry. If a useful memory is superseded, inspect the migration audit log and restore by changing that entry back to `status: "active"` in the workspace's `workspace-memory.json`. The migration runs once per workspace.
## [1.3.3] - 2026-04-28
### Fixed
+46 -10
View File
@@ -5,9 +5,9 @@
Automatic memory for OpenCode agents.
OpenCode Working Memory helps your agent keep useful context across compactions and sessions: project decisions, preferences, important references, active files, and unresolved errors.
Working memory is context that **remembers what matters, fades what changes, and stays out of the way.**
It works automatically, without manual memory tools or extra LLM/API calls.
OpenCode Working Memory preserves project decisions, preferences, and references across compactions and sessions, while keeping active files and unresolved errors fresh for the current session — with no manual tools or extra LLM/API calls.
## Why This Exists
@@ -31,6 +31,7 @@ Use it when you want your agent to remember things like:
- **Compaction-based extraction** — memory extraction piggybacks on OpenCode’s existing compaction flow.
- **No manual tools** — memory is injected automatically into the system prompt.
- **Quality guards** — filters noisy memories, temporary progress snapshots, stack traces, raw errors, and credentials.
- **Retention decay** — keeps the strongest memories in prompt context while older or weaker memories fade out naturally; important and reinforced memories decay more slowly.
## Installation
@@ -121,6 +122,27 @@ Memory types:
- `decision` — important implementation or architecture decisions
- `reference` — useful paths, commands, or configuration references
### Retention Decay
> **Memory should fade, so the agent can keep learning.**
>
> Important memories decay more slowly, but every memory must leave room for newer project reality.
Memories decay over time. The strongest stay visible in the prompt; weaker ones fade from context without being deleted.
```text
strength
│
██ │╲____ reinforced: slower decline
│ ╲______
▒▒ │ ╲__ ordinary memory
│ ╲
├ ─ ─ ─ ─ ─ ─ ─ ─╲─ dynamic cap competition zone
░░ │ ╲ easier for new memories to replace
│ ↑ still stored, not deleted
└──────────────────────────────→ time / sessions
```
## Explicit Memory Triggers
You can explicitly ask the agent to remember durable facts.
@@ -167,34 +189,47 @@ 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:
```bash
npm run cleanup:workspaces -- --dry-run
npm run cleanup:workspaces -- --quarantine
```
The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
## Configuration
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
@@ -210,6 +245,7 @@ cd opencode-working-memory
npm install
npm test
npm run typecheck
npm run cleanup:workspaces -- --dry-run
```
## Requirements
+201
View File
@@ -1,5 +1,206 @@
# 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
This release improves automatic workspace memory quality without risking broad cleanup of useful existing memories.
The quality gate is now shared across compaction extraction and migration checks, the compaction prompt is stricter about what should become durable memory, and the one-time migration is intentionally conservative.
### What Changed
- **Unified quality rules**: memory quality checks now live in one shared module and apply consistently across feedback, decisions, project facts, and references.
- **Stricter compaction output**: the compaction prompt now tells the model to save fewer memories and prefer durable facts, user preferences, architecture decisions, and hard-to-rediscover references.
- **Conservative migration cleanup**: the `2026-04-28-quality-cleanup` migration only supersedes high-confidence garbage patterns, not every rejected memory.
- **Audit logs**: automatic migration cleanup writes local JSONL audit records so superseded entries can be inspected and restored.
- **Extraction rejection logs**: newly rejected compaction candidates are logged locally to help calibrate future quality rules.
- **Regression coverage**: migration behavior is tested against sanitized real-workspace patterns to prevent mass false positives from coming back.
- **Workspace cleanup tooling**: a dev/admin cleanup command can dry-run or quarantine definite temp/test workspace residues without deleting unknown missing-root workspaces.
- **Test storage isolation**: test runs now use a temporary `XDG_DATA_HOME`, preventing fixture workspaces from polluting real local memory data.
### What Gets Cleaned Up
The migration may supersede existing `source: "compaction"` memories only when they match hard garbage patterns:
- Empty entries
- Progress snapshots, such as "Wave 1 completed successfully"
- Test or suite count snapshots, such as "180 tests passed"
- Raw errors and stack traces
- Commit or CI snapshots
- Temporary status notes, such as "Currently running npm test"
- Active file snapshots
- Code or API signatures
- Path-heavy entries that are just rediscoverable file lists
### What Is Protected
The migration does not supersede entries whose only issue is a soft heuristic failure, such as:
- `bad_feedback`
- `bad_decision`
This protects useful declarative memories like:
- Product branding rules
- API facts
- Release rules
- Architecture decisions
- User workflow preferences
Explicit and manual memories are also protected.
### Migration Behavior
- Runs once per workspace.
- Only affects active `source: "compaction"` entries.
- Marks matching entries as `status: "superseded"` instead of deleting them.
- Adds `quality_cleanup` and `quality:<reason>` tags to superseded entries.
- Writes audit logs to:
`~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`
- Writes extraction rejection logs to:
`~/.local/share/opencode-working-memory/extraction-rejections.jsonl`
### Recovery
If a useful memory is superseded, inspect the migration audit log and restore the entry by changing its status back to `"active"` in the workspace's `workspace-memory.json`.
### Workspace Residue Cleanup
If old test/temp workspace stores already exist locally, inspect them first:
```bash
npm run cleanup:workspaces -- --dry-run
```
To move definite temp/test residues into a local quarantine folder instead of deleting them:
```bash
npm run cleanup:workspaces -- --quarantine
```
The cleanup command skips existing workspace roots and unknown missing-root workspaces by default.
### Upgrade Notes
- No configuration changes required.
- Existing workspace memory files remain compatible.
- The OpenCode config entry stays the same:
```json
{
"plugin": ["opencode-working-memory"]
}
```
### Validation
- `npm test`
- `npm run typecheck`
---
## 1.3.2 (2026-04-27)
### CI Compatibility Patch
+41 -11
View File
@@ -10,7 +10,7 @@ OpenCode Working Memory implements a **three-layer memory architecture** designe
│ • Persistent storage: ~/.local/share/opencode-working-... │
│ • Types: feedback | project | decision | reference │
│ • Sources: explicit | compaction | manual │
│ • Limits: 5200 chars / 28 entries │
│ • Render limits: 3600 chars / 28 entries │
│ • Survives: session reset, compaction (same workspace) │
└─────────────────────────────────────────────────────────────┘
↓
@@ -48,8 +48,9 @@ Long-term memory that persists across sessions within the same workspace. Perfec
{
version: 1,
workspace: { root: string, key: string },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
limits: { maxRenderedChars: 3600, maxEntries: 28 },
entries: LongTermMemoryEntry[],
lastActivityAt?: string,
updatedAt: string
}
```
@@ -90,18 +91,47 @@ Memory candidates:
- Path-heavy facts (>50% paths)
- Very short text (<20 chars)
### Consolidation and Deduplication
### Consolidation, Deduplication, and Retention
Memories are deduplicated and consolidated with accounting:
1. Normalize exact text: lowercase, strip punctuation, collapse whitespace.
2. Group project/reference entries by identity where possible.
3. Group decisions and feedback by topic where possible.
4. Keep the best surviving entry by source, confidence, type, and freshness rules.
3. Keep decision and feedback entries on exact canonical matching to avoid broad semantic merges.
4. Keep the best surviving entry by source, confidence, specificity, and freshness tie-breakers.
5. Emit accounting events so pending memories can be classified as promoted, absorbed, superseded, or rejected.
This prevents absorbed or superseded pending memories from retrying forever while still preserving the active surviving memory.
Retention then decides which active memories are rendered into prompt context. It does not hard-delete old memories by age.
```typescript
strength = initialStrength * 2 ** (-effectiveAgeDays / effectiveHalfLifeDays)
```
Initial strength is based on memory type, source, optional user importance, and safety-critical status. Confidence remains stored for compatibility but is not part of retention scoring.
Rendered candidates are selected in this order:
1. Exclude `status: "superseded"` entries.
2. Compute current retention strength.
3. Sort by strength descending.
4. Apply per-type caps, with safety-critical entries exempt from type caps.
5. Keep the top 28 rendered entries under the workspace memory character budget.
Default type caps:
| Type | Rendered cap |
|------|--------------|
| `feedback` | 10 |
| `decision` | 10 |
| `project` | 8 |
| `reference` | 6 |
The type-cap total is 34, intentionally above the global 28-entry cap. These are maximums, not quotas.
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement.
### System Prompt Injection
Workspace memory is injected at the top of every message:
@@ -241,7 +271,7 @@ Applies quality gate, redaction, migration, consolidation accounting, deduplicat
- `session.compacted`: Promote session decisions to workspace memory
- `session.deleted`: Clean up session state files
Promotion uses accounting results from workspace memory normalization. Pending memories that are kept are promoted; duplicate memories are absorbed; obsolete same-topic memories are superseded; stale or over-capacity compaction memories are rejected.
Promotion uses accounting results from workspace memory normalization. Pending memories that are kept are promoted; duplicate memories are absorbed; exact decision replacements can be superseded; over-capacity compaction memories are rejected. Stale-marked memories are not hard-pruned by age; they lose rendered space through retention strength and cap competition.
## Quality Guarantees
@@ -319,14 +349,14 @@ const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
| Layer | Max Chars | Max Entries |
|-------|-----------|-------------|
| Workspace Memory | 5200 | 28 |
| Hot Session State | 1200 | 8 files, 3 errors |
| Workspace Memory | 3600 | 28 |
| Hot Session State | 700 | 8 files, 3 errors |
### Injection Overhead
- Workspace memory: ~200-500 chars per message
- Hot session state: ~200-400 chars per message
- Total: ~400-900 chars per message (minimal)
- Workspace memory: usually under ~2000 chars in observed rendered output
- Hot session state: usually under ~500 chars in observed rendered output
- Total: typically well below the configured maximums
### Storage Footprint
+48 -11
View File
@@ -8,8 +8,8 @@ OpenCode Working Memory works out-of-the-box with sensible defaults. Configurati
```typescript
const LONG_TERM_LIMITS = {
maxRenderedChars: 5200, // Maximum characters in system prompt
targetRenderedChars: 4200, // Target characters (leave buffer)
maxRenderedChars: 3600, // Maximum characters in system prompt
targetRenderedChars: 3000, // Target characters (leave buffer)
maxEntries: 28, // Maximum number of entries
maxEntryTextChars: 260, // Maximum characters per entry text
maxRationaleChars: 180, // Maximum characters per entry rationale
@@ -18,14 +18,40 @@ const LONG_TERM_LIMITS = {
**Recommendations**:
- Keep `maxRenderedChars` under 5500 to avoid context bloat
- Defaults are calibrated from observed rendered usage that was typically under ~2000 characters
- `maxEntries` of 28 provides good coverage without overwhelming
- Entry text limits ensure entries stay concise
## Retention Model Defaults
Workspace memory retention uses strength-based decay. These constants live in `src/workspace-memory.ts`:
```typescript
const BASE_HALF_LIFE_DAYS = 45;
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
const REINFORCEMENT_MAX_COUNT = 6;
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
const DORMANT_DECAY_MULTIPLIER = 0.25;
```
Initial strength uses type, source, user importance, and safety-critical factors. Confidence is stored for compatibility but is not used for retention scoring.
Rendered type caps prevent one type from filling all workspace memory slots:
| Type | Rendered cap |
|------|--------------|
| `feedback` | 10 |
| `decision` | 10 |
| `project` | 8 |
| `reference` | 6 |
Safety-critical memories are exempt from type caps but still compete under the global `maxEntries` limit. Old or stale-marked memories are not hard-pruned by age; they lose rendered space through strength and cap competition.
## Hot Session State Limits
```typescript
const HOT_STATE_LIMITS = {
maxRenderedChars: 1200, // Maximum characters in system prompt
maxRenderedChars: 700, // Maximum characters in system prompt
maxActiveFilesStored: 20, // Maximum files tracked in state
maxActiveFilesRendered: 8, // Maximum files shown in prompt
maxOpenErrorsStored: 5, // Maximum errors tracked
@@ -36,6 +62,7 @@ const HOT_STATE_LIMITS = {
**Recommendations**:
- Keep `maxRenderedChars` under 1500 for fast prompts
- Defaults are calibrated from observed rendered usage around ~500 characters or less
- `maxActiveFilesRendered` of 8 provides good context coverage
- `maxOpenErrorsRendered` of 3 avoids overwhelming error lists
@@ -43,12 +70,12 @@ const HOT_STATE_LIMITS = {
### Long-Term Memory Types
| Type | Purpose | Stale After (days) |
|------|---------|---------------------|
| `feedback` | User preferences for workspace | 90 |
| `project` | Project-level information | 60 |
| `decision` | Important decisions | 45 |
| `reference` | Key references | 90 |
| Type | Purpose | Rendered cap |
|------|---------|--------------|
| `feedback` | User preferences for workspace | 10 |
| `project` | Project-level information | 8 |
| `decision` | Important decisions | 10 |
| `reference` | Key references | 6 |
### Memory Sources
@@ -114,7 +141,7 @@ To customize limits, edit the constants in `src/types.ts`:
```typescript
// Example: Increase workspace memory limit
export const LONG_TERM_LIMITS = {
maxRenderedChars: 6000, // Increased from 5200
maxRenderedChars: 6000, // Increased from 3600
maxEntries: 35, // Increased from 28
// ...
};
@@ -144,7 +171,7 @@ const HOT_STATE_LIMITS = {
// Preserve more context
const LONG_TERM_LIMITS = {
maxEntries: 40, // Increased
targetRenderedChars: 5000, // Increased
targetRenderedChars: 5000, // Increased
};
```
@@ -175,6 +202,16 @@ cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json |
cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
```
### Inspect Retention Health
From a source checkout, maintainers can inspect stored vs rendered memory behavior:
```bash
bun scripts/memory-diag.ts health
```
The health output includes stored active memories, rendered candidates, type caps, global cap overflow, dormancy status, retention monitoring alerts, and strength-ranked top/weakest entries.
### Clear Workspace Memory
```bash
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-working-memory",
"version": "1.3.3",
"version": "1.5.0",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
@@ -16,7 +16,8 @@
"scripts": {
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types tests/*.test.ts",
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts",
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
},
"keywords": [
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Safely inspect or quarantine stale test/temp workspace memory stores.
*
* Default mode is dry-run. Quarantine moves only definite temp/test residues.
* Unknown missing roots are reported but skipped unless --include-orphans is set.
*/
import { cleanupWorkspaceResidues } from "../../src/workspace-cleanup.ts";
type CliOptions = {
mode: "dry-run" | "quarantine";
dataHome?: string;
olderThanDays?: number;
includeOrphans: boolean;
};
function usage(): string {
return `Usage:
npm run cleanup:workspaces -- --dry-run
npm run cleanup:workspaces -- --quarantine
npm run cleanup:workspaces -- --quarantine --older-than-days 1
Options:
--dry-run List candidates without moving anything (default)
--quarantine Move definite temp/test residues to quarantine
--data-home <path> Override XDG data home for testing/admin work
--older-than-days <n> Only consider workspace dirs older than n days
--include-orphans Also quarantine missing non-temp roots (off by default)
--help Show this help
`;
}
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = { mode: "dry-run", includeOrphans: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "--dry-run":
options.mode = "dry-run";
break;
case "--quarantine":
options.mode = "quarantine";
break;
case "--data-home":
options.dataHome = argv[++i];
if (!options.dataHome) throw new Error("--data-home requires a path");
break;
case "--older-than-days": {
const value = Number(argv[++i]);
if (!Number.isFinite(value) || value < 0) throw new Error("--older-than-days requires a non-negative number");
options.olderThanDays = value;
break;
}
case "--include-orphans":
options.includeOrphans = true;
break;
case "--help":
case "-h":
console.log(usage());
process.exit(0);
default:
throw new Error(`Unknown option: ${arg}\n${usage()}`);
}
}
return options;
}
const options = parseArgs(process.argv.slice(2));
const result = await cleanupWorkspaceResidues({
dataHome: options.dataHome,
mode: options.mode,
includeOrphans: options.includeOrphans,
minAgeMs: options.olderThanDays === undefined ? undefined : options.olderThanDays * 24 * 60 * 60 * 1_000,
});
console.log(`Mode: ${result.mode}`);
console.log(`Scanned: ${result.results.length}`);
console.log(`Candidates: ${result.candidates.length}`);
if (result.candidates.length > 0) {
console.log("\nCandidates:");
for (const candidate of result.candidates) {
console.log(`- ${candidate.workspaceKey} ${candidate.classification} root=${candidate.root ?? "<missing>"}`);
console.log(` reasons=${candidate.reasons.join(",")}`);
}
}
if (result.quarantined.length > 0) {
console.log(`\nQuarantined: ${result.quarantined.length}`);
console.log(`Quarantine dir: ${result.quarantineDir}`);
}
const unknownOrphans = result.results.filter(item => item.classification === "orphan_unknown");
if (unknownOrphans.length > 0 && !options.includeOrphans) {
console.log(`\nUnknown missing-root workspaces skipped: ${unknownOrphans.length}`);
console.log("Use --include-orphans only after manually confirming they are safe to quarantine.");
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Local helper to trigger migration on workspace roots.
*
* Usage:
* MIGRATION_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/run-migration.ts
*
* Or create a local file (gitignored):
* echo "/path/to/workspace1" > scripts/dev/run-migration-roots.local.txt
* echo "/path/to/workspace2" >> scripts/dev/run-migration-roots.local.txt
* bun run scripts/dev/run-migration.ts
*/
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { loadWorkspaceMemory } from "../../src/workspace-memory.ts";
async function getRoots(): Promise<string[]> {
// Priority 1: environment variable
const envRoots = process.env.MIGRATION_RUN_ROOTS;
if (envRoots) {
return envRoots.split(":").filter(root => root.length > 0);
}
// Priority 2: local file
const localFile = join(import.meta.dirname, "run-migration-roots.local.txt");
if (existsSync(localFile)) {
const content = await readFile(localFile, "utf8");
return content.trim().split("\n").filter(root => root.length > 0);
}
// No roots configured
console.log("No workspace roots configured.");
console.log("Set MIGRATION_RUN_ROOTS=/path/a:/path/b or create run-migration-roots.local.txt");
return [];
}
const roots = await getRoots();
if (roots.length === 0) {
process.exit(0);
}
for (const root of roots) {
console.log(`Loading workspace memory: ${root}`);
const store = await loadWorkspaceMemory(root);
const active = store.entries.filter(entry => entry.status !== "superseded").length;
const superseded = store.entries.filter(entry => entry.status === "superseded").length;
console.log(` active=${active} superseded=${superseded} migrations=${(store.migrations ?? []).join(",")}`);
}
+718
View File
@@ -0,0 +1,718 @@
#!/usr/bin/env bun
/**
* Maintainer-only offline diagnostics for memory quality calibration.
* Does not send telemetry, make API calls, or affect plugin runtime behavior.
*/
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { assessMemoryQuality, HARD_QUALITY_REASONS } from "../src/memory-quality.ts";
import { redactCredentials } from "../src/redaction.ts";
import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts";
import { calculateRetentionStrength, renderWorkspaceMemory } from "../src/workspace-memory.ts";
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts";
type Command = "health" | "rejections" | "audit";
type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
type CliOptions = {
raw: boolean;
workspace?: string;
all?: boolean;
softOnly?: boolean;
triggerOnly?: boolean;
since?: string;
migration?: string;
};
type RejectionLogRecord = {
timestamp?: string;
workspaceKey?: string;
workspaceRoot?: string;
type?: LongTermType;
source?: LongTermSource | string;
origin?: string;
fromTrigger?: boolean;
text?: string;
reasons?: string[];
};
type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
workspaceKey?: string;
workspaceRoot?: string;
source?: string;
origin: Origin;
fromTrigger: boolean;
};
type MigrationLogRecord = {
migrationId?: string;
timestamp?: string;
workspaceKey?: string;
workspaceRoot?: string;
entryId?: string;
type?: LongTermType;
source?: LongTermSource | string;
text?: string;
reasons?: string[];
hardReasons?: string[];
beforeStatus?: string;
afterStatus?: string;
};
const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
const TYPE_MAX_FOR_DIAG: Record<LongTermType, number> = {
feedback: 10,
decision: 10,
project: 8,
reference: 6,
};
const WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG = 14;
const DORMANT_DECAY_MULTIPLIER_FOR_DIAG = 0.25;
const SUSPICIOUS_REASONS = [
"progress_snapshot",
"active_file_snapshot",
"commit_or_ci_snapshot",
"temporary_status",
"raw_error",
"code_or_api_signature",
] as const;
const ALLOWED_ORIGINS = new Set<Origin>([
"explicit_trigger",
"compaction_candidate",
"manual",
"migration_check",
"unknown",
]);
function usage(): string {
return `Usage:
bun scripts/memory-diag.ts health [--workspace <path>] [--all] [--raw]
bun scripts/memory-diag.ts rejections [--soft-only] [--trigger-only] [--since 14d] [--raw]
bun scripts/memory-diag.ts audit [--migration <id>] [--raw]
`;
}
function die(message: string): never {
console.error(message);
console.error(usage());
process.exit(1);
}
function parseArgs(argv: string[]): { command: Command; options: CliOptions } {
const [command, ...rest] = argv;
if (!command || command === "--help" || command === "-h") {
console.log(usage());
process.exit(0);
}
if (command !== "health" && command !== "rejections" && command !== "audit") {
die(`Unknown subcommand: ${command}`);
}
const options: CliOptions = { raw: false };
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (arg === "--raw") options.raw = true;
else if (arg === "--all") options.all = true;
else if (arg === "--soft-only") options.softOnly = true;
else if (arg === "--trigger-only") options.triggerOnly = true;
else if (arg === "--workspace") {
const value = rest[++i];
if (!value) die("--workspace requires a path");
options.workspace = value;
} else if (arg === "--since") {
const value = rest[++i];
if (!value) die("--since requires a duration or ISO timestamp");
options.since = value;
} else if (arg === "--migration") {
const value = rest[++i];
if (!value) die("--migration requires an id");
options.migration = value;
} else {
die(`Unknown option: ${arg}`);
}
}
if (command === "health") {
if (options.all && options.workspace) die("Use either --all or --workspace, not both");
} else {
if (options.all || options.workspace) die(`${command} does not accept --all or --workspace`);
}
if (command !== "rejections" && (options.softOnly || options.triggerOnly || options.since)) {
die(`${command} does not accept rejection filters`);
}
if (command !== "audit" && options.migration) {
die(`${command} does not accept --migration`);
}
return { command, options };
}
function countBy<T extends string>(items: T[]): Map<T, number> {
const counts = new Map<T, number>();
for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1);
return counts;
}
function sortedCounts<T extends string>(counts: Map<T, number>): Array<[T, number]> {
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
function workspaceRootHash(root: string): string {
return createHash("sha256").update(root).digest("hex").slice(0, 16);
}
function redactAbsolutePaths(text: string): string {
return text.replace(/(?:^|[\s"'`(=:\[])(\/(?:Users|home|private|tmp|var|opt|Volumes|[^\s"'`)\],;:]+)\/[^\s"'`)\],;:]*)/g, (match, path) => match.replace(path, "<path>"));
}
function cleanText(text: string, raw: boolean): string {
if (raw) return text;
return redactAbsolutePaths(redactCredentials(text));
}
function cleanPath(path: string, raw: boolean): string {
return raw ? path : "<path>";
}
function formatWorkspaceIdentity(workspaceKeyValue: string | undefined, workspaceRoot: string | undefined, raw: boolean): string {
const parts: string[] = [];
if (workspaceKeyValue) parts.push(`workspaceKey=${workspaceKeyValue}`);
if (workspaceRoot) {
parts.push(raw ? `workspaceRoot=${workspaceRoot}` : `workspaceRootHash=${workspaceRootHash(workspaceRoot)}`);
}
return parts.join(" ");
}
function truncate(text: string, max = 120): string {
const collapsed = text.replace(/\s+/g, " ").trim();
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
}
async function readJSONFile<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch {
return null;
}
}
async function readJSONLFile<T>(path: string): Promise<{ records: T[]; invalidLines: number }> {
let content = "";
try {
content = await readFile(path, "utf8");
} catch {
return { records: [], invalidLines: 0 };
}
const records: T[] = [];
let invalidLines = 0;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
records.push(JSON.parse(trimmed) as T);
} catch {
invalidLines += 1;
}
}
return { records, invalidLines };
}
function canonicalMemoryText(text: string): string {
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[\s\p{P}]+/gu, " ")
.trim();
}
function ageDays(entry: LongTermMemoryEntry): number | null {
const time = new Date(entry.createdAt).getTime();
if (Number.isNaN(time)) return null;
return Math.floor((Date.now() - time) / 86_400_000);
}
function formatStrength(value: number): string {
return Number.isFinite(value) ? value.toFixed(3) : "0.000";
}
function daysSinceIso(value: string | undefined, now = Date.now()): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
if (!Number.isFinite(ms)) return null;
return Math.max(0, (now - ms) / 86_400_000);
}
function formatPercent(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
type RetentionDiagItem = {
entry: LongTermMemoryEntry;
strength: number;
};
function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean {
return entry.safetyCritical === true;
}
function retentionCandidatesForDiag(store: WorkspaceMemoryStore): {
sorted: RetentionDiagItem[];
rendered: RetentionDiagItem[];
typeCapped: RetentionDiagItem[];
globalCapped: RetentionDiagItem[];
} {
const now = Date.now();
const active = store.entries.filter(entry => entry.status !== "superseded");
const sorted = active
.map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) }))
.sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id));
const rendered: RetentionDiagItem[] = [];
const typeCapped: RetentionDiagItem[] = [];
const globalCapped: RetentionDiagItem[] = [];
const typeCounts: Partial<Record<LongTermType, number>> = {};
for (const item of sorted) {
if (!isSafetyCriticalForDiag(item.entry)) {
const count = typeCounts[item.entry.type] ?? 0;
const max = TYPE_MAX_FOR_DIAG[item.entry.type] ?? Infinity;
if (count >= max) {
typeCapped.push(item);
continue;
}
typeCounts[item.entry.type] = count + 1;
}
if (rendered.length < LONG_TERM_LIMITS.maxEntries) {
rendered.push(item);
} else {
globalCapped.push(item);
}
}
return { sorted, rendered, typeCapped, globalCapped };
}
function promotionLimit(source: LongTermSource): number {
if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts;
return PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
}
function emptyStore(root: string, key: string): WorkspaceMemoryStore {
return {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [],
migrations: [],
updatedAt: new Date(0).toISOString(),
};
}
function normalizedStore(store: WorkspaceMemoryStore | null, root: string, key: string): WorkspaceMemoryStore {
const fallback = emptyStore(root, key);
return {
...fallback,
...(store ?? {}),
workspace: store?.workspace ?? fallback.workspace,
limits: {
maxRenderedChars: store?.limits?.maxRenderedChars ?? fallback.limits.maxRenderedChars,
maxEntries: store?.limits?.maxEntries ?? fallback.limits.maxEntries,
},
entries: Array.isArray(store?.entries) ? store.entries : [],
migrations: Array.isArray(store?.migrations) ? store.migrations : [],
};
}
function normalizedJournal(journal: PendingMemoryJournalStore | null): PendingMemoryJournalStore {
return {
version: 1,
workspace: journal?.workspace ?? { root: "", key: "" },
entries: Array.isArray(journal?.entries) ? journal.entries : [],
updatedAt: journal?.updatedAt ?? new Date(0).toISOString(),
};
}
async function runHealth(options: CliOptions): Promise<void> {
if (options.all) {
const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 });
console.log("Workspace memory health");
console.log("");
if (scan.results.length === 0) {
console.log("No workspace stores found.");
return;
}
for (let i = 0; i < scan.results.length; i += 1) {
const result = scan.results[i];
if (i > 0) console.log("");
await printWorkspaceHealth({
root: result.root,
key: result.workspaceKey,
memoryPath: join(result.workspaceDir, "workspace-memory.json"),
pendingPath: join(result.workspaceDir, "workspace-pending-journal.json"),
raw: options.raw,
});
}
return;
}
const root = options.workspace ?? process.cwd();
const key = await workspaceKey(root);
await printWorkspaceHealth({
root,
key,
memoryPath: await workspaceMemoryPath(root),
pendingPath: await workspacePendingJournalPath(root),
raw: options.raw,
includeTitle: true,
});
}
async function printWorkspaceHealth(input: {
root?: string;
key: string;
memoryPath: string;
pendingPath: string;
raw: boolean;
includeTitle?: boolean;
}): Promise<void> {
if (input.includeTitle) {
console.log("Workspace memory health");
console.log("");
}
const rawStore = await readJSONFile<WorkspaceMemoryStore>(input.memoryPath);
const storeRoot = rawStore?.workspace?.root ?? input.root ?? "";
const storeKey = rawStore?.workspace?.key ?? input.key;
const store = normalizedStore(rawStore, storeRoot, storeKey);
const journal = normalizedJournal(await readJSONFile<PendingMemoryJournalStore>(input.pendingPath));
const identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw);
if (identity) console.log(identity);
console.log(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`);
console.log(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`);
if (!rawStore) console.log("memory store: missing or unreadable (treated as empty)");
if (!existsSync(input.pendingPath)) console.log("pending journal: missing (treated as empty)");
console.log("");
const active = store.entries.filter(entry => entry.status !== "superseded");
const superseded = store.entries.filter(entry => entry.status === "superseded");
const retention = retentionCandidatesForDiag(store);
const renderedEntries = retention.rendered.map(item => item.entry);
const renderedEstimate = renderWorkspaceMemory(store).length;
console.log(`Stored active memories: ${active.length}`);
console.log(`Superseded memories: ${superseded.length}`);
console.log(`Rendered candidates: ${renderedEntries.length}`);
console.log(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
console.log("");
const pendingEntries = journal.entries;
const retryable = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) < promotionLimit(entry.source)).length;
const nearRetryLimit = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) >= promotionLimit(entry.source) - 1).length;
const pendingBySource = countBy(pendingEntries.map(entry => entry.source));
console.log("Pending journal:");
console.log(` total: ${pendingEntries.length}`);
console.log(` retryable: ${retryable}`);
console.log(` near retry limit: ${nearRetryLimit}`);
console.log(" by source:");
for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) {
console.log(` ${source}: ${pendingBySource.get(source) ?? 0}`);
}
console.log("");
console.log("By type:");
for (const type of TYPES) {
const storedCount = active.filter(entry => entry.type === type).length;
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
const supersededCount = superseded.filter(entry => entry.type === type).length;
console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${TYPE_MAX_FOR_DIAG[type]} superseded=${supersededCount}`);
}
console.log("");
console.log("Retention caps:");
console.log(` type-capped entries: ${retention.typeCapped.length}`);
console.log(` global-cap overflow: ${retention.globalCapped.length}`);
console.log("");
const olderThan30 = active.filter(entry => (ageDays(entry) ?? 0) > 30).length;
const olderThan90 = active.filter(entry => (ageDays(entry) ?? 0) > 90).length;
const staleMarked = active.filter(entry => {
const days = ageDays(entry);
return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays);
}).length;
console.log("Age:");
console.log(` stale-marked: ${staleMarked}`);
console.log(` older than 30d: ${olderThan30}`);
console.log(` older than 90d: ${olderThan90}`);
console.log("");
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt);
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG;
const dormantDaysPastGrace = wallDaysSinceActivity === null
? 0
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG);
console.log("Dormancy:");
console.log(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
console.log(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
console.log(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
console.log(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER_FOR_DIAG}`);
console.log("");
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
const highImportanceAlert = highImportanceRatio > 0.3;
const safetyCriticalAlert = safetyCriticalCount > 5;
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
console.log("Retention monitoring:");
console.log(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
console.log(` safety_critical_count: ${safetyCriticalCount} (alert > 5)${safetyCriticalAlert ? " ALERT" : ""}`);
console.log(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
console.log("");
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
console.log("Quality warnings:");
console.log(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`);
console.log(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`);
console.log(` duplicate-ish exact canonical text: ${duplicateExtras}`);
console.log(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`);
console.log("");
console.log("Suspicious active memories:");
for (const reason of SUSPICIOUS_REASONS) {
console.log(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`);
}
const failingQuality = qualityByEntry.filter(item => !item.quality.accepted);
if (failingQuality.length > 0) {
console.log("");
console.log("Active memories failing offline quality checks:");
for (const item of failingQuality.slice(0, 8)) {
console.log(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`);
}
}
console.log("");
console.log("Top rendered candidates:");
const top = retention.rendered.slice(0, 5);
if (top.length === 0) {
console.log(" (none)");
} else {
for (const item of top) {
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
console.log("");
console.log("Weakest active memories:");
const weakest = retention.sorted.slice(-5).reverse();
if (weakest.length === 0) {
console.log(" (none)");
} else {
for (const item of weakest) {
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
}
}
}
function inferOrigin(record: RejectionLogRecord): Origin {
if (record.origin && ALLOWED_ORIGINS.has(record.origin as Origin)) return record.origin as Origin;
if (record.source === "compaction") return "compaction_candidate";
if (record.source === "explicit") return "explicit_trigger";
if (record.source === "manual") return "manual";
return "unknown";
}
function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | null {
if (!record.text || !Array.isArray(record.reasons)) return null;
const origin = inferOrigin(record);
return {
timestamp: record.timestamp ?? "",
workspaceKey: record.workspaceKey,
workspaceRoot: record.workspaceRoot,
type: record.type ?? "project",
source: record.source,
origin,
fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger",
text: record.text,
reasons: record.reasons,
};
}
function sinceCutoff(rawSince: string | undefined): number | null {
if (!rawSince) return null;
const relative = rawSince.match(/^(\d+)([dhm])$/i);
if (relative) {
const amount = Number(relative[1]);
const unit = relative[2].toLowerCase();
const multiplier = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : 60_000;
return Date.now() - amount * multiplier;
}
const timestamp = new Date(rawSince).getTime();
if (Number.isNaN(timestamp)) die(`Invalid --since value: ${rawSince}`);
return timestamp;
}
function hasSoftReason(record: NormalizedRejection): boolean {
return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason));
}
async function runRejections(options: CliOptions): Promise<void> {
const path = extractionRejectionLogPath();
const { records, invalidLines } = await readJSONLFile<RejectionLogRecord>(path);
const cutoff = sinceCutoff(options.since);
let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null);
if (cutoff !== null) {
normalized = normalized.filter(record => {
const timestamp = new Date(record.timestamp).getTime();
return !Number.isNaN(timestamp) && timestamp >= cutoff;
});
}
if (options.softOnly) normalized = normalized.filter(hasSoftReason);
if (options.triggerOnly) normalized = normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger");
console.log("Extraction rejection summary");
console.log("");
console.log(`logPath=${cleanPath(path, options.raw)}`);
if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`);
console.log("");
console.log(`Total rejected: ${normalized.length}`);
console.log("");
console.log("By reason:");
const byReason = sortedCounts(countBy(normalized.flatMap(record => record.reasons)));
if (byReason.length === 0) console.log(" (none)");
else for (const [reason, count] of byReason) console.log(` ${reason.padEnd(24)} ${count}`);
console.log("");
console.log("By origin:");
const byOrigin = sortedCounts(countBy(normalized.map(record => record.origin)));
if (byOrigin.length === 0) console.log(" (none)");
else for (const [origin, count] of byOrigin) console.log(` ${origin.padEnd(24)} ${count}`);
console.log("");
console.log("Trigger-origin rejections (high priority for v1.5):");
const triggerReasons = sortedCounts(countBy(normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons)));
if (triggerReasons.length === 0) console.log(" (none)");
else for (const [reason, count] of triggerReasons) console.log(` ${reason.padEnd(24)} ${count}`);
console.log("");
console.log("Recent suspicious soft rejects:");
const suspicious = normalized
.filter(hasSoftReason)
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
.slice(0, 8);
if (suspicious.length === 0) {
console.log(" (none)");
} else {
for (const record of suspicious) {
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
console.log(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, options.raw)))}`);
console.log(` reasons: ${record.reasons.join(",")}`);
console.log(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`);
}
}
}
function migrationLogsRoot(): string {
return join(dataHome(), "opencode-working-memory", "migration-logs");
}
async function migrationLogPaths(options: CliOptions): Promise<string[]> {
if (options.migration) return [migrationLogPath(options.migration)];
const root = migrationLogsRoot();
let entries: string[] = [];
try {
entries = await readdir(root);
} catch {
return [];
}
return entries.filter(entry => entry.endsWith(".jsonl")).sort().map(entry => join(root, entry));
}
function migrationIdFromPath(path: string): string {
return path.split("/").pop()?.replace(/\.jsonl$/, "") ?? "unknown";
}
function riskySupersedeReasons(record: MigrationLogRecord): string[] {
const reasons: string[] = [];
const hardReasonsMissing = !Array.isArray(record.hardReasons);
const hardReasons = Array.isArray(record.hardReasons) ? record.hardReasons : [];
const qualityReasons = Array.isArray(record.reasons) ? record.reasons : [];
const text = record.text ?? "";
if (hardReasonsMissing || hardReasons.length === 0) reasons.push("missing_or_empty_hardReasons");
if (qualityReasons.length > 0 && hardReasons.length === 0) reasons.push("soft_reasons_without_hardReasons");
if (/\b(?:User|user|prefers|requires|wants|insists)\b|用戶|使用者|偏好|要求|不要|不刪除/u.test(text)) reasons.push("user_preference_marker");
if (/\b(?:must|should|do not|never|is|are|follows)\b|必須|應該|採用|維持|需支援/iu.test(text)) reasons.push("durable_rule_marker");
if ((record.type === "feedback" || record.type === "decision") && hardReasons.length === 1 && hardReasons[0] === "path_heavy") {
reasons.push("feedback_or_decision_path_heavy_only");
}
return reasons;
}
async function runAudit(options: CliOptions): Promise<void> {
const paths = await migrationLogPaths(options);
console.log("Migration audit report");
console.log("");
if (paths.length === 0) {
console.log("No migration logs found.");
return;
}
for (let i = 0; i < paths.length; i += 1) {
const path = paths[i];
const migrationId = options.migration ?? migrationIdFromPath(path);
const { records, invalidLines } = await readJSONLFile<MigrationLogRecord>(path);
const superseded = records.filter(record => !record.afterStatus || record.afterStatus === "superseded");
const hardReasons = superseded.flatMap(record => {
if (Array.isArray(record.hardReasons)) return record.hardReasons;
return Array.isArray(record.reasons) ? record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)) : [];
});
const risky = superseded
.map(record => ({ record, reasons: riskySupersedeReasons(record) }))
.filter(item => item.reasons.length > 0);
if (i > 0) console.log("");
console.log(`Migration: ${migrationId}`);
console.log(`logPath=${cleanPath(path, options.raw)}`);
if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`);
console.log(`Superseded entries: ${superseded.length}`);
console.log("");
console.log("By hard reason:");
const byHardReason = sortedCounts(countBy(hardReasons));
if (byHardReason.length === 0) console.log(" (none)");
else for (const [reason, count] of byHardReason) console.log(` ${reason.padEnd(24)} ${count}`);
console.log("");
console.log("Potentially risky supersedes:");
console.log(` ${risky.length}`);
for (const item of risky.slice(0, 10)) {
const record = item.record;
const hard = Array.isArray(record.hardReasons) ? record.hardReasons : [];
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
console.log(` - [${record.type ?? "unknown"}] hardReasons=${JSON.stringify(hard)} risk=${item.reasons.join(",")} ${JSON.stringify(truncate(cleanText(record.text ?? "", options.raw)))}`);
if (identity) console.log(` ${identity}`);
}
}
}
const { command, options } = parseArgs(process.argv.slice(2));
if (command === "health") await runHealth(options);
else if (command === "rejections") await runRejections(options);
else await runAudit(options);
+43 -52
View File
@@ -1,6 +1,11 @@
import { createHash } from "crypto";
import { appendFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
import { LONG_TERM_LIMITS } from "./types.ts";
import { assessMemoryQuality } from "./memory-quality.ts";
import { extractionRejectionLogPath } from "./paths.ts";
import { redactCredentials } from "./redaction.ts";
function id(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -51,7 +56,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
/(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim,
/(?:^|\n)\s*(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/gim,
// save/add to memory
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/gim,
// commit to memory
@@ -63,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>();
@@ -96,6 +102,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: nowMs,
staleAfterDays: staleAfterDaysFor(type),
});
}
@@ -199,7 +206,7 @@ function normalizeCandidateBody(body: string): { text: string; hadTrigger: boole
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im,
/(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im,
/(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im,
/(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/im,
/(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/im,
/(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im,
/(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im,
];
@@ -223,9 +230,27 @@ function extractFirstPath(text: string): string | undefined {
}
/**
* Quality gate for workspace memory candidates.
* Rejects low-quality entries like git hashes, error messages, etc.
* Acceptance gate for workspace memory candidates.
* Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts.
*/
type ExtractionRejectionLogEntry = {
timestamp: string;
type: LongTermType;
text: string;
reasons: string[];
source: "compaction";
};
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
try {
const path = extractionRejectionLogPath();
await mkdir(dirname(path), { recursive: true });
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
} catch (error) {
console.error("[memory] failed to write extraction rejection log:", error);
}
}
function shouldAcceptWorkspaceMemoryCandidate(
entry: {
type: LongTermType;
@@ -245,63 +270,27 @@ function shouldAcceptWorkspaceMemoryCandidate(
return false;
}
// Git history / commit hash
if (/\b[0-9a-f]{7,40}\b/.test(text)) return false;
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return false;
// Raw error / stack trace
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError):/i.test(text)) return false;
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return false;
// Active file list
if (/^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text)) return false;
// Temporary progress
if (/^(currently|now|pending|in progress|todo|wip):/i.test(text)) return false;
// Code signature / API doc
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false;
// Indirect Prompt Injection / Adversarial Instructions
// Rejects attempts to overwrite system behavior or "ignore" rules.
// comparative "instead of" is allowed.
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false;
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
// Path-heavy facts (rediscoverable from repo)
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
if (pathCount > 2) return false;
// Session-specific progress snapshots for project type
if (entry.type === "project") {
if (isProjectSnapshotViolation(text)) return false;
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
if (!quality.accepted) {
void logExtractionRejection({
timestamp: new Date().toISOString(),
type: entry.type,
text: redactCredentials(text),
reasons: quality.reasons,
source: "compaction",
});
return false;
}
return true;
}
function isProjectSnapshotViolation(text: string): boolean {
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// File counts with snapshot/process context only, not static limits
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
if (hasSnapshotContext && !hasLimitContext) return true;
}
// Phase/Wave/Sprint/Milestone/Task progress
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
return false;
}
/**
* Extract candidate block from summary using multiple formats.
* Supports: Plain text label, Markdown section, legacy XML.
@@ -330,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")) {
@@ -361,6 +351,7 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
status: "active",
createdAt: now,
updatedAt: now,
retentionClock: nowMs,
staleAfterDays: staleAfterDaysFor(type),
});
}
+124
View File
@@ -0,0 +1,124 @@
import type { LongTermMemoryEntry, LongTermSource } from "./types.ts";
export type MemoryQualityInput = Pick<LongTermMemoryEntry, "type" | "text"> & {
source?: LongTermSource;
};
export type MemoryQualityResult = {
accepted: boolean;
reasons: string[];
};
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
"empty",
"progress_snapshot",
"raw_error",
"commit_or_ci_snapshot",
"temporary_status",
"active_file_snapshot",
"code_or_api_signature",
"path_heavy",
]);
export function isHardQualityReason(reason: string): boolean {
return HARD_QUALITY_REASONS.has(reason);
}
export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult {
const reasons: string[] = [];
const text = entry.text.trim();
if (text.length === 0) reasons.push("empty");
if (isProgressSnapshotViolation(text)) reasons.push("progress_snapshot");
if (isRawErrorViolation(text)) reasons.push("raw_error");
if (isCommitOrCiViolation(text)) reasons.push("commit_or_ci_snapshot");
if (isPathHeavyViolation(text)) reasons.push("path_heavy");
if (isTemporaryStatusViolation(text)) reasons.push("temporary_status");
if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot");
if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature");
if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback");
if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision");
return { accepted: reasons.length === 0, reasons };
}
export function isProgressSnapshotViolation(text: string): boolean {
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
if (hasSnapshotContext && !hasLimitContext) return true;
}
if (/\b(?:completed|done|finished|implemented|added|updated|fixed|reviewed|passed|modified)\b/i.test(text)) {
if (/\b(?:wave|phase|task|plan|pr|commit|ci|test|suite|implementation|session|change|fix|review|file)\b/i.test(text)) return true;
}
if (/(?:已完成|完成|修復|实现|實作).{0,40}(?:wave|phase|task|plan|PR|測試|测试|實作|实现|修復)/iu.test(text)) return true;
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成|已完成/i.test(text)) return true;
}
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
if (/\b(?:currently|right now|latest change|previous session|last wave|next step)\b/i.test(text)) return true;
return false;
}
export function isFeedbackQualityViolation(text: string): boolean {
const stablePreference = /\b(?:user|the user)\s+(?:prefers|wants|asked|expects|requires|likes|dislikes)\b/i.test(text)
|| /\b(?:prefer|preference|going forward|from now on|always|never)\b/i.test(text)
|| /(?:使用者|用戶|用户).{0,12}(?:偏好|希望|要求|想要)/u.test(text)
|| /(?:以後|以后|請|请).{0,20}(?:使用|回答|保持|避免)/u.test(text);
if (stablePreference) return false;
const internalNote = /\b(?:implemented|updated|fixed|reviewed|added|changed|modified|created|writes|wrote)\b/i.test(text);
if (internalNote) return true;
return true;
}
export function isDecisionQualityViolation(text: string): boolean {
const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
if (!futureRule) return true;
if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true;
if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true;
return false;
}
function isRawErrorViolation(text: string): boolean {
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
return false;
}
function isCommitOrCiViolation(text: string): boolean {
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return true;
if (/\b[0-9a-f]{7,40}\b/.test(text)) return true;
if (/\bCI\b.*\b(?:passed|failed|run|compatibility|flaky)\b/i.test(text)) return true;
if (/\b(?:passed|failed|run|compatibility|flaky)\b.*\bCI\b/i.test(text)) return true;
if (/\bcompatibility\s+run\s+\d+/i.test(text)) return true;
return false;
}
function isPathHeavyViolation(text: string): boolean {
const pathCount = (text.match(/\/[\w.-]+(?:\/[\w.-]+)+/g) || []).length;
return pathCount > 2;
}
function isTemporaryStatusViolation(text: string): boolean {
if (/^(currently|now|pending|in progress|todo|wip)\b/i.test(text)) return true;
if (/\b(?:run npm test|tests? are running|next reply|before continuing)\b/i.test(text)) return true;
return false;
}
function isActiveFileSnapshotViolation(text: string): boolean {
return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text);
}
function isCodeOrApiSignatureViolation(text: string): boolean {
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return true;
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return true;
return false;
}
+8
View File
@@ -28,3 +28,11 @@ export async function sessionStatePath(root: string, sessionID: string): Promise
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
}
export function migrationLogPath(migrationId: string): string {
return join(dataHome(), "opencode-working-memory", "migration-logs", `${migrationId}.jsonl`);
}
export function extractionRejectionLogPath(): string {
return join(dataHome(), "opencode-working-memory", "extraction-rejections.jsonl");
}
+48 -37
View File
@@ -36,6 +36,7 @@ import {
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
renderWorkspaceMemory,
reinforceMemory,
} from "./workspace-memory.ts";
import {
appendPendingMemories,
@@ -108,45 +109,37 @@ function buildCompactionPrompt(privateContext: string): string {
"",
"## Relevant Files",
"",
"At the end of the summary, extract durable memory entries for future sessions.",
"At the end of the summary, include a Memory candidates section only if there are durable facts that will change future behavior.",
"",
"Memory quality bar:",
"Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.",
"",
"Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.",
"",
"When unsure, skip it. Fewer high-signal memories are better than many low-value ones.",
"CRITICAL MEMORY RULES:",
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
"- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.",
"- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.",
"- If unsure, skip it.",
"",
"Good memory examples:",
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
"- [decision] Use frozen workspace memory snapshots plus ephemeral hot state for cache stability.",
"- [project] The plugin should piggyback memory extraction on OpenCode compaction and avoid extra LLM calls.",
"- [reference] Workspace memory appears in frozen system[1]; pending memories appear in hot session state until compaction.",
"- [decision] Do not add semantic merge to memory dedupe.",
"- [project] This repository is an OpenCode plugin using local JSON stores.",
"- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.",
"",
"Bad memory examples to skip:",
"- 42 tests passed.",
"- Wave 2 completed successfully.",
"- Modified 5 files.",
"- commit 4309cb8 contains the latest fix.",
"- TypeError: Cannot read properties of undefined.",
"- Currently running npm test.",
"",
"A memory should still be useful if a new agent opens this workspace next week.",
"",
"Only extract facts that are likely to stay true across sessions.",
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
"For progress, extract the stable goal or durable milestone, not the current number.",
"For references, extract configuration values that do not usually change between sessions.",
"For feedback, extract unresolved issues or user preferences that future sessions need to know.",
"Use exactly this candidate format, including square brackets around the type:",
"- 180 tests passed and CI is green.",
"- Implemented owner-aware cleanup in plugin.ts.",
"- The assistant reviewed code reviewer feedback and updated the plan.",
"- Commit a762e86 contains the owner scope fix.",
"",
"Format when there ARE durable memories:",
"Memory candidates:",
"- [feedback] content",
"- [project] content",
"- [decision] content",
"- [reference] content",
"- [feedback|decision|project|reference] future-facing durable fact",
"",
"Do not write '- project content'; write '- [project] content'.",
"Format when there are NO durable memories:",
"Memory candidates:",
"(none)",
"",
"Background context, use this to inform the summary above.",
"Do not output this context verbatim:",
@@ -319,17 +312,35 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
beforeEntries = [...workspaceMemory.entries];
const existingKeys = new Set(
workspaceMemory.entries
.filter(memory => memory.status !== "superseded")
.map(memory => memoryKey(memory)),
);
const existingByKey = new Map<string, { memory: typeof workspaceMemory.entries[number]; index: number }>();
workspaceMemory.entries.forEach((memory, index) => {
if (memory.status === "superseded") return;
existingByKey.set(memoryKey(memory), { memory, index });
});
const promotedAt = Date.now();
for (const memory of pending) {
const key = memoryKey(memory);
if (!existingKeys.has(key)) {
workspaceMemory.entries.push(memory);
existingKeys.add(key);
const existing = existingByKey.get(key);
if (existing) {
const reinforced = reinforceMemory(
existing.memory,
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
promotedAt,
);
if (reinforced !== existing.memory) {
workspaceMemory.entries[existing.index] = reinforced;
existingByKey.set(key, { memory: reinforced, index: existing.index });
}
} else {
workspaceMemory.entries.push({
...memory,
retentionClock: memory.retentionClock ?? promotedAt,
});
existingByKey.set(key, {
memory: workspaceMemory.entries[workspaceMemory.entries.length - 1],
index: workspaceMemory.entries.length - 1,
});
}
}
+2 -5
View File
@@ -59,7 +59,7 @@ export function accountPendingPromotions(input: {
continue;
}
if (terminal.reason === "rejected_capacity" || terminal.reason === "rejected_stale") {
if (terminal.reason === "rejected_capacity") {
rejectedKeys.add(key);
continue;
}
@@ -80,10 +80,7 @@ export function accountPendingPromotions(input: {
...input.pending
.filter(memory => {
const terminal = terminalEventByKey.get(memoryKey(memory));
return memory.source === "compaction" && (
terminal?.reason === "rejected_capacity" ||
terminal?.reason === "rejected_stale"
);
return memory.source === "compaction" && terminal?.reason === "rejected_capacity";
})
.map(memory => memoryKey(memory)),
]);
+73
View File
@@ -0,0 +1,73 @@
/**
* Shared redaction utilities for sensitive credential patterns.
* Used by both workspace memory normalization and extraction rejection logging.
*/
// Password labels in multiple languages
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
// Username labels in multiple languages
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
// Sensitive key labels
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
// Secret value pattern (excludes common delimiters and brackets)
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
// Prefix patterns for different credential types
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
const BEARER_PREFIX = String.raw`(Bearer\s+)`;
/**
* Redacts sensitive credentials from text.
* Handles:
* - PINs in multiple formats
* - Username/password pairs
* - Standalone passwords
* - Bearer tokens
* - API keys, secrets, credentials, auth tokens, private keys
*
* Supports multiple languages and delimiters (ASCII and CJK).
*/
export function redactCredentials(text: string): string {
let result = text;
// 1. PIN
result = result.replace(
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
// 2. Username+password pair
result = result.replace(
new RegExp(
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
"gi",
),
"$1[REDACTED]$3$4[REDACTED]",
);
// 3. Standalone password
result = result.replace(
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
// 4. Bearer tokens (but not "bearer token:" labels)
result = result.replace(
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
// 5. Sensitive keys/tokens
result = result.replace(
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
return result;
}
+10 -3
View File
@@ -20,6 +20,12 @@ export type LongTermMemoryEntry = {
promotionAttempts?: number;
lastPromotionAttemptAt?: string;
lastPromotionFailureReason?: string;
retentionClock?: number; // Unix timestamp when retention started
reinforcementCount?: number; // Number of times this memory was reinforced
lastReinforcedAt?: number; // Unix timestamp of last reinforcement
lastReinforcedSessionID?: string;
userImportance?: "low" | "normal" | "high";
safetyCritical?: boolean;
};
export type WorkspaceMemoryStore = {
@@ -35,6 +41,7 @@ export type WorkspaceMemoryStore = {
entries: LongTermMemoryEntry[];
migrations?: string[];
updatedAt: string;
lastActivityAt?: string;
};
export type PendingMemoryJournalStore = {
@@ -88,15 +95,15 @@ export type SessionState = {
};
export const LONG_TERM_LIMITS = {
maxRenderedChars: 5200,
targetRenderedChars: 4200,
maxRenderedChars: 3600,
targetRenderedChars: 3000,
maxEntries: 28,
maxEntryTextChars: 260,
maxRationaleChars: 180,
} as const;
export const HOT_STATE_LIMITS = {
maxRenderedChars: 1200,
maxRenderedChars: 700,
maxActiveFilesStored: 20,
maxActiveFilesRendered: 8,
maxOpenErrorsStored: 5,
+282
View File
@@ -0,0 +1,282 @@
import { existsSync } from "node:fs";
import { appendFile, mkdir, readFile, readdir, rename, stat } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { dataHome as defaultDataHome } from "./paths.ts";
export type WorkspaceCleanupClassification =
| "test_temp_definite"
| "orphan_unknown"
| "live_or_existing"
| "invalid_or_unreadable";
export type WorkspaceCleanupResult = {
workspaceKey: string;
workspaceDir: string;
root?: string;
rootExists: boolean;
classification: WorkspaceCleanupClassification;
reasons: string[];
entryCount?: number;
migrations?: string[];
updatedAt?: string;
};
export type WorkspaceCleanupScanOptions = {
dataHome?: string;
nowMs?: number;
minAgeMs?: number;
includeOrphans?: boolean;
};
export type WorkspaceCleanupScan = {
results: WorkspaceCleanupResult[];
candidates: WorkspaceCleanupResult[];
};
export type WorkspaceCleanupMode = "dry-run" | "quarantine";
export type WorkspaceCleanupOptions = WorkspaceCleanupScanOptions & {
mode?: WorkspaceCleanupMode;
now?: Date;
};
export type WorkspaceCleanupQuarantineEvent = WorkspaceCleanupResult & {
from: string;
to: string;
quarantinedAt: string;
};
export type WorkspaceCleanupRunResult = WorkspaceCleanupScan & {
mode: WorkspaceCleanupMode;
quarantined: WorkspaceCleanupQuarantineEvent[];
quarantineDir?: string;
};
type WorkspaceMemoryShape = {
workspace?: {
root?: unknown;
key?: unknown;
};
entries?: unknown[];
migrations?: unknown[];
updatedAt?: unknown;
};
const DEFAULT_MIN_AGE_MS = 10 * 60 * 1_000;
const KNOWN_TEST_ROOT_PREFIXES = [
"memory-plugin-test-",
"memory-plugin-prompt-",
"wm-",
"wm-quality-",
"wm-accounting-",
"wm-redact-",
"wm-normalized-",
"wm-ordering-",
"wm-extraction-",
];
function normalizePathForComparison(path: string): string {
return resolve(path).replace(/\/+$/, "");
}
function isInsidePath(path: string, parent: string): boolean {
const normalizedPath = normalizePathForComparison(path);
const normalizedParent = normalizePathForComparison(parent);
return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`);
}
export function isTempRoot(root: string, osTmpdir = tmpdir()): boolean {
const normalized = normalizePathForComparison(root);
const normalizedTmp = normalizePathForComparison(osTmpdir);
if (isInsidePath(normalized, normalizedTmp)) return true;
if (isInsidePath(normalized, "/tmp")) return true;
if (isInsidePath(normalized, "/private/tmp")) return true;
return /^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/.test(normalized);
}
export function isKnownTestWorkspaceRoot(root: string): boolean {
const name = basename(root);
return KNOWN_TEST_ROOT_PREFIXES.some(prefix => name.startsWith(prefix));
}
function classifyCandidate(result: WorkspaceCleanupResult, includeOrphans: boolean): boolean {
if (result.reasons.includes("recent_workspace_dir")) return false;
if (result.reasons.includes("lock_present")) return false;
if (result.classification === "test_temp_definite") return true;
return includeOrphans && result.classification === "orphan_unknown";
}
export async function classifyWorkspaceDir(
workspaceDir: string,
options: { nowMs?: number; minAgeMs?: number } = {},
): Promise<WorkspaceCleanupResult> {
const workspaceKey = basename(workspaceDir);
const reasons: string[] = [];
const memoryPath = join(workspaceDir, "workspace-memory.json");
if (existsSync(`${memoryPath}.lock`)) {
reasons.push("lock_present");
}
let stats;
try {
stats = await stat(workspaceDir);
} catch {
return {
workspaceKey,
workspaceDir,
rootExists: false,
classification: "invalid_or_unreadable",
reasons: ["workspace_dir_unreadable"],
};
}
const minAgeMs = options.minAgeMs ?? DEFAULT_MIN_AGE_MS;
const nowMs = options.nowMs ?? Date.now();
if (minAgeMs > 0 && nowMs - stats.mtimeMs < minAgeMs) {
reasons.push("recent_workspace_dir");
}
let store: WorkspaceMemoryShape;
try {
store = JSON.parse(await readFile(memoryPath, "utf8")) as WorkspaceMemoryShape;
} catch {
return {
workspaceKey,
workspaceDir,
rootExists: false,
classification: "invalid_or_unreadable",
reasons: [...reasons, "invalid_json"],
};
}
const root = typeof store.workspace?.root === "string" ? store.workspace.root : undefined;
const key = typeof store.workspace?.key === "string" ? store.workspace.key : workspaceKey;
const entryCount = Array.isArray(store.entries) ? store.entries.length : undefined;
const migrations = Array.isArray(store.migrations) ? store.migrations.filter((item): item is string => typeof item === "string") : undefined;
const updatedAt = typeof store.updatedAt === "string" ? store.updatedAt : undefined;
if (!root) {
return {
workspaceKey: key,
workspaceDir,
rootExists: false,
classification: "invalid_or_unreadable",
reasons: [...reasons, "missing_workspace_root"],
entryCount,
migrations,
updatedAt,
};
}
const rootExists = existsSync(root);
if (rootExists) {
return {
workspaceKey: key,
workspaceDir,
root,
rootExists,
classification: "live_or_existing",
reasons: [...reasons, "root_exists"],
entryCount,
migrations,
updatedAt,
};
}
reasons.push("root_missing");
const tempRoot = isTempRoot(root);
const testRoot = isKnownTestWorkspaceRoot(root);
if (tempRoot) reasons.push("root_under_temp");
if (testRoot) reasons.push(`test_prefix_${KNOWN_TEST_ROOT_PREFIXES.find(prefix => basename(root).startsWith(prefix))?.replace(/-$/, "") ?? basename(root)}`);
return {
workspaceKey: key,
workspaceDir,
root,
rootExists,
classification: tempRoot || testRoot ? "test_temp_definite" : "orphan_unknown",
reasons,
entryCount,
migrations,
updatedAt,
};
}
function workspacesDir(dataHome: string): string {
return join(dataHome, "opencode-working-memory", "workspaces");
}
export async function scanWorkspaceResidues(options: WorkspaceCleanupScanOptions = {}): Promise<WorkspaceCleanupScan> {
const root = workspacesDir(options.dataHome ?? defaultDataHome());
const results: WorkspaceCleanupResult[] = [];
let entries: string[];
try {
entries = await readdir(root);
} catch {
return { results, candidates: [] };
}
for (const entry of entries) {
const workspaceDir = join(root, entry);
const stats = await stat(workspaceDir).catch(() => undefined);
if (!stats?.isDirectory()) continue;
results.push(await classifyWorkspaceDir(workspaceDir, {
nowMs: options.nowMs,
minAgeMs: options.minAgeMs,
}));
}
return {
results,
candidates: results.filter(result => classifyCandidate(result, options.includeOrphans ?? false)),
};
}
function quarantineName(now: Date): string {
return `workspace-cleanup-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`;
}
export async function cleanupWorkspaceResidues(options: WorkspaceCleanupOptions = {}): Promise<WorkspaceCleanupRunResult> {
const mode = options.mode ?? "dry-run";
const now = options.now ?? new Date();
const scan = await scanWorkspaceResidues({
dataHome: options.dataHome,
nowMs: options.nowMs,
minAgeMs: options.minAgeMs,
includeOrphans: options.includeOrphans,
});
if (mode === "dry-run" || scan.candidates.length === 0) {
return { ...scan, mode, quarantined: [] };
}
const dataHome = options.dataHome ?? defaultDataHome();
const quarantineDir = join(dataHome, "opencode-working-memory", "quarantine", quarantineName(now));
const quarantined: WorkspaceCleanupQuarantineEvent[] = [];
for (const candidate of scan.candidates) {
const destination = join(quarantineDir, "workspaces", candidate.workspaceKey);
await mkdir(dirname(destination), { recursive: true });
await rename(candidate.workspaceDir, destination);
const event: WorkspaceCleanupQuarantineEvent = {
...candidate,
from: candidate.workspaceDir,
to: destination,
quarantinedAt: now.toISOString(),
};
quarantined.push(event);
await mkdir(quarantineDir, { recursive: true });
await appendFile(join(quarantineDir, "manifest.jsonl"), JSON.stringify(event) + "\n", "utf8");
}
return { ...scan, mode, quarantined, quarantineDir };
}
+316 -128
View File
@@ -1,31 +1,160 @@
import { appendFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
import { LONG_TERM_LIMITS } from "./types.ts";
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
import { redactCredentials } from "./redaction.ts";
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
const MIN_ENVELOPE_LENGTH = 80;
const MIGRATION_ID = "2026-04-26-p0-cleanup";
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
// 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 PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
const TYPE_FACTOR = {
reference: 1.0,
project: 1.25,
feedback: 2.25,
decision: 2.5,
} as const;
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
const BEARER_PREFIX = String.raw`(Bearer\s+)`;
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;
@@ -48,7 +177,23 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
events: MemoryConsolidationEvent[];
};
export type QualityCleanupMigrationLogEntry = {
migrationId: string;
timestamp: string;
workspaceKey: string;
workspaceRoot: string;
entryId: string;
type: LongTermMemoryEntry["type"];
source: LongTermMemoryEntry["source"];
text: string;
reasons: string[];
hardReasons: string[];
beforeStatus: "active";
afterStatus: "superseded";
};
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
@@ -58,7 +203,8 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
},
entries: [],
migrations: [],
updatedAt: new Date().toISOString(),
updatedAt: nowIso,
lastActivityAt: nowIso,
};
}
@@ -77,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.
@@ -106,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;
}
@@ -186,8 +334,26 @@ export async function normalizeWorkspaceMemoryWithAccounting(
};
});
// One-time migration for legacy snapshot violations
result = runMigrationP0Cleanup(result, nowIso);
// One-time migrations for legacy/low-quality snapshot violations.
// Run quality cleanup first so hard violations receive quality audit tags
// before the older P0 project-only cleanup marks progress snapshots.
const beforeQualityCleanup = result;
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
result = qualityCleanup.store;
let skipRemainingMigrations = false;
if (qualityCleanup.events.length > 0) {
try {
await appendQualityCleanupMigrationLog(qualityCleanup.events);
} catch (error) {
console.error("[memory] failed to write quality cleanup migration log:", error);
console.error("[memory] aborting migration to maintain audit trail integrity");
result = beforeQualityCleanup;
skipRemainingMigrations = true;
}
}
if (!skipRemainingMigrations) {
result = runMigrationP0Cleanup(result, nowIso);
}
// P0 accounting only considers active entries. Entries that were already
// superseded before this normalization are preserved in storage; entries that
@@ -195,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 {
@@ -213,66 +380,6 @@ export async function normalizeWorkspaceMemoryWithAccounting(
};
}
export function redactCredentials(text: string): string {
let result = text;
// 1. PIN
result = result.replace(
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
// 2. Username+password pair
result = result.replace(
new RegExp(
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
"gi",
),
"$1[REDACTED]$3$4[REDACTED]",
);
// 3. Standalone password
result = result.replace(
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
// 4. Standalone sensitive keys/tokens
result = result.replace(
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
result = result.replace(
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
"$1[REDACTED]",
);
return result;
}
export function isProjectSnapshotViolation(text: string): boolean {
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// File counts with snapshot context, excluding limit statements
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
if (hasSnapshotContext && !hasLimitContext) return true;
}
// Phase/Wave/Sprint/Milestone/Task progress
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
return false;
}
export function runMigrationP0Cleanup(
store: WorkspaceMemoryStore,
nowIso: string,
@@ -282,10 +389,10 @@ export function runMigrationP0Cleanup(
}
const entries = store.entries.map(entry => {
if (entry.source === "explicit") return entry;
if (entry.source !== "compaction") return entry;
if (entry.type !== "project") return entry;
if (isProjectSnapshotViolation(entry.text)) {
if (isProgressSnapshotViolation(entry.text)) {
return {
...entry,
status: "superseded" as const,
@@ -304,6 +411,74 @@ export function runMigrationP0Cleanup(
};
}
async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationLogEntry[]): Promise<void> {
if (events.length === 0) return;
const path = migrationLogPath(QUALITY_CLEANUP_MIGRATION_ID);
await mkdir(dirname(path), { recursive: true });
await appendFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n", "utf8");
}
export function runMigrationQualityCleanup(
store: WorkspaceMemoryStore,
nowIso: string,
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
return { store, events: [] };
}
const events: QualityCleanupMigrationLogEntry[] = [];
let changed = false;
const entries = store.entries.map(entry => {
if (entry.source !== "compaction") return entry;
if (entry.status === "superseded") return entry;
const quality = assessMemoryQuality(entry);
if (quality.accepted) return entry;
const hardReasons = quality.reasons.filter(isHardQualityReason);
if (hardReasons.length === 0) return entry;
changed = true;
events.push({
migrationId: QUALITY_CLEANUP_MIGRATION_ID,
timestamp: nowIso,
workspaceKey: store.workspace.key,
workspaceRoot: store.workspace.root,
entryId: entry.id,
type: entry.type,
source: entry.source,
text: entry.text,
reasons: quality.reasons,
hardReasons,
beforeStatus: "active",
afterStatus: "superseded",
});
const tags = new Set([
...(entry.tags ?? []),
"quality_cleanup",
...hardReasons.map(reason => `quality:${reason}`),
]);
return {
...entry,
status: "superseded" as const,
updatedAt: nowIso,
tags: [...tags],
};
});
return {
store: {
...store,
entries,
migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID],
updatedAt: changed ? nowIso : store.updatedAt,
},
events,
};
}
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
if (source === "explicit") return 3;
if (source === "manual") return 2;
@@ -413,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,
@@ -461,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))
@@ -486,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[] = [];
@@ -513,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);
}
}
@@ -537,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);
}
}
@@ -564,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();
@@ -575,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,
@@ -597,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(
+86 -6
View File
@@ -1,6 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { extractErrorsFromBash, extractExplicitMemories } from "../src/extractors.ts";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extractErrorsFromBash, extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
async function waitForFile(path: string, attempts = 20): Promise<string> {
let lastError: unknown;
for (let i = 0; i < attempts; i += 1) {
try {
return await readFile(path, "utf8");
} catch (error) {
lastError = error;
await new Promise(resolve => setTimeout(resolve, 10));
}
}
throw lastError;
}
// ============================================
// Task 1: extractErrorsFromBash tests
@@ -75,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", () => {
@@ -129,8 +149,6 @@ test("extractExplicitMemories captures multiple memories in same message", () =>
// Task 7: Compaction quality gate tests
// ============================================
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
test("parseWorkspaceMemoryCandidates rejects short text", () => {
const summary = `
## Memory Candidates
@@ -186,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", () => {
@@ -223,7 +245,7 @@ test("parseWorkspaceMemoryCandidates accepts bracketless candidate format", () =
Memory candidates:
- project Backend health improvements organized into phased milestones
- reference Scrypt 參數必須是 N=16384, r=8, p=1
- feedback 端口 9473 可能被舊進程佔用,需殺掉後重啟
- feedback User prefers Traditional Chinese memory summaries
- decision Use output.prompt to replace the default compaction template
`;
@@ -281,6 +303,64 @@ Memory candidates:
assert.equal(items.length, 0, "Exact test counts are session snapshots, not durable memory");
});
test("parseWorkspaceMemoryCandidates logs quality gate rejections locally", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-reject-data-"));
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
const summary = `
Memory candidates:
- feedback Wave 1 completed successfully and all tests passed
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl");
const lines = (await waitForFile(logPath)).trim().split("\n");
assert.equal(lines.length, 1);
const event = JSON.parse(lines[0]);
assert.equal(event.type, "feedback");
assert.equal(event.text, "Wave 1 completed successfully and all tests passed");
assert.deepEqual(event.reasons, ["progress_snapshot", "bad_feedback"]);
assert.equal(event.source, "compaction");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(dataHome, { recursive: true, force: true });
}
});
test("parseWorkspaceMemoryCandidates redacts secrets in extraction rejection log", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-"));
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
const summary = `
Memory candidates:
- reference TypeError: bearer sk_test token=tok123 password=pass123 secret=sec123 api_key=key123
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl");
const lines = (await waitForFile(logPath)).trim().split("\n");
assert.equal(lines.length, 1);
const event = JSON.parse(lines[0]);
assert.equal(
event.text,
"TypeError: bearer [REDACTED] token=[REDACTED] password=[REDACTED] secret=[REDACTED] api_key=[REDACTED]",
);
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(dataHome, { recursive: true, force: true });
}
});
test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => {
const summary = `
Memory candidates:
@@ -451,14 +531,14 @@ test("parseWorkspaceMemoryCandidates allows benign ignore/instruction wording",
Memory candidates:
- [project] Use .gitignore to ignore generated files.
- [reference] Instruction parser supports Markdown sections and bracketed memory types.
- [decision] Prompt context uses a frozen workspace snapshot plus hot session state.
- [decision] Use a frozen workspace snapshot plus hot session state for prompt context.
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 3);
assert.equal(items[0].text, "Use .gitignore to ignore generated files.");
assert.equal(items[1].text, "Instruction parser supports Markdown sections and bracketed memory types.");
assert.equal(items[2].text, "Prompt context uses a frozen workspace snapshot plus hot session state.");
assert.equal(items[2].text, "Use a frozen workspace snapshot plus hot session state for prompt context.");
});
test("parseWorkspaceMemoryCandidates rejects direct system prompt override attempts", () => {
+74
View File
@@ -0,0 +1,74 @@
import type { LongTermMemoryEntry } from "../../src/types.ts";
const now = "2026-04-28T00:00:00.000Z";
function mem(
id: string,
type: LongTermMemoryEntry["type"],
text: string,
source: LongTermMemoryEntry["source"] = "compaction",
): LongTermMemoryEntry {
return {
id,
type,
text,
source,
confidence: source === "explicit" ? 1 : 0.75,
status: "active",
createdAt: now,
updatedAt: now,
};
}
export const reviewerCurrent28Fixture: LongTermMemoryEntry[] = [
// High-value durable entries. These should survive.
mem("good_feedback_language", "feedback", "User prefers architecture reviews in Traditional Chinese", "explicit"),
mem("good_feedback_direct", "feedback", "User wants direct architecture feedback with concrete file paths", "explicit"),
mem("good_feedback_no_manual_cleanup", "feedback", "User prefers automatic memory cleanup over manual cleanup instructions", "explicit"),
mem("good_decision_no_extra_api", "decision", "Do not add extra LLM API calls for memory consolidation"),
mem("good_decision_no_semantic_merge", "decision", "Memory dedupe must use exact canonical keys and generic URL/path identity only"),
mem("good_decision_no_render_tracking", "decision", "Do not use rendered-memory access tracking as evidence"),
mem("good_reference_frozen", "reference", "Workspace memory is rendered as a frozen system[1] snapshot; pending memories remain in hot session state until compaction"),
mem("good_project_plugin", "project", "The project is an OpenCode plugin using TypeScript and local JSON stores"),
mem("good_reference_accounting", "reference", "Promotion accounting reports promoted, absorbed, superseded, and rejected outcomes"),
// Pseudo feedback/decision/progress snapshots. These should be superseded/rejected.
mem("bad_feedback_wave_done", "feedback", "Wave 1 completed successfully and all tests passed"),
mem("bad_feedback_plan_done", "feedback", "Plan 1 critical stability fixes were implemented"),
mem("bad_feedback_session_note", "feedback", "The assistant reviewed the code reviewer feedback and updated the plan"),
mem("bad_feedback_impl_note", "feedback", "Implemented owner-aware pending journal cleanup in plugin.ts"),
mem("bad_decision_commit", "decision", "Commit 53aa6d3 completed consolidation accounting"),
mem("bad_decision_tests", "decision", "180 tests pass and 0 tests fail after the latest change"),
mem("bad_decision_pr_status", "decision", "PR1 is done and PR2 is ready to start"),
mem("bad_project_files", "project", "Modified src/plugin.ts src/workspace-memory.ts src/pending-journal.ts during the last wave"),
mem("bad_project_wave", "project", "Wave 3 finished after cache bounds and Bearer redaction were added"),
mem("bad_reference_commit", "reference", "Commit a762e86 contains the owner scope fix"),
mem("bad_reference_ci", "reference", "CI compatibility run 25033906652 passed"),
mem("bad_reference_error", "reference", "TypeError: Cannot read properties of undefined"),
mem("bad_project_current", "project", "Currently running npm test before continuing"),
// Borderline implementation facts. Reject unless they are written as future rules.
mem("bad_decision_impl_detail", "decision", "dedupeLongTermEntriesWithAccounting was updated in the previous session"),
mem("bad_feedback_internal", "feedback", "The migration writes to disk when redaction changes content"),
mem("bad_reference_tmp", "reference", "storage.test.ts had a flaky cross-process test in CI"),
// Durable future-facing rules. These should survive.
mem("good_decision_quality", "decision", "Reject completion and progress statements before storing compaction memory candidates"),
mem("good_decision_quality_shared", "decision", "Use one shared memory quality gate for extraction and migration"),
mem("good_reference_quality_migration", "reference", "Quality cleanup migration supersedes low-quality compaction memories and does not touch explicit memories"),
];
export const expectedAcceptedFixtureIds = new Set([
"good_feedback_language",
"good_feedback_direct",
"good_feedback_no_manual_cleanup",
"good_decision_no_extra_api",
"good_decision_no_semantic_merge",
"good_decision_no_render_tracking",
"good_reference_frozen",
"good_project_plugin",
"good_reference_accounting",
"good_decision_quality",
"good_decision_quality_shared",
"good_reference_quality_migration",
]);
+67
View File
@@ -0,0 +1,67 @@
import type { LongTermMemoryEntry } from "../../src/types.ts";
export type RealWorkspaceFixtureEntry = LongTermMemoryEntry & {
expectedAfterMigration: "active" | "superseded";
expectation: string;
};
const baseTimestamp = "2026-04-28T00:00:00.000Z";
function mem(
id: string,
type: LongTermMemoryEntry["type"],
text: string,
expectedAfterMigration: "active" | "superseded",
expectation: string,
): RealWorkspaceFixtureEntry {
return {
id,
type,
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: baseTimestamp,
updatedAt: baseTimestamp,
staleAfterDays: type === "feedback" ? undefined : 45,
expectedAfterMigration,
expectation,
};
}
export const REAL_WORKSPACE_FIXTURES: Record<string, RealWorkspaceFixtureEntry[]> = {
"workspace-alpha": [
mem("alpha_ui_rule", "feedback", "UI should have consistent style: both tables scrollable, about 20 rows", "active", "durable UI rule without user preference keyword"),
mem("alpha_csp_rule", "feedback", "Architecture recommendation: migrate the content security policy to nonce or hash rules rather than unsafe inline scripts", "active", "durable architecture recommendation"),
mem("alpha_form_rule", "decision", "Form uses defensive action and method attributes so the fallback does not navigate to the home page when scripts fail", "active", "declarative design rule"),
mem("alpha_logging_rule", "decision", "Cloud logging filter supports multiple log formats: structured event type, structured message, and text payload", "active", "durable declarative logging spec"),
],
"workspace-beta": [
mem("beta_phase_snapshot", "project", "Backend health improvement plan completed Phase 1-4", "superseded", "progress snapshot"),
mem("beta_test_snapshot", "project", "Test suite: 1237 tests pass, 226 suites", "superseded", "test count snapshot"),
mem("beta_sync_snapshot", "project", "External drive synced 37 files including bundles, service, frontend, tests, and docs", "superseded", "file sync snapshot"),
],
"workspace-gamma": [
mem("gamma_need_check", "feedback", "Architecture recommendation: confirm actual demand before executing the later priority phase", "active", "durable plan decision"),
mem("gamma_review_fallback", "feedback", "Primary review automation can be unreliable; use phase verification as the fallback", "active", "durable workaround rule"),
mem("gamma_wave_rule", "feedback", "Each wave should end with verifier confirmation, and the full implementation should end with code review", "active", "durable workflow rule"),
mem("gamma_remote_headers", "decision", "Remote headers are passed through the HTTP transport request initialization headers option", "active", "declarative API rule"),
mem("gamma_signal_order", "decision", "Graceful process cleanup signal order: interrupt for 300ms, terminate for 700ms, then kill", "active", "durable process cleanup spec"),
mem("gamma_ownership", "decision", "Runtime state ownership model: the command-line entrypoint owns both runtime objects, and disposal order is primary runtime first", "active", "durable ownership model"),
mem("gamma_retry_policy", "decision", "Recovery retry policy: only once per tool call, only for transport or session failures", "active", "durable retry policy"),
],
"workspace-delta": [
mem("delta_user_cycle", "feedback", "User requires a complete plan, review, feedback, modify, and verify loop rather than direct execution", "active", "user workflow preference"),
mem("delta_batching", "feedback", "Large-batch embedding requires controlled batch size around 20 to 50 items and a delay between requests", "active", "durable operational knowledge"),
mem("delta_option_b", "decision", "Phase 2 fix adopted Option B: grouped search across multiple profiles", "active", "design decision using adopted"),
mem("delta_single_source", "decision", "MCP source keeps a single generic source type, with item identity encoded in the source ID", "active", "design constraint using keeps"),
mem("delta_endpoint", "decision", "Embedding service endpoint is `/api/embed` rather than `/api/embeddings`, with the input field in the request body", "active", "declarative API fact"),
mem("delta_filter_pipeline", "decision", "Filter pipeline uses pre-chunk filtering rather than post-chunk filtering to prevent embedding contamination", "active", "durable architecture rule"),
mem("delta_do_not_delete", "decision", "Do not delete isolated reference-like lines because citation fragments in body text can be valid references", "active", "do-not rule"),
],
"workspace-epsilon": [
mem("epsilon_author_credit", "feedback", "User insists on preserving external contributor author credit and uses merge workflow", "active", "durable preference using insists"),
mem("epsilon_branding", "decision", "Product branding is \"Generic Working Memory\" without \"Plugin\" in the name", "active", "durable branding rule"),
mem("epsilon_changelog", "decision", "Changelog version scope follows release tags: changes from the previous version tag through the current branch belong to the next version", "active", "durable release rule"),
],
};
+143
View File
@@ -0,0 +1,143 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
const execFileAsync = promisify(execFile);
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
function entry(id: string, text: string, type: LongTermMemoryEntry["type"]): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
id,
type,
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
};
}
async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[], options: { lastActivityAt?: string; omitLastActivityAt?: boolean } = {}): Promise<void> {
const key = await workspaceKey(root);
const path = await workspaceMemoryPath(root);
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries,
migrations: [],
updatedAt: now,
};
if (!options.omitLastActivityAt) store.lastActivityAt = options.lastActivityAt ?? now;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
}
async function runMemoryDiagHealth(root: string): Promise<string> {
const { stdout } = await execFileAsync(process.execPath, [
"--experimental-strip-types",
"scripts/memory-diag.ts",
"health",
"--workspace",
root,
], { cwd: repoRoot });
return stdout;
}
test("memory health reports stored vs rendered retention counts", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 17 }, (_, i) => entry(`feedback-${i}`, `Unique feedback preference for memory health ${i}`, "feedback")),
...Array.from({ length: 11 }, (_, i) => entry(`decision-${i}`, `Unique durable decision for memory health ${i}`, "decision")),
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Stored active memories:/);
assert.match(stdout, /Rendered candidates:/);
assert.match(stdout, /feedback\s+stored=17\s+rendered=10/);
assert.match(stdout, /Top rendered candidates:\n\s+- strength=/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports dormancy and retention monitoring alerts", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const lastActivityAt = new Date(Date.now() - 19 * 24 * 60 * 60 * 1000).toISOString();
const entries = Array.from({ length: 10 }, (_, i) => ({
...entry(`monitoring-${i}`, `Unique monitoring memory ${i} for retention health`, i % 2 === 0 ? "feedback" : "decision"),
userImportance: i < 4 ? "high" as const : "normal" as const,
safetyCritical: i < 6,
reinforcementCount: i < 2 ? 6 : 0,
}));
await writeWorkspaceStore(root, entries, { lastActivityAt });
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Dormancy:/);
assert.match(stdout, /wall days since activity: 19\.0/);
assert.match(stdout, /dormant discount active: yes/);
assert.match(stdout, /dormant days past grace: 5\.0/);
assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/);
assert.match(stdout, /safety_critical_count: 6 .* ALERT/);
assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports global cap overflow separately from type caps", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
const entries: LongTermMemoryEntry[] = [
...Array.from({ length: 10 }, (_, i) => entry(`global-feedback-${i}`, `Unique global feedback preference ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`global-decision-${i}`, `Unique global durable decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`global-project-${i}`, `Unique global project fact ${i}`, "project")),
...Array.from({ length: 6 }, (_, i) => entry(`global-reference-${i}`, `Unique global reference fact ${i}`, "reference")),
];
await writeWorkspaceStore(root, entries);
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /Rendered candidates: 28/);
assert.match(stdout, /type-capped entries: 0/);
assert.match(stdout, /global-cap overflow: 6/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("memory health reports missing dormancy and non-alert monitoring defaults", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-"));
try {
await writeWorkspaceStore(root, [], { omitLastActivityAt: true });
const stdout = await runMemoryDiagHealth(root);
assert.match(stdout, /lastActivityAt: \(missing\)/);
assert.match(stdout, /wall days since activity: unknown/);
assert.match(stdout, /dormant discount active: no/);
assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/);
assert.match(stdout, /safety_critical_count: 0 \(alert > 5\)\n/);
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+83 -1
View File
@@ -1,6 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import { extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import { assessMemoryQuality, isHardQualityReason } from "../src/memory-quality.ts";
import { expectedAcceptedFixtureIds, reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
const acceptedCases = [
{
@@ -64,6 +66,18 @@ const rejectedCases = [
name: "temporary pending task",
line: "- [decision] currently: run npm test before the next reply",
},
{
name: "misclassified feedback completion snapshot",
line: "- [feedback] Wave 1 completed successfully and all tests passed",
},
{
name: "misclassified decision implementation note",
line: "- [decision] Implemented owner-aware cleanup in plugin.ts",
},
{
name: "session internal review note",
line: "- [feedback] The assistant reviewed the code reviewer feedback and updated the plan",
},
] as const;
for (const item of acceptedCases) {
@@ -91,3 +105,71 @@ ${item.line}
assert.equal(entries.length, 0);
});
}
test("reviewer current-28 fixture keeps durable memories and rejects pseudo memories", () => {
for (const entry of reviewerCurrent28Fixture) {
const result = assessMemoryQuality(entry);
assert.equal(
result.accepted,
expectedAcceptedFixtureIds.has(entry.id),
`${entry.id}: ${entry.text} -> ${result.reasons.join(",")}`,
);
}
});
test("progress snapshot rejection is type independent", () => {
for (const type of ["feedback", "project", "decision", "reference"] as const) {
const result = assessMemoryQuality({ type, text: "Wave 2 completed successfully", source: "compaction" });
assert.equal(result.accepted, false, `${type} progress snapshots must reject`);
assert.ok(result.reasons.includes("progress_snapshot"));
}
});
test("feedback must be stable user preference or instruction", () => {
assert.equal(assessMemoryQuality({ type: "feedback", text: "User prefers concise architecture reviews", source: "compaction" }).accepted, true);
assert.equal(assessMemoryQuality({ type: "feedback", text: "Implemented owner-aware cleanup in plugin.ts", source: "compaction" }).accepted, false);
});
test("decision must be future-facing rule, not completed implementation note", () => {
assert.equal(assessMemoryQuality({ type: "decision", text: "Do not add semantic merge to memory dedupe", source: "compaction" }).accepted, true);
assert.equal(assessMemoryQuality({ type: "decision", text: "Use the cache boundary that was chosen in ADR-2 for future memory rendering", source: "compaction" }).accepted, true);
assert.equal(assessMemoryQuality({ type: "decision", text: "Added semantic merge tests in the previous wave", source: "compaction" }).accepted, false);
});
test("shared quality gate owns extractor low-quality syntax rejections", () => {
const rejected = [
{ type: "project" as const, text: "fix: add new feature" },
{ type: "reference" as const, text: "modified src/plugin.ts" },
{ type: "reference" as const, text: "function buildCompactionPrompt(privateContext: string): string" },
{ type: "reference" as const, text: "GET /api/sessions" },
];
for (const entry of rejected) {
assert.equal(
assessMemoryQuality({ ...entry, source: "compaction" }).accepted,
false,
`${entry.type}: ${entry.text}`,
);
}
});
test("explicit memories bypass extraction quality gate", () => {
const entries = extractExplicitMemories("remember: Wave 1 completed successfully and all tests passed");
assert.equal(entries.length, 1);
assert.equal(entries[0].source, "explicit");
assert.match(entries[0].text, /Wave 1 completed/);
});
test("hard quality reasons exclude soft whitelist failures", () => {
assert.equal(isHardQualityReason("progress_snapshot"), true);
assert.equal(isHardQualityReason("raw_error"), true);
assert.equal(isHardQualityReason("commit_or_ci_snapshot"), true);
assert.equal(isHardQualityReason("temporary_status"), true);
assert.equal(isHardQualityReason("active_file_snapshot"), true);
assert.equal(isHardQualityReason("code_or_api_signature"), true);
assert.equal(isHardQualityReason("path_heavy"), true);
assert.equal(isHardQualityReason("empty"), true);
assert.equal(isHardQualityReason("bad_feedback"), false);
assert.equal(isHardQualityReason("bad_decision"), false);
});
+190 -10
View File
@@ -297,9 +297,9 @@ test("compaction hook sets output.prompt with ---free template", async () => {
"Prompt should include concrete positive memory examples");
assert.equal(prompt!.includes("Bad memory examples to skip:"), true,
"Prompt should include concrete negative memory examples");
assert.equal(prompt!.includes("42 tests passed"), true,
assert.equal(prompt!.includes("180 tests passed"), true,
"Prompt should explicitly reject test-count snapshots");
assert.equal(prompt!.includes("commit 4309cb8"), true,
assert.equal(prompt!.includes("Commit a762e86"), true,
"Prompt should explicitly reject commit-hash snapshots");
// Should contain our context data (hot session state)
@@ -317,6 +317,27 @@ test("compaction hook sets output.prompt with ---free template", async () => {
}
});
test("compaction prompt forbids progress and session-internal memory candidates", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
try {
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const output = { prompt: "", context: [] as string[] };
await (plugin as Record<string, Function>)["experimental.session.compacting"](
{ sessionID: "prompt-session", model: {} },
output,
);
assert.match(output.prompt, /CRITICAL MEMORY RULES/);
assert.match(output.prompt, /NO completion or progress statements/i);
assert.match(output.prompt, /NO session-internal implementation notes/i);
assert.match(output.prompt, /feedback ONLY/i);
assert.match(output.prompt, /Most compactions should produce ZERO memories/i);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("compaction hook merges existing output.context from other plugins", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -751,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 });
}
@@ -1401,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",
@@ -1413,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;
});
@@ -1455,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",
@@ -1467,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;
});
@@ -1510,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",
@@ -1522,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;
});
@@ -1593,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",
@@ -1605,6 +1761,30 @@ test("session.compacted clears compaction pending memories when all rejected by
updatedAt: now,
});
}
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_all_rejected_decision_${i}`,
type: "decision",
text: `Pinned high priority decision ${i} that keeps the workspace entry cap full.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
for (let i = 0; i < 8; i += 1) {
store.entries.push({
id: `mem_high_all_rejected_project_${i}`,
type: "project",
text: `Pinned high priority project ${i} that keeps the workspace entry cap full.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
-17
View File
@@ -229,20 +229,3 @@ test("accountPendingPromotions marks manual capacity rejection as retryable", ()
assert.equal(result.clearableKeys.size, 0);
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
});
test("accountPendingPromotions clears compaction stale rejection from accounting", () => {
const pending = [mem("pending_stale", "Stale compaction reference should be terminal.", {
type: "reference",
source: "compaction",
})];
const result = accountPendingPromotions({
pending,
before: [],
after: [],
events: [event(pending[0], "rejected_stale")],
});
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
});
+21
View File
@@ -0,0 +1,21 @@
import { after } from "node:test";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const previousTestFlag = process.env.OPENCODE_WORKING_MEMORY_TEST;
const testDataHome = await mkdtemp(join(tmpdir(), "opencode-working-memory-test-xdg-"));
process.env.XDG_DATA_HOME = testDataHome;
process.env.OPENCODE_WORKING_MEMORY_TEST = "1";
after(async () => {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
if (previousTestFlag === undefined) delete process.env.OPENCODE_WORKING_MEMORY_TEST;
else process.env.OPENCODE_WORKING_MEMORY_TEST = previousTestFlag;
await rm(testDataHome, { recursive: true, force: true });
});
+171
View File
@@ -0,0 +1,171 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
classifyWorkspaceDir,
cleanupWorkspaceResidues,
scanWorkspaceResidues,
} from "../src/workspace-cleanup.ts";
async function writeWorkspaceStore(dataHome: string, key: string, root: string): Promise<string> {
const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", key);
await mkdir(workspaceDir, { recursive: true });
await writeFile(
join(workspaceDir, "workspace-memory.json"),
JSON.stringify({
version: 1,
workspace: { root, key },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
entries: [],
updatedAt: "2026-04-28T00:00:00.000Z",
}, null, 2),
"utf8",
);
return workspaceDir;
}
test("workspace cleanup classifies missing temp test roots as definite residue", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
try {
const missingTempRoot = join(tmpdir(), "memory-plugin-test-missing-root");
await rm(missingTempRoot, { recursive: true, force: true });
const workspaceDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot);
const result = await classifyWorkspaceDir(workspaceDir);
assert.equal(result.classification, "test_temp_definite");
assert.equal(result.rootExists, false);
assert.ok(result.reasons.includes("root_missing"));
assert.ok(result.reasons.some(reason => reason.startsWith("root_under_temp")));
assert.ok(result.reasons.includes("test_prefix_memory-plugin-test"));
} finally {
await rm(dataHome, { recursive: true, force: true });
}
});
test("workspace cleanup keeps existing temp roots live", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
const liveRoot = await mkdtemp(join(tmpdir(), "wm-quality-live-root-"));
try {
const workspaceDir = await writeWorkspaceStore(dataHome, "live", liveRoot);
const result = await classifyWorkspaceDir(workspaceDir);
assert.equal(result.classification, "live_or_existing");
assert.equal(result.rootExists, true);
} finally {
await rm(dataHome, { recursive: true, force: true });
await rm(liveRoot, { recursive: true, force: true });
}
});
test("workspace cleanup reports missing non-temp roots as unknown orphans", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
try {
const missingNonTempRoot = `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`;
const workspaceDir = await writeWorkspaceStore(dataHome, "orphan", missingNonTempRoot);
const result = await classifyWorkspaceDir(workspaceDir);
assert.equal(result.classification, "orphan_unknown");
assert.equal(result.rootExists, false);
assert.ok(result.reasons.includes("root_missing"));
} finally {
await rm(dataHome, { recursive: true, force: true });
}
});
test("workspace cleanup reports invalid stores without moving them", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
try {
const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", "invalid");
await mkdir(workspaceDir, { recursive: true });
await writeFile(join(workspaceDir, "workspace-memory.json"), "{ invalid", "utf8");
const result = await classifyWorkspaceDir(workspaceDir);
assert.equal(result.classification, "invalid_or_unreadable");
assert.ok(result.reasons.includes("invalid_json"));
const cleanup = await cleanupWorkspaceResidues({ dataHome, mode: "quarantine" });
assert.equal(cleanup.quarantined.length, 0);
assert.equal(existsSync(workspaceDir), true);
} finally {
await rm(dataHome, { recursive: true, force: true });
}
});
test("workspace cleanup dry-run scans definite residue without moving it", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
try {
const missingTempRoot = join(tmpdir(), "wm-accounting-missing-root");
await rm(missingTempRoot, { recursive: true, force: true });
const workspaceDir = await writeWorkspaceStore(dataHome, "dryrun", missingTempRoot);
const result = await cleanupWorkspaceResidues({ dataHome, minAgeMs: 0 });
assert.equal(result.mode, "dry-run");
assert.equal(result.candidates.length, 1);
assert.equal(result.quarantined.length, 0);
assert.equal(existsSync(workspaceDir), true);
} finally {
await rm(dataHome, { recursive: true, force: true });
}
});
test("workspace cleanup quarantine moves definite residue and writes manifest", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
try {
const missingTempRoot = join(tmpdir(), "wm-redact-missing-root");
await rm(missingTempRoot, { recursive: true, force: true });
const definiteDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot);
const orphanDir = await writeWorkspaceStore(dataHome, "orphan", `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`);
const result = await cleanupWorkspaceResidues({
dataHome,
mode: "quarantine",
minAgeMs: 0,
now: new Date("2026-04-28T12:00:00.000Z"),
});
assert.equal(result.quarantined.length, 1);
assert.equal(result.quarantined[0]?.workspaceKey, "definite");
assert.equal(existsSync(definiteDir), false);
assert.equal(existsSync(orphanDir), true);
assert.ok(result.quarantineDir);
assert.equal(existsSync(join(result.quarantineDir!, "workspaces", "definite", "workspace-memory.json")), true);
const manifest = await readFile(join(result.quarantineDir!, "manifest.jsonl"), "utf8");
const event = JSON.parse(manifest.trim());
assert.equal(event.workspaceKey, "definite");
assert.equal(event.classification, "test_temp_definite");
assert.equal(event.root, missingTempRoot);
} finally {
await rm(dataHome, { recursive: true, force: true });
}
});
test("workspace cleanup skips recently updated definite residue", async () => {
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
try {
const missingTempRoot = join(tmpdir(), "wm-extraction-missing-root");
await rm(missingTempRoot, { recursive: true, force: true });
const workspaceDir = await writeWorkspaceStore(dataHome, "recent", missingTempRoot);
const stats = await stat(workspaceDir);
const result = await scanWorkspaceResidues({
dataHome,
nowMs: stats.mtimeMs + 1_000,
minAgeMs: 10 * 60 * 1_000,
});
assert.equal(result.candidates.length, 0);
assert.equal(result.results.find(item => item.workspaceKey === "recent")?.classification, "test_temp_definite");
assert.ok(result.results.find(item => item.workspaceKey === "recent")?.reasons.includes("recent_workspace_dir"));
} finally {
await rm(dataHome, { recursive: true, force: true });
}
});
+825 -54
View File
@@ -4,8 +4,8 @@ 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 { workspaceMemoryPath } from "../src/paths.ts";
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
import {
renderWorkspaceMemory,
enforceLongTermLimits,
@@ -14,13 +14,30 @@ import {
normalizeWorkspaceMemoryWithAccounting,
workspaceMemoryExactKey,
workspaceMemoryIdentityKey,
redactCredentials,
isProjectSnapshotViolation,
runMigrationP0Cleanup,
runMigrationQualityCleanup,
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();
@@ -253,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 = {
@@ -375,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);
@@ -442,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,
};
@@ -467,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(
@@ -487,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 () => {
@@ -503,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;
});
@@ -648,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", () => {
@@ -889,13 +1283,13 @@ test("redactCredentials is idempotent and also redacts rationale text", () => {
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
});
test("isProjectSnapshotViolation detects wave progress and avoids limit context false positives", () => {
assert.equal(isProjectSnapshotViolation("1237 tests pass, 226 suites"), true);
assert.equal(isProjectSnapshotViolation("USB 同步:37 個文件"), true);
assert.equal(isProjectSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true);
test("shared progress snapshot rule detects wave progress and avoids limit context false positives", () => {
assert.equal(isProgressSnapshotViolation("1237 tests pass, 226 suites"), true);
assert.equal(isProgressSnapshotViolation("USB 同步:37 個文件"), true);
assert.equal(isProgressSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true);
assert.equal(isProjectSnapshotViolation("Upload limit is 10 files"), false);
assert.equal(isProjectSnapshotViolation("Project supports 5 test suites"), false);
assert.equal(isProgressSnapshotViolation("Upload limit is 10 files"), false);
assert.equal(isProgressSnapshotViolation("Project supports 5 test suites"), false);
});
test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs once", () => {
@@ -953,6 +1347,379 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
});
test("quality cleanup migration preserves soft-only feedback and decision violations", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-quality-soft-preserve-"));
try {
const now = new Date().toISOString();
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [
{
id: "soft_feedback",
type: "feedback",
text: "UI 要統一風格:兩個表格都要 scrollable,約 20 rows",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
},
{
id: "soft_decision",
type: "decision",
text: "Product branding is \"OpenCode Working Memory\" without \"Plugin\" in the name",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
staleAfterDays: 45,
},
],
migrations: [],
updatedAt: now,
});
const loaded = await loadWorkspaceMemory(root);
assert.equal(loaded.entries.find(e => e.id === "soft_feedback")?.status, "active");
assert.equal(loaded.entries.find(e => e.id === "soft_decision")?.status, "active");
assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("quality cleanup migration supersedes hard quality violations", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-quality-hard-supersede-"));
try {
const now = new Date().toISOString();
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "hard_progress",
type: "project",
text: "測試套件:1237 tests pass, 226 suites",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
staleAfterDays: 60,
}],
migrations: [],
updatedAt: now,
});
const loaded = await loadWorkspaceMemory(root);
const entry = loaded.entries.find(e => e.id === "hard_progress");
assert.equal(entry?.status, "superseded");
assert.ok(entry?.tags?.includes("quality_cleanup"));
assert.ok(entry?.tags?.includes("quality:progress_snapshot"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("quality cleanup migration writes audit log for hard supersedes", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-quality-audit-root-"));
const dataHome = await mkdtemp(join(tmpdir(), "wm-quality-audit-data-"));
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
const now = new Date().toISOString();
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "hard_progress",
type: "project",
text: "測試套件:1237 tests pass, 226 suites",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
staleAfterDays: 60,
}],
migrations: [],
updatedAt: now,
});
await loadWorkspaceMemory(root);
const logPath = join(dataHome, "opencode-working-memory", "migration-logs", "2026-04-28-quality-cleanup.jsonl");
const lines = (await readFile(logPath, "utf8")).trim().split("\n");
assert.equal(lines.length, 1);
const event = JSON.parse(lines[0]);
assert.equal(event.migrationId, "2026-04-28-quality-cleanup");
assert.equal(event.entryId, "hard_progress");
assert.deepEqual(event.hardReasons, ["progress_snapshot"]);
assert.equal(event.beforeStatus, "active");
assert.equal(event.afterStatus, "superseded");
assert.equal(event.text, "測試套件:1237 tests pass, 226 suites");
} finally {
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(root, { recursive: true, force: true });
await rm(dataHome, { recursive: true, force: true });
}
});
test("quality cleanup migration aborts supersede when audit log cannot be written", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const previousConsoleError = console.error;
process.env.XDG_DATA_HOME = dataHome;
console.error = () => {};
try {
await mkdir(root, { recursive: true });
const now = "2026-04-28T00:00:00.000Z";
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify({
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "hard_progress",
type: "project",
text: "Test suite: 1237 tests pass, 226 suites",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
staleAfterDays: 60,
}],
migrations: [],
updatedAt: now,
}, null, 2), "utf8");
const blockedLogDir = join(dataHome, "opencode-working-memory", "migration-logs");
await writeFile(blockedLogDir, "not a directory", "utf8");
const loaded = await loadWorkspaceMemory(root);
const persisted = JSON.parse(await readFile(storePath, "utf8")) as WorkspaceMemoryStore;
assert.equal(loaded.entries.find(entry => entry.id === "hard_progress")?.status, "active");
assert.equal(persisted.entries.find(entry => entry.id === "hard_progress")?.status, "active");
assert.equal(loaded.migrations?.includes("2026-04-28-quality-cleanup"), false);
assert.equal(persisted.migrations?.includes("2026-04-28-quality-cleanup"), false);
} finally {
console.error = previousConsoleError;
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousXdgDataHome;
await rm(sandbox, { recursive: true, force: true });
}
});
test("real workspace regression fixture is de-identified and English-only", () => {
const cjkText = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
const identifyingTerms = [
"medical-atlas",
"opencode-record",
"agent-reports",
"pdf-extraction",
"self-repo",
"OpenCode Working Memory",
];
const failures: string[] = [];
for (const [workspaceName, fixtureEntries] of Object.entries(REAL_WORKSPACE_FIXTURES)) {
if (identifyingTerms.some(term => workspaceName.includes(term))) {
failures.push(`${workspaceName}: workspace key should be generalized`);
}
for (const entry of fixtureEntries) {
if (cjkText.test(entry.text)) {
failures.push(`${workspaceName}/${entry.id}: text must be English-only`);
}
for (const term of identifyingTerms) {
if (entry.text.includes(term)) {
failures.push(`${workspaceName}/${entry.id}: text contains identifying term ${term}`);
}
}
}
}
assert.equal(failures.length, 0, `Fixture privacy failures:\n${failures.join("\n")}`);
});
test("quality cleanup migration regression against real workspace samples", async () => {
const failures: string[] = [];
const now = "2026-04-28T00:00:00.000Z";
for (const [workspaceName, fixtureEntries] of Object.entries(REAL_WORKSPACE_FIXTURES)) {
const root = `/fixture/${workspaceName}`;
const store = {
version: 1,
workspace: { root, key: workspaceName.padEnd(16, "0").slice(0, 16) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: fixtureEntries.map(({ expectedAfterMigration, expectation, ...entry }) => entry),
migrations: [],
updatedAt: now,
};
const result = runMigrationQualityCleanup(store, now).store;
const byId = new Map(result.entries.map(entry => [entry.id, entry]));
for (const original of fixtureEntries) {
const after = byId.get(original.id);
if (!after) {
failures.push(`${workspaceName}/${original.id}: missing after migration`);
continue;
}
if (after.status !== original.expectedAfterMigration) {
failures.push(
`${workspaceName}/${original.id}: expected ${original.expectedAfterMigration}, got ${after.status}\n` +
` text: ${original.text.slice(0, 120)}\n` +
` why: ${original.expectation}`,
);
}
}
}
assert.equal(failures.length, 0, `Regression failures:\n${failures.join("\n")}`);
});
test("quality cleanup migration supersedes only hard violations from current fixture", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-"));
try {
const now = new Date().toISOString();
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: reviewerCurrent28Fixture,
migrations: [],
updatedAt: now,
});
const loaded = await loadWorkspaceMemory(root);
const activeIds = new Set(loaded.entries.filter(entry => entry.status === "active").map(entry => entry.id));
const supersededIds = new Set(loaded.entries.filter(entry => entry.status === "superseded").map(entry => entry.id));
for (const entry of reviewerCurrent28Fixture) {
const quality = assessMemoryQuality(entry);
const hasHardReason = quality.reasons.some(isHardQualityReason);
if (entry.source === "compaction" && !quality.accepted && hasHardReason) {
assert.equal(supersededIds.has(entry.id), true, `${entry.id} should be superseded`);
} else {
assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`);
}
}
assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup"));
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("quality cleanup migration dedupes tags", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-quality-tags-"));
try {
const now = new Date().toISOString();
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [{
id: "bad_with_tags",
type: "feedback",
text: "Wave 1 completed successfully and all tests passed",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
tags: ["quality_cleanup", "quality:progress_snapshot"],
}],
migrations: [],
updatedAt: now,
});
const loaded = await loadWorkspaceMemory(root);
const tags = loaded.entries[0].tags ?? [];
assert.equal(tags.filter(tag => tag === "quality_cleanup").length, 1);
assert.equal(tags.filter(tag => tag === "quality:progress_snapshot").length, 1);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("quality cleanup migration does not supersede explicit memories", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-quality-explicit-"));
try {
const now = new Date().toISOString();
const explicitBadShape = {
id: "explicit_progress_like",
type: "feedback" as const,
text: "Wave 1 completed successfully and all tests passed",
source: "explicit" as const,
confidence: 1,
status: "active" as const,
createdAt: now,
updatedAt: now,
};
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [explicitBadShape],
migrations: [],
updatedAt: now,
});
const loaded = await loadWorkspaceMemory(root);
assert.equal(loaded.entries[0].status, "active");
assert.equal(loaded.entries[0].source, "explicit");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("quality cleanup migration does not supersede manual memories", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-quality-manual-"));
try {
const now = new Date().toISOString();
const manualBadShape = {
id: "manual_progress_like",
type: "feedback" as const,
text: "Wave 1 completed successfully and all tests passed",
source: "manual" as const,
confidence: 0.9,
status: "active" as const,
createdAt: now,
updatedAt: now,
};
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
entries: [manualBadShape],
migrations: [],
updatedAt: now,
});
const loaded = await loadWorkspaceMemory(root);
assert.equal(loaded.entries[0].status, "active");
assert.equal(loaded.entries[0].source, "manual");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("renderWorkspaceMemory excludes superseded entries", () => {
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
@@ -990,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");
@@ -1024,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;
@@ -1039,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");
@@ -1090,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;
@@ -1151,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;