mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d700f4877f | |||
| c0a083ddaf | |||
| 8e07bfe3c1 | |||
| c7088a8a6e | |||
| efed9e5585 | |||
| 7de10c5808 | |||
| 12eddc2f8c | |||
| 5e85d098d8 | |||
| 99c6b97c96 | |||
| 83dcfb479c | |||
| ed6005f6cf | |||
| 069ec8ecbb | |||
| 60c7019820 | |||
| 1847f63480 | |||
| 8b21325469 | |||
| b846b34e30 | |||
| 47905921ca | |||
| ca88193f9f | |||
| 1927cc8828 | |||
| 64f86ef39c | |||
| 39d27e8d3c | |||
| 77bf8af3fe | |||
| 6eb341f43c | |||
| 6a1fa525dc | |||
| d6875aac1b | |||
| c2ee245620 | |||
| 15c0c8a45d | |||
| 909fec9abb | |||
| ef1248f23a | |||
| c8c7dbed3b | |||
| bfa2972353 | |||
| 5fe4955057 | |||
| 55e163adef | |||
| 5ed57943d2 | |||
| 2fc2172d59 |
@@ -51,8 +51,6 @@ pnpm-lock.yaml
|
|||||||
|
|
||||||
# Superpowers local planning artifacts
|
# Superpowers local planning artifacts
|
||||||
docs/superpowers/plans/
|
docs/superpowers/plans/
|
||||||
docs/plans/
|
|
||||||
docs/disruptions/
|
|
||||||
|
|
||||||
# Local dev/admin script inputs
|
# Local dev/admin script inputs
|
||||||
scripts/dev/run-migration-roots.local.txt
|
scripts/dev/run-migration-roots.local.txt
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export type LongTermSource = "explicit" | "compaction" | "manual";
|
|||||||
|
|
||||||
// ✅ USE: const assertions for limits
|
// ✅ USE: const assertions for limits
|
||||||
export const LONG_TERM_LIMITS = {
|
export const LONG_TERM_LIMITS = {
|
||||||
maxRenderedChars: 3600,
|
maxRenderedChars: 5200,
|
||||||
maxEntries: 28,
|
maxEntries: 28,
|
||||||
} as const;
|
} as const;
|
||||||
```
|
```
|
||||||
@@ -140,8 +140,8 @@ const maxEntries = 28;
|
|||||||
async function loadWorkspaceMemory() { }
|
async function loadWorkspaceMemory() { }
|
||||||
|
|
||||||
// ✅ REQUIRED: SCREAMING_SNAKE_CASE for constants
|
// ✅ REQUIRED: SCREAMING_SNAKE_CASE for constants
|
||||||
const LONG_TERM_LIMITS = { maxRenderedChars: 3600, maxEntries: 28 };
|
const LONG_TERM_LIMITS = { maxRenderedChars: 5200, maxEntries: 28 };
|
||||||
const HOT_STATE_LIMITS = { maxRenderedChars: 700 };
|
const HOT_STATE_LIMITS = { maxRenderedChars: 1200 };
|
||||||
|
|
||||||
// ✅ REQUIRED: PascalCase for types
|
// ✅ REQUIRED: PascalCase for types
|
||||||
type WorkspaceMemoryStore = { ... };
|
type WorkspaceMemoryStore = { ... };
|
||||||
@@ -236,7 +236,7 @@ export default {
|
|||||||
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
|
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
|
||||||
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
|
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
|
||||||
- **Schema**: See `src/types.ts:WorkspaceMemoryStore`
|
- **Schema**: See `src/types.ts:WorkspaceMemoryStore`
|
||||||
- **Limits**: 3600 chars, 28 entries max
|
- **Limits**: 5200 chars, 28 entries max
|
||||||
|
|
||||||
### Session State Files
|
### Session State Files
|
||||||
|
|
||||||
@@ -299,9 +299,9 @@ Extracts workspace memory candidates from conversation, applies quality gate and
|
|||||||
|
|
||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
- **Workspace memory budget**: 3600 chars injected into system prompt
|
- **Workspace memory budget**: 5200 chars injected into system prompt
|
||||||
- **Session state budget**: 700 chars injected into system prompt
|
- **Session state budget**: 1200 chars injected into system prompt
|
||||||
- **Total overhead**: typically well below configured maximums
|
- **Total overhead**: ~1500-6000 chars per message (minimal)
|
||||||
- **Storage footprint**: ~2-5 KB per workspace for memory, ~1-3 KB per session
|
- **Storage footprint**: ~2-5 KB per workspace for memory, ~1-3 KB per session
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -5,53 +5,6 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [1.5.1] - 2026-04-30
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Per-workspace evidence log for extraction, promotion, reinforcement, render, storage, and hook lifecycle events.
|
|
||||||
- `memory-diag health --json` for machine-readable diagnostics.
|
|
||||||
- `memory-diag explain` for per-memory render status, strength, reasons, and evidence event IDs.
|
|
||||||
- `memory-diag trace --memory <id>` for memory lifecycle history.
|
|
||||||
- UTC calendar-day reinforcement gate so repeated matches cannot inflate a memory multiple times in the same day.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Retention constants and calculations moved to `src/retention.ts`.
|
|
||||||
- `safetyCritical` is now fully inert: no retention multiplier and no type-cap bypass, while remaining JSON-compatible.
|
|
||||||
|
|
||||||
## [1.5.0] - 2026-04-29
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Strength-based workspace memory retention using exponential decay instead of additive priority scoring.
|
|
||||||
- Per-type rendered caps for workspace memory candidates: feedback 10, decision 10, project 8, and reference 6.
|
|
||||||
- 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.
|
|
||||||
- Deprecated `safetyCritical` is retained for JSON compatibility but no longer affects retention strength or type-cap behavior.
|
|
||||||
- Old or stale-marked memories are no longer hard-pruned; they remain stored and only fall out of rendered context through strength and cap competition.
|
|
||||||
- Existing duplicate promotion and dedupe paths now reinforce the surviving memory instead of only absorbing the duplicate.
|
|
||||||
- Health output now separates stored active memories from rendered candidates to make cap behavior easier to understand.
|
|
||||||
- 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
|
## [1.4.0] - 2026-04-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
|
|
||||||
Automatic memory for OpenCode agents.
|
Automatic memory for OpenCode agents.
|
||||||
|
|
||||||
Working memory is context that **remembers what matters, fades what changes, and stays out of the way.**
|
OpenCode Working Memory helps your agent keep useful context across compactions and sessions: project decisions, preferences, important references, active files, and unresolved errors.
|
||||||
|
|
||||||
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.
|
It works automatically, without manual memory tools or extra LLM/API calls.
|
||||||
|
|
||||||
## Why This Exists
|
## Why This Exists
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ Use it when you want your agent to remember things like:
|
|||||||
- **Compaction-based extraction** — memory extraction piggybacks on OpenCode’s existing compaction flow.
|
- **Compaction-based extraction** — memory extraction piggybacks on OpenCode’s existing compaction flow.
|
||||||
- **No manual tools** — memory is injected automatically into the system prompt.
|
- **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.
|
- **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
|
## Installation
|
||||||
|
|
||||||
@@ -122,27 +121,6 @@ Memory types:
|
|||||||
- `decision` — important implementation or architecture decisions
|
- `decision` — important implementation or architecture decisions
|
||||||
- `reference` — useful paths, commands, or configuration references
|
- `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
|
## Explicit Memory Triggers
|
||||||
|
|
||||||
You can explicitly ask the agent to remember durable facts.
|
You can explicitly ask the agent to remember durable facts.
|
||||||
@@ -189,15 +167,13 @@ It includes guards for:
|
|||||||
|
|
||||||
- Credential redaction
|
- Credential redaction
|
||||||
- Duplicate memory cleanup
|
- Duplicate memory cleanup
|
||||||
- Accounting for promoted, absorbed, superseded, and rejected memories
|
- Superseding older decisions with newer ones
|
||||||
- Strength-based retention so useful memories stay visible without hard age pruning
|
- Consolidation accounting so promoted, absorbed, superseded, and rejected memories are handled differently
|
||||||
- Filtering stack traces, git hashes, raw errors, and noisy path-heavy facts
|
- Filtering stack traces, git hashes, raw errors, and noisy path-heavy facts
|
||||||
- Rejecting temporary project progress snapshots
|
- Rejecting temporary project progress snapshots
|
||||||
|
|
||||||
The goal is to remember durable facts, not every detail.
|
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".
|
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:
|
For local development cleanup, use:
|
||||||
@@ -209,43 +185,27 @@ 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.
|
The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
|
||||||
|
|
||||||
### Local Inspection CLI
|
|
||||||
|
|
||||||
Maintainers can run read-only inspection surfaces without telemetry or mutation. Human output redacts absolute paths and credentials by default; pass `--raw` only when you intentionally need local paths.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun scripts/memory-diag.ts quality --workspace /path/to/repo
|
|
||||||
bun scripts/memory-diag.ts coverage --workspace /path/to/repo --include-historical
|
|
||||||
bun scripts/memory-diag.ts disappearances --workspace /path/to/repo --explain
|
|
||||||
bun scripts/memory-diag.ts rejections --quality --reason bad_decision --unique
|
|
||||||
```
|
|
||||||
|
|
||||||
- `quality` summarizes store caps, retention clocks, evidence coverage, disappearances, and rejection scoping.
|
|
||||||
- `coverage` classifies per-memory evidence lifecycle coverage, optionally including historical evidence-only memory IDs.
|
|
||||||
- `disappearances --explain` reports evidence memory IDs absent from the current store with terminal capacity, promotion, supersession, or render-omission context when available.
|
|
||||||
- `rejections --quality` groups rejection records by scope, reason distribution, and heuristic possible false-positive buckets.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
OpenCode Working Memory works out of the box.
|
OpenCode Working Memory works out of the box.
|
||||||
|
|
||||||
Default behavior:
|
Default behavior:
|
||||||
|
|
||||||
- Workspace memory budget: 3600 characters (~900 tokens)
|
- Workspace memory budget: 5200 characters
|
||||||
- Workspace memory limit: 28 entries
|
- Workspace memory limit: 28 entries
|
||||||
- Hot session state budget: 700 characters (~175 tokens)
|
- Hot session state budget: 1200 characters
|
||||||
- Active files shown: 8
|
- Active files shown: 8
|
||||||
- Open errors shown: 3
|
- Open errors shown: 3
|
||||||
|
|
||||||
See [Configuration](docs/configuration.md) for customization options.
|
See [Configuration](docs/configuration.md) for customization options.
|
||||||
|
|
||||||
## Roadmap
|
## Ongoing Work
|
||||||
|
|
||||||
Current focus:
|
Current focus:
|
||||||
|
|
||||||
- Add explicit delete tombstones so removed memories do not get re-extracted.
|
- Improve memory recording quality so only durable, useful facts are kept.
|
||||||
- Enforce explicit `supersedes` chains for safer replacement of obsolete memories.
|
- Strengthen deduplication and supersession so stale memories do not pile up.
|
||||||
- Explore tiered hot/warm/cold storage after the retention model has more real-world data.
|
- Add better forgetting behavior for obsolete decisions, preferences, and project facts.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -1,141 +1,5 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
## 1.5.1 (2026-04-30)
|
|
||||||
|
|
||||||
### Evidence Loop and Explainability
|
|
||||||
|
|
||||||
This release adds an evidence-based audit trail for memory lifecycle events and user-facing diagnostics for understanding why memories are rendered, promoted, or rejected.
|
|
||||||
|
|
||||||
> **Evidence before sublimation.** Every memory decision can be traced.
|
|
||||||
|
|
||||||
### What Changed
|
|
||||||
|
|
||||||
- **Evidence log**: extraction, promotion, reinforcement, render, and storage events are now recorded in a per-workspace `events.jsonl` with 90-day retention and 5000-event cap.
|
|
||||||
- **User explainability**: `memory-diag explain` shows per-memory render status with strength, reasons, and evidence. `memory-diag trace --memory <id>` shows the full lifecycle history.
|
|
||||||
- **Machine-readable diagnostics**: `memory-diag health --json` outputs structured `MemoryDiagJSON` for scripting.
|
|
||||||
- **Calendar-day reinforcement gate**: reinforcement now requires distinct UTC calendar days, preventing repetitive-task gaming that could inflate a memory's strength within a single day.
|
|
||||||
- **SafetyCritical deprecation complete**: the `safetyCritical` field no longer affects retention strength or type-cap bypass. All memories fade by the same rules.
|
|
||||||
- **Retention module extraction**: retention constants and calculations moved to `src/retention.ts` for cleaner separation.
|
|
||||||
|
|
||||||
### Privacy
|
|
||||||
|
|
||||||
- Evidence text previews are credential-redacted. Memory content is stored as truncated hashes, never in full.
|
|
||||||
- Diagnostics default to redacted output. `--raw` is available for maintainers.
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
|
|
||||||
- No configuration changes required.
|
|
||||||
- Existing workspace memory files remain compatible.
|
|
||||||
- Evidence logs are created automatically; no migration needed.
|
|
||||||
|
|
||||||
### Validation
|
|
||||||
|
|
||||||
- `npm run typecheck`
|
|
||||||
- `npm test` — 271 tests passing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.5.0 (2026-04-29)
|
|
||||||
|
|
||||||
### Retention Decay Model
|
|
||||||
|
|
||||||
This release changes workspace memory retention from hard stale pruning and additive priority scoring to a strength-based decay model.
|
|
||||||
|
|
||||||
Think of it like a forgetting curve: memories fade over time, but important and reinforced memories decay slower. Weak entries fall out of rendered prompt context by cap competition, not hard deletion.
|
|
||||||
|
|
||||||
> **Memory should fade, so the agent can keep learning.**
|
|
||||||
> Important memories decay slower, but every memory must leave room for newer project reality and avoid long-term memory pollution.
|
|
||||||
|
|
||||||
```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, and user importance now determine how strong a memory starts.
|
|
||||||
- **No confidence scoring**: confidence remains in stored data for compatibility, but it no longer affects retention ranking.
|
|
||||||
- **Type caps**: rendered workspace memory now caps feedback, decisions, project facts, and references separately so one type cannot monopolize all 28 slots.
|
|
||||||
- **Deprecation:** `safetyCritical` field no longer affects retention strength or type-cap bypass. All system memories now fade according to the same rules. Safety rules belong in user-controlled `agent.md` files, not in system memory.
|
|
||||||
- **Dormant-aware age**: after 14 inactive days, additional dormant workspace time counts at 0.25x so paused projects do not forget too aggressively.
|
|
||||||
- **Reinforcement**: repeated matching memories reinforce the survivor and slow future decay, with same-session and one-hour guards to avoid accidental spam.
|
|
||||||
- **No hard stale pruning**: old or stale-marked memories are no longer automatically dropped by age; they lose rendered space only through cap competition.
|
|
||||||
- **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` — 242 tests passing
|
|
||||||
- `bun scripts/memory-diag.ts health`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.4.0 (2026-04-28)
|
## 1.4.0 (2026-04-28)
|
||||||
|
|
||||||
### Memory Quality Cleanup
|
### Memory Quality Cleanup
|
||||||
|
|||||||
+11
-49
@@ -10,7 +10,7 @@ OpenCode Working Memory implements a **three-layer memory architecture** designe
|
|||||||
│ • Persistent storage: ~/.local/share/opencode-working-... │
|
│ • Persistent storage: ~/.local/share/opencode-working-... │
|
||||||
│ • Types: feedback | project | decision | reference │
|
│ • Types: feedback | project | decision | reference │
|
||||||
│ • Sources: explicit | compaction | manual │
|
│ • Sources: explicit | compaction | manual │
|
||||||
│ • Render limits: 3600 chars / 28 entries │
|
│ • Limits: 5200 chars / 28 entries │
|
||||||
│ • Survives: session reset, compaction (same workspace) │
|
│ • Survives: session reset, compaction (same workspace) │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
↓
|
↓
|
||||||
@@ -48,9 +48,8 @@ Long-term memory that persists across sessions within the same workspace. Perfec
|
|||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
workspace: { root: string, key: string },
|
workspace: { root: string, key: string },
|
||||||
limits: { maxRenderedChars: 3600, maxEntries: 28 },
|
limits: { maxRenderedChars: 5200, maxEntries: 28 },
|
||||||
entries: LongTermMemoryEntry[],
|
entries: LongTermMemoryEntry[],
|
||||||
lastActivityAt?: string,
|
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -91,51 +90,18 @@ Memory candidates:
|
|||||||
- Path-heavy facts (>50% paths)
|
- Path-heavy facts (>50% paths)
|
||||||
- Very short text (<20 chars)
|
- Very short text (<20 chars)
|
||||||
|
|
||||||
### Consolidation, Deduplication, and Retention
|
### Consolidation and Deduplication
|
||||||
|
|
||||||
Memories are deduplicated and consolidated with accounting:
|
Memories are deduplicated and consolidated with accounting:
|
||||||
|
|
||||||
1. Normalize exact text: lowercase, strip punctuation, collapse whitespace.
|
1. Normalize exact text: lowercase, strip punctuation, collapse whitespace.
|
||||||
2. Group project/reference entries by identity where possible.
|
2. Group project/reference entries by identity where possible.
|
||||||
3. Keep decision and feedback entries on exact canonical matching to avoid broad semantic merges.
|
3. Group decisions and feedback by topic where possible.
|
||||||
4. Keep the best surviving entry by source, confidence, specificity, and freshness tie-breakers.
|
4. Keep the best surviving entry by source, confidence, type, and freshness rules.
|
||||||
5. Emit accounting events so pending memories can be classified as promoted, absorbed, superseded, or rejected.
|
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.
|
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, and optional user importance. Confidence remains stored for compatibility but is not part of retention scoring.
|
|
||||||
|
|
||||||
Rendered candidates are selected in this order:
|
|
||||||
|
|
||||||
1. Exclude `status: "superseded"` entries.
|
|
||||||
2. Compute current retention strength.
|
|
||||||
3. Sort by strength descending.
|
|
||||||
4. Apply per-type caps.
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Safety-Critical Deprecation
|
|
||||||
|
|
||||||
The `safetyCritical` field on `LongTermMemoryEntry` is deprecated as of the retention v1.5.1 model update. It no longer affects retention strength or type-cap bypass. The field is preserved in the type definition for backward compatibility with existing workspace memory JSON files, but has no active behavior. Safety rules should be maintained in user-controlled files such as `agent.md` rather than in system memory.
|
|
||||||
|
|
||||||
### System Prompt Injection
|
### System Prompt Injection
|
||||||
|
|
||||||
Workspace memory is injected at the top of every message:
|
Workspace memory is injected at the top of every message:
|
||||||
@@ -275,7 +241,7 @@ Applies quality gate, redaction, migration, consolidation accounting, deduplicat
|
|||||||
- `session.compacted`: Promote session decisions to workspace memory
|
- `session.compacted`: Promote session decisions to workspace memory
|
||||||
- `session.deleted`: Clean up session state files
|
- `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; 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.
|
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.
|
||||||
|
|
||||||
## Quality Guarantees
|
## Quality Guarantees
|
||||||
|
|
||||||
@@ -347,24 +313,20 @@ canonical("Use npm cache for plugins") === "use npm cache for plugins"
|
|||||||
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
|
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Storage Safety
|
|
||||||
|
|
||||||
All read-modify-write updates go through `updateJSON()`, which combines an in-process promise queue with an on-disk `.lock` file. The lock file uses exclusive creation, heartbeat refreshes while held, stale-lock recovery after 30 seconds, and a 5 second wait timeout for live contention. Direct `readJSON()` is fallback-oriented and does not mutate data except when corrupt JSON is quarantined.
|
|
||||||
|
|
||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
### Memory Budgets
|
### Memory Budgets
|
||||||
|
|
||||||
| Layer | Max Chars | Max Entries |
|
| Layer | Max Chars | Max Entries |
|
||||||
|-------|-----------|-------------|
|
|-------|-----------|-------------|
|
||||||
| Workspace Memory | 3600 | 28 |
|
| Workspace Memory | 5200 | 28 |
|
||||||
| Hot Session State | 700 | 8 files, 3 errors |
|
| Hot Session State | 1200 | 8 files, 3 errors |
|
||||||
|
|
||||||
### Injection Overhead
|
### Injection Overhead
|
||||||
|
|
||||||
- Workspace memory: usually under ~2000 chars in observed rendered output
|
- Workspace memory: ~200-500 chars per message
|
||||||
- Hot session state: usually under ~500 chars in observed rendered output
|
- Hot session state: ~200-400 chars per message
|
||||||
- Total: typically well below the configured maximums
|
- Total: ~400-900 chars per message (minimal)
|
||||||
|
|
||||||
### Storage Footprint
|
### Storage Footprint
|
||||||
|
|
||||||
|
|||||||
+11
-48
@@ -8,8 +8,8 @@ OpenCode Working Memory works out-of-the-box with sensible defaults. Configurati
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const LONG_TERM_LIMITS = {
|
const LONG_TERM_LIMITS = {
|
||||||
maxRenderedChars: 3600, // Maximum characters in system prompt
|
maxRenderedChars: 5200, // Maximum characters in system prompt
|
||||||
targetRenderedChars: 3000, // Target characters (leave buffer)
|
targetRenderedChars: 4200, // Target characters (leave buffer)
|
||||||
maxEntries: 28, // Maximum number of entries
|
maxEntries: 28, // Maximum number of entries
|
||||||
maxEntryTextChars: 260, // Maximum characters per entry text
|
maxEntryTextChars: 260, // Maximum characters per entry text
|
||||||
maxRationaleChars: 180, // Maximum characters per entry rationale
|
maxRationaleChars: 180, // Maximum characters per entry rationale
|
||||||
@@ -18,40 +18,14 @@ const LONG_TERM_LIMITS = {
|
|||||||
|
|
||||||
**Recommendations**:
|
**Recommendations**:
|
||||||
- Keep `maxRenderedChars` under 5500 to avoid context bloat
|
- 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
|
- `maxEntries` of 28 provides good coverage without overwhelming
|
||||||
- Entry text limits ensure entries stay concise
|
- 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, and user importance factors. Confidence is stored for compatibility but is not used for retention scoring.
|
|
||||||
|
|
||||||
Rendered type caps prevent one type from filling all workspace memory slots:
|
|
||||||
|
|
||||||
| Type | Rendered cap |
|
|
||||||
|------|--------------|
|
|
||||||
| `feedback` | 10 |
|
|
||||||
| `decision` | 10 |
|
|
||||||
| `project` | 8 |
|
|
||||||
| `reference` | 6 |
|
|
||||||
|
|
||||||
Old or stale-marked memories are not hard-pruned by age; they lose rendered space through strength and cap competition. The deprecated `safetyCritical` field is preserved for compatibility but no longer affects strength or type caps.
|
|
||||||
|
|
||||||
## Hot Session State Limits
|
## Hot Session State Limits
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const HOT_STATE_LIMITS = {
|
const HOT_STATE_LIMITS = {
|
||||||
maxRenderedChars: 700, // Maximum characters in system prompt
|
maxRenderedChars: 1200, // Maximum characters in system prompt
|
||||||
maxActiveFilesStored: 20, // Maximum files tracked in state
|
maxActiveFilesStored: 20, // Maximum files tracked in state
|
||||||
maxActiveFilesRendered: 8, // Maximum files shown in prompt
|
maxActiveFilesRendered: 8, // Maximum files shown in prompt
|
||||||
maxOpenErrorsStored: 5, // Maximum errors tracked
|
maxOpenErrorsStored: 5, // Maximum errors tracked
|
||||||
@@ -62,7 +36,6 @@ const HOT_STATE_LIMITS = {
|
|||||||
|
|
||||||
**Recommendations**:
|
**Recommendations**:
|
||||||
- Keep `maxRenderedChars` under 1500 for fast prompts
|
- 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
|
- `maxActiveFilesRendered` of 8 provides good context coverage
|
||||||
- `maxOpenErrorsRendered` of 3 avoids overwhelming error lists
|
- `maxOpenErrorsRendered` of 3 avoids overwhelming error lists
|
||||||
|
|
||||||
@@ -70,12 +43,12 @@ const HOT_STATE_LIMITS = {
|
|||||||
|
|
||||||
### Long-Term Memory Types
|
### Long-Term Memory Types
|
||||||
|
|
||||||
| Type | Purpose | Rendered cap |
|
| Type | Purpose | Stale After (days) |
|
||||||
|------|---------|--------------|
|
|------|---------|---------------------|
|
||||||
| `feedback` | User preferences for workspace | 10 |
|
| `feedback` | User preferences for workspace | 90 |
|
||||||
| `project` | Project-level information | 8 |
|
| `project` | Project-level information | 60 |
|
||||||
| `decision` | Important decisions | 10 |
|
| `decision` | Important decisions | 45 |
|
||||||
| `reference` | Key references | 6 |
|
| `reference` | Key references | 90 |
|
||||||
|
|
||||||
### Memory Sources
|
### Memory Sources
|
||||||
|
|
||||||
@@ -141,7 +114,7 @@ To customize limits, edit the constants in `src/types.ts`:
|
|||||||
```typescript
|
```typescript
|
||||||
// Example: Increase workspace memory limit
|
// Example: Increase workspace memory limit
|
||||||
export const LONG_TERM_LIMITS = {
|
export const LONG_TERM_LIMITS = {
|
||||||
maxRenderedChars: 6000, // Increased from 3600
|
maxRenderedChars: 6000, // Increased from 5200
|
||||||
maxEntries: 35, // Increased from 28
|
maxEntries: 35, // Increased from 28
|
||||||
// ...
|
// ...
|
||||||
};
|
};
|
||||||
@@ -171,7 +144,7 @@ const HOT_STATE_LIMITS = {
|
|||||||
// Preserve more context
|
// Preserve more context
|
||||||
const LONG_TERM_LIMITS = {
|
const LONG_TERM_LIMITS = {
|
||||||
maxEntries: 40, // Increased
|
maxEntries: 40, // Increased
|
||||||
targetRenderedChars: 5000, // Increased
|
targetRenderedChars: 5000, // Increased
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -202,16 +175,6 @@ cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json |
|
|||||||
cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
|
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
|
### Clear Workspace Memory
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-working-memory",
|
"name": "opencode-working-memory",
|
||||||
"version": "1.5.2",
|
"version": "1.4.0",
|
||||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||||
"typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"",
|
"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",
|
"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"
|
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,529 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
|
|
||||||
import { redactCredentials } from "./redaction.ts";
|
|
||||||
|
|
||||||
export type EvidenceEventType =
|
|
||||||
| "extraction_candidate_accepted"
|
|
||||||
| "extraction_candidate_rejected"
|
|
||||||
| "explicit_memory_detected"
|
|
||||||
| "explicit_memory_ignored"
|
|
||||||
| "pending_memory_appended"
|
|
||||||
| "pending_memory_cleared"
|
|
||||||
| "promotion_promoted"
|
|
||||||
| "promotion_absorbed_exact"
|
|
||||||
| "promotion_absorbed_identity"
|
|
||||||
| "promotion_superseded"
|
|
||||||
| "promotion_rejected_capacity"
|
|
||||||
| "promotion_retry_scheduled"
|
|
||||||
| "promotion_retry_exhausted"
|
|
||||||
| "memory_reinforced"
|
|
||||||
| "render_selected"
|
|
||||||
| "render_omitted"
|
|
||||||
| "memory_removed_capacity"
|
|
||||||
| "storage_corrupt_json_quarantined"
|
|
||||||
| "storage_stale_lock_recovered"
|
|
||||||
| "storage_lock_timeout"
|
|
||||||
| "hook_failed";
|
|
||||||
|
|
||||||
export type EvidencePhase =
|
|
||||||
| "extraction"
|
|
||||||
| "explicit"
|
|
||||||
| "pending_journal"
|
|
||||||
| "promotion"
|
|
||||||
| "reinforcement"
|
|
||||||
| "render"
|
|
||||||
| "storage"
|
|
||||||
| "hook";
|
|
||||||
|
|
||||||
export type EvidenceOutcome =
|
|
||||||
| "accepted"
|
|
||||||
| "rejected"
|
|
||||||
| "promoted"
|
|
||||||
| "absorbed"
|
|
||||||
| "superseded"
|
|
||||||
| "rendered"
|
|
||||||
| "omitted"
|
|
||||||
| "removed"
|
|
||||||
| "retried"
|
|
||||||
| "exhausted"
|
|
||||||
| "reinforced"
|
|
||||||
| "quarantined"
|
|
||||||
| "failed"
|
|
||||||
| "recovered";
|
|
||||||
|
|
||||||
export type MemoryEvidenceRef = {
|
|
||||||
memoryId?: string;
|
|
||||||
memoryKeyHash?: string;
|
|
||||||
identityKeyHash?: string;
|
|
||||||
type?: "feedback" | "project" | "decision" | "reference";
|
|
||||||
source?: "explicit" | "compaction" | "manual";
|
|
||||||
status?: "active" | "superseded";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EvidenceRelation = {
|
|
||||||
role:
|
|
||||||
| "candidate"
|
|
||||||
| "pending"
|
|
||||||
| "promoted"
|
|
||||||
| "retained"
|
|
||||||
| "absorbed"
|
|
||||||
| "superseded"
|
|
||||||
| "superseded_by"
|
|
||||||
| "reinforced"
|
|
||||||
| "reinforced_by"
|
|
||||||
| "rendered"
|
|
||||||
| "omitted"
|
|
||||||
| "removed";
|
|
||||||
memory?: MemoryEvidenceRef;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EvidenceDetailValue = string | number | boolean | null | string[] | number[];
|
|
||||||
|
|
||||||
export type EvidenceEventV1 = {
|
|
||||||
version: 1;
|
|
||||||
eventId: string;
|
|
||||||
createdAt: string;
|
|
||||||
workspaceKey: string;
|
|
||||||
workspaceRootHash: string;
|
|
||||||
sessionHash?: string;
|
|
||||||
messageHash?: string;
|
|
||||||
type: EvidenceEventType;
|
|
||||||
phase: EvidencePhase;
|
|
||||||
outcome: EvidenceOutcome;
|
|
||||||
memory?: MemoryEvidenceRef;
|
|
||||||
relations?: EvidenceRelation[];
|
|
||||||
reasonCodes: string[];
|
|
||||||
details?: Record<string, EvidenceDetailValue>;
|
|
||||||
textPreview?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EvidenceEventInput = Omit<
|
|
||||||
EvidenceEventV1,
|
|
||||||
"version" | "eventId" | "createdAt" | "workspaceKey" | "workspaceRootHash"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type EvidenceQuery = {
|
|
||||||
since?: string;
|
|
||||||
until?: string;
|
|
||||||
types?: EvidenceEventType[];
|
|
||||||
phases?: EvidencePhase[];
|
|
||||||
outcomes?: EvidenceOutcome[];
|
|
||||||
memoryId?: string;
|
|
||||||
memoryKeyHash?: string;
|
|
||||||
identityKeyHash?: string;
|
|
||||||
sessionHash?: string;
|
|
||||||
limit?: number;
|
|
||||||
newestFirst?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MemoryEvidenceSummary = {
|
|
||||||
memoryId?: string;
|
|
||||||
memoryKeyHash?: string;
|
|
||||||
latestOutcome?: EvidenceOutcome;
|
|
||||||
latestRenderStatus?: "rendered" | "omitted";
|
|
||||||
reasonCodes: string[];
|
|
||||||
eventIds: string[];
|
|
||||||
lastEventAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MemoryLifecycleTrace = {
|
|
||||||
memoryId?: string;
|
|
||||||
memoryKeyHash?: string;
|
|
||||||
identityKeyHash?: string;
|
|
||||||
events: EvidenceEventV1[];
|
|
||||||
createdBy?: EvidenceEventV1;
|
|
||||||
acceptedBy?: EvidenceEventV1;
|
|
||||||
promotedBy?: EvidenceEventV1;
|
|
||||||
absorbedBy?: EvidenceEventV1;
|
|
||||||
supersededBy?: EvidenceEventV1;
|
|
||||||
reinforcedBy: EvidenceEventV1[];
|
|
||||||
latestRender?: EvidenceEventV1;
|
|
||||||
currentStatus:
|
|
||||||
| "accepted"
|
|
||||||
| "pending"
|
|
||||||
| "promoted"
|
|
||||||
| "absorbed"
|
|
||||||
| "superseded"
|
|
||||||
| "rendered"
|
|
||||||
| "omitted"
|
|
||||||
| "rejected"
|
|
||||||
| "unknown";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EVIDENCE_LOG_LIMITS = {
|
|
||||||
maxAgeDays: 90,
|
|
||||||
maxEventsPerWorkspace: 5000,
|
|
||||||
maxBytesPerWorkspace: 2 * 1024 * 1024,
|
|
||||||
pruneEveryAppendCount: 100,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const appendCounts = new Map<string, number>();
|
|
||||||
const HASH_PATTERN = /^[a-f0-9]{16}$/i;
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
const MAX_DETAIL_STRING_CHARS = 240;
|
|
||||||
const MAX_DETAIL_ARRAY_ITEMS = 25;
|
|
||||||
|
|
||||||
function evidenceHash(value: string): string {
|
|
||||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHashValue(value: string | undefined): string | undefined {
|
|
||||||
if (!value) return undefined;
|
|
||||||
return HASH_PATTERN.test(value) ? value.toLowerCase() : evidenceHash(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolvedRoot(root: string): Promise<string> {
|
|
||||||
return realpath(root).catch(() => root);
|
|
||||||
}
|
|
||||||
|
|
||||||
function evidenceTextPreview(text: string, maxChars = 120): string {
|
|
||||||
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeReasonCode(reason: string): string {
|
|
||||||
return reason.replace(/[^a-zA-Z0-9_.:-]/g, "_").slice(0, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeMemoryRef(memory: MemoryEvidenceRef | undefined): MemoryEvidenceRef | undefined {
|
|
||||||
if (!memory) return undefined;
|
|
||||||
const sanitized: MemoryEvidenceRef = {};
|
|
||||||
if (typeof memory.memoryId === "string" && memory.memoryId) sanitized.memoryId = memory.memoryId.slice(0, 160);
|
|
||||||
if (memory.memoryKeyHash) sanitized.memoryKeyHash = normalizeHashValue(memory.memoryKeyHash);
|
|
||||||
if (memory.identityKeyHash) sanitized.identityKeyHash = normalizeHashValue(memory.identityKeyHash);
|
|
||||||
if (memory.type) sanitized.type = memory.type;
|
|
||||||
if (memory.source) sanitized.source = memory.source;
|
|
||||||
if (memory.status) sanitized.status = memory.status;
|
|
||||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeRelations(relations: EvidenceRelation[] | undefined): EvidenceRelation[] | undefined {
|
|
||||||
if (!relations) return undefined;
|
|
||||||
const sanitized = relations
|
|
||||||
.map(relation => ({
|
|
||||||
role: relation.role,
|
|
||||||
memory: sanitizeMemoryRef(relation.memory),
|
|
||||||
}))
|
|
||||||
.slice(0, 25);
|
|
||||||
return sanitized.length > 0 ? sanitized : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeDetailString(value: string): string {
|
|
||||||
return evidenceTextPreview(value, MAX_DETAIL_STRING_CHARS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeDetails(details: EvidenceEventInput["details"]): EvidenceEventV1["details"] {
|
|
||||||
if (!details) return undefined;
|
|
||||||
const sanitized: Record<string, EvidenceDetailValue> = {};
|
|
||||||
|
|
||||||
for (const [rawKey, rawValue] of Object.entries(details).slice(0, 50)) {
|
|
||||||
const key = rawKey.replace(/[^a-zA-Z0-9_.:-]/g, "_").slice(0, 80);
|
|
||||||
if (!key) continue;
|
|
||||||
|
|
||||||
if (typeof rawValue === "string") {
|
|
||||||
sanitized[key] = sanitizeDetailString(rawValue);
|
|
||||||
} else if (typeof rawValue === "number") {
|
|
||||||
if (Number.isFinite(rawValue)) sanitized[key] = rawValue;
|
|
||||||
} else if (typeof rawValue === "boolean" || rawValue === null) {
|
|
||||||
sanitized[key] = rawValue;
|
|
||||||
} else if (Array.isArray(rawValue)) {
|
|
||||||
if (rawValue.every(item => typeof item === "string")) {
|
|
||||||
sanitized[key] = rawValue.slice(0, MAX_DETAIL_ARRAY_ITEMS).map(item => sanitizeDetailString(item));
|
|
||||||
} else if (rawValue.every(item => typeof item === "number" && Number.isFinite(item))) {
|
|
||||||
sanitized[key] = rawValue.slice(0, MAX_DETAIL_ARRAY_ITEMS) as number[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEvidenceEvent(
|
|
||||||
input: EvidenceEventInput,
|
|
||||||
workspaceKeyValue: string,
|
|
||||||
workspaceRootHash: string,
|
|
||||||
): EvidenceEventV1 {
|
|
||||||
const textPreviewMax = input.type === "extraction_candidate_rejected" ? 80 : 120;
|
|
||||||
const event: EvidenceEventV1 = {
|
|
||||||
version: 1,
|
|
||||||
eventId: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
workspaceKey: workspaceKeyValue,
|
|
||||||
workspaceRootHash,
|
|
||||||
type: input.type,
|
|
||||||
phase: input.phase,
|
|
||||||
outcome: input.outcome,
|
|
||||||
reasonCodes: input.reasonCodes.map(sanitizeReasonCode).filter(Boolean).slice(0, 25),
|
|
||||||
};
|
|
||||||
|
|
||||||
const memory = sanitizeMemoryRef(input.memory);
|
|
||||||
const relations = sanitizeRelations(input.relations);
|
|
||||||
const details = sanitizeDetails(input.details);
|
|
||||||
if (input.sessionHash) event.sessionHash = normalizeHashValue(input.sessionHash);
|
|
||||||
if (input.messageHash) event.messageHash = normalizeHashValue(input.messageHash);
|
|
||||||
if (memory) event.memory = memory;
|
|
||||||
if (relations) event.relations = relations;
|
|
||||||
if (details) event.details = details;
|
|
||||||
if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax);
|
|
||||||
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await appendFile(path, `${line}\n`, "utf8");
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error(`[memory] failed to write evidence event: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function maybePruneEvidenceLog(path: string): Promise<void> {
|
|
||||||
const nextCount = (appendCounts.get(path) ?? 0) + 1;
|
|
||||||
appendCounts.set(path, nextCount);
|
|
||||||
if (nextCount % EVIDENCE_LOG_LIMITS.pruneEveryAppendCount !== 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pruneEvidenceLogPath(path);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error(`[memory] failed to prune evidence log: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function appendEvidenceEvent(root: string, event: EvidenceEventInput): Promise<EvidenceEventV1> {
|
|
||||||
const records = await appendEvidenceEvents(root, [event]);
|
|
||||||
return records[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function appendEvidenceEvents(root: string, events: EvidenceEventInput[]): Promise<EvidenceEventV1[]> {
|
|
||||||
const path = await workspaceEvidenceLogPath(root);
|
|
||||||
const rootPath = await resolvedRoot(root);
|
|
||||||
const workspaceRootHash = evidenceHash(rootPath);
|
|
||||||
const workspaceKeyValue = await workspaceKey(root);
|
|
||||||
const records = events.map(event => buildEvidenceEvent(event, workspaceKeyValue, workspaceRootHash));
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
await safeAppendEvidenceLine(path, JSON.stringify(record));
|
|
||||||
await maybePruneEvidenceLog(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function appendEvidenceEventForWorkspaceKey(
|
|
||||||
workspaceKeyValue: string,
|
|
||||||
event: EvidenceEventInput,
|
|
||||||
): Promise<EvidenceEventV1> {
|
|
||||||
const path = join(dataHome(), "opencode-working-memory", "workspaces", workspaceKeyValue, "evidence", "events.jsonl");
|
|
||||||
const record = buildEvidenceEvent(event, workspaceKeyValue, workspaceKeyValue);
|
|
||||||
await safeAppendEvidenceLine(path, JSON.stringify(record));
|
|
||||||
await maybePruneEvidenceLog(path);
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ParsedEvidenceLine = {
|
|
||||||
event: EvidenceEventV1;
|
|
||||||
index: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseEvidenceLine(line: string): EvidenceEventV1 | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line) as Partial<EvidenceEventV1>;
|
|
||||||
if (parsed.version !== 1 || !parsed.eventId || !parsed.createdAt || !parsed.type) return null;
|
|
||||||
return parsed as EvidenceEventV1;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readEvidenceLines(path: string, warnInvalid: boolean): Promise<{ valid: ParsedEvidenceLine[]; invalid: string[] }> {
|
|
||||||
if (!existsSync(path)) return { valid: [], invalid: [] };
|
|
||||||
const raw = await readFile(path, "utf8");
|
|
||||||
const valid: ParsedEvidenceLine[] = [];
|
|
||||||
const invalid: string[] = [];
|
|
||||||
|
|
||||||
raw.split(/\n/).forEach((line, index) => {
|
|
||||||
if (!line.trim()) return;
|
|
||||||
const event = parseEvidenceLine(line);
|
|
||||||
if (event) {
|
|
||||||
valid.push({ event, index });
|
|
||||||
} else {
|
|
||||||
invalid.push(line);
|
|
||||||
if (warnInvalid) console.warn(`[memory] skipped invalid evidence log line ${index + 1}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { valid, invalid };
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventTimeMs(event: EvidenceEventV1): number {
|
|
||||||
const ms = new Date(event.createdAt).getTime();
|
|
||||||
return Number.isFinite(ms) ? ms : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function atomicWriteText(path: string, text: string): Promise<void> {
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
const tmp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
||||||
try {
|
|
||||||
await writeFile(tmp, text, { encoding: "utf8", mode: 0o600 });
|
|
||||||
await rename(tmp, path);
|
|
||||||
} catch (error) {
|
|
||||||
await rm(tmp, { force: true }).catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeEvents(events: EvidenceEventV1[]): string {
|
|
||||||
return events.map(event => JSON.stringify(event)).join("\n") + (events.length > 0 ? "\n" : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimEventsToByteLimit(events: EvidenceEventV1[]): EvidenceEventV1[] {
|
|
||||||
let kept = [...events];
|
|
||||||
while (kept.length > 0 && Buffer.byteLength(serializeEvents(kept), "utf8") > EVIDENCE_LOG_LIMITS.maxBytesPerWorkspace) {
|
|
||||||
kept = kept.slice(1);
|
|
||||||
}
|
|
||||||
return kept;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pruneEvidenceLogPath(path: string): Promise<void> {
|
|
||||||
if (!existsSync(path)) return;
|
|
||||||
const stats = await stat(path);
|
|
||||||
if (stats.isDirectory()) return;
|
|
||||||
|
|
||||||
const { valid, invalid } = await readEvidenceLines(path, false);
|
|
||||||
if (invalid.length > 0) {
|
|
||||||
const corruptPath = `${path}.corrupt-lines-${Date.now()}.jsonl`;
|
|
||||||
await writeFile(corruptPath, invalid.join("\n") + "\n", { encoding: "utf8", mode: 0o600 }).catch(error => {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error(`[memory] failed to quarantine invalid evidence lines: ${message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const cutoff = Date.now() - EVIDENCE_LOG_LIMITS.maxAgeDays * DAY_MS;
|
|
||||||
let events = valid
|
|
||||||
.filter(item => eventTimeMs(item.event) >= cutoff)
|
|
||||||
.sort((a, b) => eventTimeMs(a.event) - eventTimeMs(b.event) || a.index - b.index)
|
|
||||||
.map(item => item.event);
|
|
||||||
|
|
||||||
if (events.length > EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace) {
|
|
||||||
events = events.slice(events.length - EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
events = trimEventsToByteLimit(events);
|
|
||||||
await atomicWriteText(path, serializeEvents(events));
|
|
||||||
}
|
|
||||||
|
|
||||||
function memoryRefMatches(memory: MemoryEvidenceRef | undefined, query: Pick<EvidenceQuery, "memoryId" | "memoryKeyHash" | "identityKeyHash">): boolean {
|
|
||||||
if (!memory) return false;
|
|
||||||
const memoryKeyHash = normalizeHashValue(query.memoryKeyHash);
|
|
||||||
const identityKeyHash = normalizeHashValue(query.identityKeyHash);
|
|
||||||
if (query.memoryId && memory.memoryId === query.memoryId) return true;
|
|
||||||
if (memoryKeyHash && memory.memoryKeyHash === memoryKeyHash) return true;
|
|
||||||
if (identityKeyHash && memory.identityKeyHash === identityKeyHash) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventMatchesMemory(event: EvidenceEventV1, query: Pick<EvidenceQuery, "memoryId" | "memoryKeyHash" | "identityKeyHash">): boolean {
|
|
||||||
if (!query.memoryId && !query.memoryKeyHash && !query.identityKeyHash) return true;
|
|
||||||
if (memoryRefMatches(event.memory, query)) return true;
|
|
||||||
return (event.relations ?? []).some(relation => memoryRefMatches(relation.memory, query));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryEvidenceEvents(
|
|
||||||
root: string,
|
|
||||||
query: EvidenceQuery = {},
|
|
||||||
): Promise<EvidenceEventV1[]> {
|
|
||||||
const path = await workspaceEvidenceLogPath(root);
|
|
||||||
const { valid } = await readEvidenceLines(path, true);
|
|
||||||
const sinceMs = query.since ? new Date(query.since).getTime() : undefined;
|
|
||||||
const untilMs = query.until ? new Date(query.until).getTime() : undefined;
|
|
||||||
const sessionHash = normalizeHashValue(query.sessionHash);
|
|
||||||
|
|
||||||
let events = valid.map(item => item.event).filter(event => {
|
|
||||||
const createdAtMs = eventTimeMs(event);
|
|
||||||
if (Number.isFinite(sinceMs) && createdAtMs < sinceMs) return false;
|
|
||||||
if (Number.isFinite(untilMs) && createdAtMs > untilMs) return false;
|
|
||||||
if (query.types && !query.types.includes(event.type)) return false;
|
|
||||||
if (query.phases && !query.phases.includes(event.phase)) return false;
|
|
||||||
if (query.outcomes && !query.outcomes.includes(event.outcome)) return false;
|
|
||||||
if (sessionHash && event.sessionHash !== sessionHash) return false;
|
|
||||||
if (!eventMatchesMemory(event, query)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (query.newestFirst) events = events.slice().reverse();
|
|
||||||
if (typeof query.limit === "number" && query.limit >= 0) events = events.slice(0, query.limit);
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function summarizeMemoryEvidence(
|
|
||||||
root: string,
|
|
||||||
input: { memoryId?: string; memoryKeyHash?: string },
|
|
||||||
): Promise<MemoryEvidenceSummary> {
|
|
||||||
const events = await queryEvidenceEvents(root, input);
|
|
||||||
const latest = events.at(-1);
|
|
||||||
const latestRender = events.filter(event => event.phase === "render").at(-1);
|
|
||||||
const reasonCodes = new Set<string>();
|
|
||||||
for (const event of events) {
|
|
||||||
for (const reason of event.reasonCodes) reasonCodes.add(reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
memoryId: input.memoryId,
|
|
||||||
memoryKeyHash: normalizeHashValue(input.memoryKeyHash),
|
|
||||||
latestOutcome: latest?.outcome,
|
|
||||||
latestRenderStatus: latestRender?.outcome === "rendered" || latestRender?.outcome === "omitted"
|
|
||||||
? latestRender.outcome
|
|
||||||
: undefined,
|
|
||||||
reasonCodes: [...reasonCodes],
|
|
||||||
eventIds: events.map(event => event.eventId),
|
|
||||||
lastEventAt: latest?.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentStatusFromEvent(event: EvidenceEventV1 | undefined): MemoryLifecycleTrace["currentStatus"] {
|
|
||||||
if (!event) return "unknown";
|
|
||||||
if (event.outcome === "accepted") return "accepted";
|
|
||||||
if (event.outcome === "promoted") return "promoted";
|
|
||||||
if (event.outcome === "absorbed") return "absorbed";
|
|
||||||
if (event.outcome === "superseded") return "superseded";
|
|
||||||
if (event.outcome === "rendered") return "rendered";
|
|
||||||
if (event.outcome === "omitted") return "omitted";
|
|
||||||
if (event.outcome === "rejected") return "rejected";
|
|
||||||
if (event.type === "pending_memory_appended") return "pending";
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function traceMemoryLifecycle(
|
|
||||||
root: string,
|
|
||||||
input: { memoryId?: string; memoryKeyHash?: string; identityKeyHash?: string },
|
|
||||||
): Promise<MemoryLifecycleTrace> {
|
|
||||||
const events = await queryEvidenceEvents(root, input);
|
|
||||||
const createdBy = events.find(event => event.type === "explicit_memory_detected" || event.type === "pending_memory_appended");
|
|
||||||
const acceptedBy = events.find(event => event.type === "extraction_candidate_accepted" || event.type === "explicit_memory_detected");
|
|
||||||
const promotedBy = events.find(event => event.type === "promotion_promoted");
|
|
||||||
const absorbedBy = events.find(event => event.outcome === "absorbed");
|
|
||||||
const supersededBy = events.find(event => event.outcome === "superseded");
|
|
||||||
const reinforcedBy = events.filter(event => event.type === "memory_reinforced" || event.outcome === "reinforced");
|
|
||||||
const latestRender = events.filter(event => event.phase === "render").at(-1);
|
|
||||||
const latest = events.at(-1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
memoryId: input.memoryId,
|
|
||||||
memoryKeyHash: normalizeHashValue(input.memoryKeyHash),
|
|
||||||
identityKeyHash: normalizeHashValue(input.identityKeyHash),
|
|
||||||
events,
|
|
||||||
createdBy,
|
|
||||||
acceptedBy,
|
|
||||||
promotedBy,
|
|
||||||
absorbedBy,
|
|
||||||
supersededBy,
|
|
||||||
reinforcedBy,
|
|
||||||
latestRender,
|
|
||||||
currentStatus: currentStatusFromEvent(latest),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+29
-192
@@ -6,7 +6,6 @@ import { LONG_TERM_LIMITS } from "./types.ts";
|
|||||||
import { assessMemoryQuality } from "./memory-quality.ts";
|
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||||
import { extractionRejectionLogPath } from "./paths.ts";
|
import { extractionRejectionLogPath } from "./paths.ts";
|
||||||
import { redactCredentials } from "./redaction.ts";
|
import { redactCredentials } from "./redaction.ts";
|
||||||
import type { EvidenceEventInput } from "./evidence-log.ts";
|
|
||||||
|
|
||||||
function id(prefix: string): string {
|
function id(prefix: string): string {
|
||||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
@@ -47,34 +46,6 @@ function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||||
return extractExplicitMemoriesWithEvidence(text).entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkspaceMemoryParseResult = {
|
|
||||||
entries: LongTermMemoryEntry[];
|
|
||||||
evidence: EvidenceEventInput[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function evidenceTextPreview(text: string, maxChars = 120): string {
|
|
||||||
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
|
|
||||||
}
|
|
||||||
|
|
||||||
function memoryEvidence(memory: LongTermMemoryEntry): EvidenceEventInput["memory"] {
|
|
||||||
return {
|
|
||||||
memoryId: memory.id,
|
|
||||||
type: memory.type,
|
|
||||||
source: memory.source,
|
|
||||||
status: memory.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractionEvidence(
|
|
||||||
input: Pick<EvidenceEventInput, "type" | "phase" | "outcome" | "reasonCodes" | "textPreview" | "memory" | "details">,
|
|
||||||
): EvidenceEventInput {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemoryParseResult {
|
|
||||||
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
|
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
|
||||||
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
|
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
|
||||||
const patterns = [
|
const patterns = [
|
||||||
@@ -97,77 +68,31 @@ export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemo
|
|||||||
/(?:^|\n)\s*(?:my preference is|i prefer)[::,,]?\s*(.+)$/gim,
|
/(?:^|\n)\s*(?:my preference is|i prefer)[::,,]?\s*(.+)$/gim,
|
||||||
];
|
];
|
||||||
|
|
||||||
const nowMs = Date.now();
|
const now = new Date().toISOString();
|
||||||
const now = new Date(nowMs).toISOString();
|
|
||||||
const entries: LongTermMemoryEntry[] = [];
|
const entries: LongTermMemoryEntry[] = [];
|
||||||
const evidence: EvidenceEventInput[] = [];
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const negatedLinePattern = /(?:^|\n)\s*(?:(?:please\s+)?(?:do\s+not|don't|dont|never)\s+remember(?:\s+(?:this|that))?|不要\s*(?:記住|记住)|別\s*(?:記住|记住)|别\s*(?:記住|记住))[::,,]?\s*(.+)$/gim;
|
|
||||||
for (const match of text.matchAll(negatedLinePattern)) {
|
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "explicit_memory_ignored",
|
|
||||||
phase: "explicit",
|
|
||||||
outcome: "rejected",
|
|
||||||
reasonCodes: ["negated_request"],
|
|
||||||
textPreview: evidenceTextPreview(match[1] ?? match[0], 80),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
for (const match of text.matchAll(pattern)) {
|
for (const match of text.matchAll(pattern)) {
|
||||||
const body = match[1]?.trim();
|
const body = match[1]?.trim();
|
||||||
if (body && /^(再说|再說|later|next time)$/i.test(body)) {
|
if (!body || body.length < 8) continue;
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "explicit_memory_ignored",
|
|
||||||
phase: "explicit",
|
|
||||||
outcome: "rejected",
|
|
||||||
reasonCodes: ["deferral"],
|
|
||||||
textPreview: evidenceTextPreview(body, 80),
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!body || body.length < 8) {
|
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "explicit_memory_ignored",
|
|
||||||
phase: "explicit",
|
|
||||||
outcome: "rejected",
|
|
||||||
reasonCodes: ["too_short"],
|
|
||||||
textPreview: evidenceTextPreview(body ?? match[0], 80),
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate actual trigger position (after possible newline)
|
// Calculate actual trigger position (after possible newline)
|
||||||
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
|
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
|
||||||
|
|
||||||
// Check if this is a negated request (e.g., "不要記住")
|
// Check if this is a negated request (e.g., "不要記住")
|
||||||
if (isNegatedMemoryRequest(text, triggerIndex)) {
|
if (isNegatedMemoryRequest(text, triggerIndex)) continue;
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "explicit_memory_ignored",
|
|
||||||
phase: "explicit",
|
|
||||||
outcome: "rejected",
|
|
||||||
reasonCodes: ["negated_request"],
|
|
||||||
textPreview: evidenceTextPreview(body, 80),
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Check if it's a deferral (e.g., "later", "next time")
|
||||||
|
if (/^(再说|再說|later|next time)$/i.test(body)) continue;
|
||||||
|
|
||||||
// Dedupe by canonical body
|
// Dedupe by canonical body
|
||||||
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
|
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
|
||||||
if (seen.has(key)) {
|
if (seen.has(key)) continue;
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "explicit_memory_ignored",
|
|
||||||
phase: "explicit",
|
|
||||||
outcome: "rejected",
|
|
||||||
reasonCodes: ["duplicate_in_message"],
|
|
||||||
textPreview: evidenceTextPreview(body, 80),
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
|
|
||||||
const type = classifyExplicitMemory(body);
|
const type = classifyExplicitMemory(body);
|
||||||
const memory: LongTermMemoryEntry = {
|
entries.push({
|
||||||
id: id("mem"),
|
id: id("mem"),
|
||||||
type,
|
type,
|
||||||
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||||
@@ -176,22 +101,12 @@ export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemo
|
|||||||
status: "active",
|
status: "active",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
retentionClock: nowMs,
|
|
||||||
staleAfterDays: staleAfterDaysFor(type),
|
staleAfterDays: staleAfterDaysFor(type),
|
||||||
};
|
});
|
||||||
entries.push(memory);
|
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "explicit_memory_detected",
|
|
||||||
phase: "explicit",
|
|
||||||
outcome: "accepted",
|
|
||||||
reasonCodes: ["explicit_trigger_matched"],
|
|
||||||
memory: memoryEvidence(memory),
|
|
||||||
textPreview: evidenceTextPreview(memory.text),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { entries, evidence };
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyExplicitMemory(text: string): LongTermType {
|
function classifyExplicitMemory(text: string): LongTermType {
|
||||||
@@ -322,13 +237,6 @@ type ExtractionRejectionLogEntry = {
|
|||||||
text: string;
|
text: string;
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
source: "compaction";
|
source: "compaction";
|
||||||
workspaceKey?: string;
|
|
||||||
workspaceRootHash?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkspaceMemoryCandidateParseOptions = {
|
|
||||||
workspaceKey?: string;
|
|
||||||
workspaceRootHash?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
|
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
|
||||||
@@ -341,15 +249,15 @@ async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateWorkspaceMemoryCandidate(
|
function shouldAcceptWorkspaceMemoryCandidate(
|
||||||
entry: {
|
entry: {
|
||||||
type: LongTermType;
|
type: LongTermType;
|
||||||
text: string;
|
text: string;
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
fromMemoryTrigger?: boolean;
|
fromMemoryTrigger?: boolean;
|
||||||
} & WorkspaceMemoryCandidateParseOptions = {},
|
} = {},
|
||||||
): { accepted: boolean; reasons: string[] } {
|
): boolean {
|
||||||
const text = entry.text.trim();
|
const text = entry.text.trim();
|
||||||
const minLength = options.fromMemoryTrigger ? 6 : 20;
|
const minLength = options.fromMemoryTrigger ? 6 : 20;
|
||||||
|
|
||||||
@@ -357,14 +265,14 @@ function evaluateWorkspaceMemoryCandidate(
|
|||||||
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
|
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
|
||||||
// Stable config values can be short — allow below generic min length
|
// Stable config values can be short — allow below generic min length
|
||||||
} else if (text.length < minLength) {
|
} else if (text.length < minLength) {
|
||||||
return { accepted: false, reasons: ["too_short"] };
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indirect Prompt Injection / Adversarial Instructions
|
// Indirect Prompt Injection / Adversarial Instructions
|
||||||
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
||||||
// comparative "instead of" is allowed.
|
// 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 { accepted: false, reasons: ["prompt_injection"] };
|
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 { accepted: false, reasons: ["prompt_injection"] };
|
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
|
||||||
|
|
||||||
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
|
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
|
||||||
if (!quality.accepted) {
|
if (!quality.accepted) {
|
||||||
@@ -374,25 +282,11 @@ function evaluateWorkspaceMemoryCandidate(
|
|||||||
text: redactCredentials(text),
|
text: redactCredentials(text),
|
||||||
reasons: quality.reasons,
|
reasons: quality.reasons,
|
||||||
source: "compaction",
|
source: "compaction",
|
||||||
workspaceKey: options.workspaceKey,
|
|
||||||
workspaceRootHash: options.workspaceRootHash,
|
|
||||||
});
|
});
|
||||||
return { accepted: false, reasons: quality.reasons };
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { accepted: true, reasons: ["quality_gate_passed"] };
|
return true;
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAcceptWorkspaceMemoryCandidate(
|
|
||||||
entry: {
|
|
||||||
type: LongTermType;
|
|
||||||
text: string;
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
fromMemoryTrigger?: boolean;
|
|
||||||
} & WorkspaceMemoryCandidateParseOptions = {},
|
|
||||||
): boolean {
|
|
||||||
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -419,24 +313,12 @@ function extractCandidateBlock(summary: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseWorkspaceMemoryCandidates(
|
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
|
||||||
summary: string,
|
|
||||||
options?: WorkspaceMemoryCandidateParseOptions,
|
|
||||||
): LongTermMemoryEntry[] {
|
|
||||||
return parseWorkspaceMemoryCandidatesWithEvidence(summary, options).entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseWorkspaceMemoryCandidatesWithEvidence(
|
|
||||||
summary: string,
|
|
||||||
options: WorkspaceMemoryCandidateParseOptions = {},
|
|
||||||
): WorkspaceMemoryParseResult {
|
|
||||||
const block = extractCandidateBlock(summary);
|
const block = extractCandidateBlock(summary);
|
||||||
if (!block) return { entries: [], evidence: [] };
|
if (!block) return [];
|
||||||
|
|
||||||
const nowMs = Date.now();
|
const now = new Date().toISOString();
|
||||||
const now = new Date(nowMs).toISOString();
|
|
||||||
const entries: LongTermMemoryEntry[] = [];
|
const entries: LongTermMemoryEntry[] = [];
|
||||||
const evidence: EvidenceEventInput[] = [];
|
|
||||||
|
|
||||||
for (const line of block.split("\n")) {
|
for (const line of block.split("\n")) {
|
||||||
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
||||||
@@ -446,53 +328,18 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
|
|||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||||
const normalizedBody = normalizeCandidateBody(item[3]);
|
const normalizedBody = normalizeCandidateBody(item[3]);
|
||||||
if (!normalizedBody) {
|
if (!normalizedBody) continue;
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "extraction_candidate_rejected",
|
|
||||||
phase: "extraction",
|
|
||||||
outcome: "rejected",
|
|
||||||
reasonCodes: ["negated_request"],
|
|
||||||
memory: { type, source: "compaction" },
|
|
||||||
textPreview: evidenceTextPreview(item[3], 80),
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minLength = normalizedBody.hadTrigger ? 6 : 12;
|
const minLength = normalizedBody.hadTrigger ? 6 : 12;
|
||||||
if (normalizedBody.text.length < minLength) {
|
if (normalizedBody.text.length < minLength) continue;
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "extraction_candidate_rejected",
|
|
||||||
phase: "extraction",
|
|
||||||
outcome: "rejected",
|
|
||||||
reasonCodes: ["too_short"],
|
|
||||||
memory: { type, source: "compaction" },
|
|
||||||
textPreview: evidenceTextPreview(normalizedBody.text, 80),
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply quality gate
|
// Apply quality gate
|
||||||
const quality = evaluateWorkspaceMemoryCandidate(
|
if (!shouldAcceptWorkspaceMemoryCandidate(
|
||||||
{ type, text: normalizedBody.text },
|
{ type, text: normalizedBody.text },
|
||||||
{
|
{ fromMemoryTrigger: normalizedBody.hadTrigger },
|
||||||
fromMemoryTrigger: normalizedBody.hadTrigger,
|
)) continue;
|
||||||
workspaceKey: options.workspaceKey,
|
|
||||||
workspaceRootHash: options.workspaceRootHash,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!quality.accepted) {
|
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "extraction_candidate_rejected",
|
|
||||||
phase: "extraction",
|
|
||||||
outcome: "rejected",
|
|
||||||
reasonCodes: quality.reasons,
|
|
||||||
memory: { type, source: "compaction" },
|
|
||||||
textPreview: evidenceTextPreview(normalizedBody.text, 80),
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const memory: LongTermMemoryEntry = {
|
entries.push({
|
||||||
id: id("mem"),
|
id: id("mem"),
|
||||||
type,
|
type,
|
||||||
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||||
@@ -501,19 +348,9 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
|
|||||||
status: "active",
|
status: "active",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
retentionClock: nowMs,
|
|
||||||
staleAfterDays: staleAfterDaysFor(type),
|
staleAfterDays: staleAfterDaysFor(type),
|
||||||
};
|
});
|
||||||
entries.push(memory);
|
|
||||||
evidence.push(extractionEvidence({
|
|
||||||
type: "extraction_candidate_accepted",
|
|
||||||
phase: "extraction",
|
|
||||||
outcome: "accepted",
|
|
||||||
reasonCodes: ["quality_gate_passed", "valid_candidate_format"],
|
|
||||||
memory: memoryEvidence(memory),
|
|
||||||
textPreview: evidenceTextPreview(memory.text),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { entries, evidence };
|
return entries;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-27
@@ -78,39 +78,15 @@ export function isFeedbackQualityViolation(text: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasFutureRule(text: string): boolean {
|
export function isDecisionQualityViolation(text: string): boolean {
|
||||||
return /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|
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);
|
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
|
||||||
}
|
if (!futureRule) return true;
|
||||||
|
|
||||||
export function isArchitectureLikeDecision(text: string): boolean {
|
|
||||||
if (/\b(?:[A-Z][A-Z0-9]*_[A-Z0-9_]*|[A-Z][A-Z0-9]{3,})\b/.test(text)) return true;
|
|
||||||
if (/\b(?:schema|model|scoring|retention|cap|evidence|normalization|root cause|architecture(?!\s+keywords?)|boundary|rule|memory system)\b/i.test(text)) return true;
|
|
||||||
if (/(?:模型|架構|架构|证据|證據|規則|规则|邊界|边界|記憶系統|记忆系统|原因|採用|采用)/u.test(text)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isImplementationStatusDecision(text: string): boolean {
|
|
||||||
if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true;
|
if (/\b(?: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;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDecisionQualityViolation(text: string): boolean {
|
|
||||||
if (hasFutureRule(text)) {
|
|
||||||
if (isImplementationStatusDecision(text)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isArchitectureLikeDecision(text)) {
|
|
||||||
if (isImplementationStatusDecision(text)) return true;
|
|
||||||
if (/\b(?:session|wave|task|test|CI|compatibility|commit)\b/i.test(text)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRawErrorViolation(text: string): boolean {
|
function isRawErrorViolation(text: string): boolean {
|
||||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
|
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
|
||||||
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
|
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ export async function workspacePendingJournalPath(root: string): Promise<string>
|
|||||||
return join(await memoryRoot(root), "workspace-pending-journal.json");
|
return join(await memoryRoot(root), "workspace-pending-journal.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function workspaceEvidenceLogPath(root: string): Promise<string> {
|
|
||||||
return join(await memoryRoot(root), "evidence", "events.jsonl");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sessionStatePath(root: string, sessionID: string): Promise<string> {
|
export async function sessionStatePath(root: string, sessionID: string): Promise<string> {
|
||||||
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
|
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
|
||||||
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
|
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
|
||||||
|
|||||||
+143
-290
@@ -23,23 +23,20 @@
|
|||||||
* - Parses compaction summaries for memory candidates and merges them
|
* - Parses compaction summaries for memory candidates and merges them
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { rm } from "fs/promises";
|
||||||
import { realpath, rm } from "fs/promises";
|
|
||||||
import type { Plugin } from "@opencode-ai/plugin";
|
import type { Plugin } from "@opencode-ai/plugin";
|
||||||
import {
|
import {
|
||||||
extractExplicitMemoriesWithEvidence,
|
extractExplicitMemories,
|
||||||
extractActiveFiles,
|
extractActiveFiles,
|
||||||
extractErrorsFromBash,
|
extractErrorsFromBash,
|
||||||
parseWorkspaceMemoryCandidatesWithEvidence,
|
parseWorkspaceMemoryCandidates,
|
||||||
} from "./extractors.ts";
|
} from "./extractors.ts";
|
||||||
import {
|
import {
|
||||||
loadWorkspaceMemory,
|
loadWorkspaceMemory,
|
||||||
updateWorkspaceMemory,
|
updateWorkspaceMemory,
|
||||||
updateWorkspaceMemoryWithAccounting,
|
updateWorkspaceMemoryWithAccounting,
|
||||||
accountWorkspaceMemoryRender,
|
renderWorkspaceMemory,
|
||||||
workspaceMemoryIdentityKey,
|
|
||||||
} from "./workspace-memory.ts";
|
} from "./workspace-memory.ts";
|
||||||
import { reinforceMemory } from "./retention.ts";
|
|
||||||
import {
|
import {
|
||||||
appendPendingMemories,
|
appendPendingMemories,
|
||||||
clearPendingMemories,
|
clearPendingMemories,
|
||||||
@@ -58,15 +55,14 @@ import {
|
|||||||
addRecentDecision,
|
addRecentDecision,
|
||||||
renderHotSessionState,
|
renderHotSessionState,
|
||||||
} from "./session-state.ts";
|
} from "./session-state.ts";
|
||||||
import { sessionStatePath, workspaceKey } from "./paths.ts";
|
import { sessionStatePath } from "./paths.ts";
|
||||||
import {
|
import {
|
||||||
latestUserText,
|
latestUserText,
|
||||||
latestCompactionSummary,
|
latestCompactionSummary,
|
||||||
pendingTodos,
|
pendingTodos,
|
||||||
} from "./opencode.ts";
|
} from "./opencode.ts";
|
||||||
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts";
|
import { accountPendingPromotions } from "./promotion-accounting.ts";
|
||||||
import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts";
|
import { WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||||
import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the complete compaction prompt.
|
* Build the complete compaction prompt.
|
||||||
@@ -165,38 +161,6 @@ function renderTodosForCompaction(todos: Array<{ content: string; status: string
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeErrorMessage(error: unknown): string {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return message.replace(/\s+/g, " ").slice(0, 240);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise<void> {
|
|
||||||
const message = safeErrorMessage(error);
|
|
||||||
console.error(`[memory] ${scope} failed: ${message}`);
|
|
||||||
if (root) {
|
|
||||||
await appendEvidenceEvent(root, {
|
|
||||||
type: "hook_failed",
|
|
||||||
phase: "hook",
|
|
||||||
outcome: "failed",
|
|
||||||
reasonCodes: [scope],
|
|
||||||
details: { message },
|
|
||||||
}).catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function workspaceRootHash(root: string): Promise<string> {
|
|
||||||
const resolved = await realpath(root).catch(() => root);
|
|
||||||
return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function workspaceIdentity(root: string): Promise<{ workspaceKey: string; workspaceRootHash: string }> {
|
|
||||||
const [workspaceKeyValue, workspaceRootHashValue] = await Promise.all([
|
|
||||||
workspaceKey(root),
|
|
||||||
workspaceRootHash(root),
|
|
||||||
]);
|
|
||||||
return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MemoryV2Plugin: Plugin = async (input) => {
|
export const MemoryV2Plugin: Plugin = async (input) => {
|
||||||
const { directory, client } = input;
|
const { directory, client } = input;
|
||||||
|
|
||||||
@@ -233,29 +197,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
// Cache for processed user message IDs (to avoid duplicate processing)
|
// Cache for processed user message IDs (to avoid duplicate processing)
|
||||||
const processedUserMessages = new Map<string, Set<string>>();
|
const processedUserMessages = new Map<string, Set<string>>();
|
||||||
|
|
||||||
function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
|
|
||||||
return {
|
|
||||||
memoryId: memory.id,
|
|
||||||
memoryKeyHash: memoryKey(memory),
|
|
||||||
identityKeyHash: workspaceMemoryIdentityKey(memory),
|
|
||||||
type: memory.type,
|
|
||||||
source: memory.source,
|
|
||||||
status: memory.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pendingAppendedEvidence(memory: LongTermMemoryEntry): EvidenceEventInput {
|
|
||||||
return {
|
|
||||||
type: "pending_memory_appended",
|
|
||||||
phase: "pending_journal",
|
|
||||||
outcome: "accepted",
|
|
||||||
memory: memoryEvidenceRef(memory),
|
|
||||||
relations: [{ role: "pending", memory: memoryEvidenceRef(memory) }],
|
|
||||||
reasonCodes: ["pending_journal_append"],
|
|
||||||
textPreview: memory.text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||||
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||||
@@ -312,14 +253,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
|
|
||||||
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
|
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
|
||||||
|
|
||||||
const extraction = extractExplicitMemoriesWithEvidence(latestMessage.text);
|
const memories = extractExplicitMemories(latestMessage.text).map(memory => ({
|
||||||
await appendEvidenceEvents(directory, extraction.evidence.map(event => ({
|
|
||||||
...event,
|
|
||||||
sessionHash: sessionID,
|
|
||||||
messageHash: latestMessage.id,
|
|
||||||
})));
|
|
||||||
|
|
||||||
const memories = extraction.entries.map(memory => ({
|
|
||||||
...memory,
|
...memory,
|
||||||
pendingOwnerSessionID: sessionID,
|
pendingOwnerSessionID: sessionID,
|
||||||
pendingMessageID: latestMessage.id,
|
pendingMessageID: latestMessage.id,
|
||||||
@@ -332,11 +266,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
await appendPendingMemories(directory, memories);
|
await appendPendingMemories(directory, memories);
|
||||||
await appendEvidenceEvents(directory, memories.map(memory => ({
|
|
||||||
...pendingAppendedEvidence(memory),
|
|
||||||
sessionHash: sessionID,
|
|
||||||
messageHash: latestMessage.id,
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decisions.length > 0) {
|
if (decisions.length > 0) {
|
||||||
@@ -355,22 +284,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession);
|
rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupePendingPromotionMemories(
|
|
||||||
memories: LongTermMemoryEntry[],
|
|
||||||
): LongTermMemoryEntry[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const deduped: LongTermMemoryEntry[] = [];
|
|
||||||
|
|
||||||
for (const memory of memories) {
|
|
||||||
const key = memoryKey(memory);
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
deduped.push(memory);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deduped;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promotePendingMemories(
|
async function promotePendingMemories(
|
||||||
sessionID?: string,
|
sessionID?: string,
|
||||||
options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {},
|
options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {},
|
||||||
@@ -388,45 +301,27 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const pending = dedupePendingPromotionMemories([
|
const pending = [
|
||||||
...(sessionState?.pendingMemories ?? []),
|
...(sessionState?.pendingMemories ?? []),
|
||||||
...journalPending,
|
...journalPending,
|
||||||
]);
|
];
|
||||||
if (pending.length === 0) return;
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
|
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
|
||||||
|
|
||||||
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
|
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
|
||||||
beforeEntries = [...workspaceMemory.entries];
|
beforeEntries = [...workspaceMemory.entries];
|
||||||
const existingByKey = new Map<string, { memory: typeof workspaceMemory.entries[number]; index: number }>();
|
const existingKeys = new Set(
|
||||||
workspaceMemory.entries.forEach((memory, index) => {
|
workspaceMemory.entries
|
||||||
if (memory.status === "superseded") return;
|
.filter(memory => memory.status !== "superseded")
|
||||||
existingByKey.set(memoryKey(memory), { memory, index });
|
.map(memory => memoryKey(memory)),
|
||||||
});
|
);
|
||||||
|
|
||||||
const promotedAt = Date.now();
|
|
||||||
for (const memory of pending) {
|
for (const memory of pending) {
|
||||||
const key = memoryKey(memory);
|
const key = memoryKey(memory);
|
||||||
const existing = existingByKey.get(key);
|
if (!existingKeys.has(key)) {
|
||||||
if (existing) {
|
workspaceMemory.entries.push(memory);
|
||||||
const reinforced = reinforceMemory(
|
existingKeys.add(key);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,20 +345,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await appendEvidenceEvents(directory, [
|
|
||||||
...updateResult.evidence,
|
|
||||||
...promotionAccountingEvidenceEvents({
|
|
||||||
pending,
|
|
||||||
after: updateResult.store.entries,
|
|
||||||
events: updateResult.events,
|
|
||||||
accounting,
|
|
||||||
exhaustedRejectedKeys,
|
|
||||||
}),
|
|
||||||
].map(event => ({
|
|
||||||
...event,
|
|
||||||
sessionHash: sessionID,
|
|
||||||
})));
|
|
||||||
|
|
||||||
const sessionRemovalKeys = new Set([
|
const sessionRemovalKeys = new Set([
|
||||||
...accounting.clearableKeys,
|
...accounting.clearableKeys,
|
||||||
...exhaustedRejectedKeys,
|
...exhaustedRejectedKeys,
|
||||||
@@ -538,12 +419,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = await loadWorkspaceMemory(root);
|
const store = await loadWorkspaceMemory(root);
|
||||||
const renderAccounting = accountWorkspaceMemoryRender(store);
|
const renderedPrompt = renderWorkspaceMemory(store);
|
||||||
const renderedPrompt = renderAccounting.prompt;
|
|
||||||
await appendEvidenceEvents(root, renderAccounting.evidence.map(event => ({
|
|
||||||
...event,
|
|
||||||
sessionHash: sessionID,
|
|
||||||
})));
|
|
||||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
||||||
pruneFrozenWorkspaceMemoryCache(now);
|
pruneFrozenWorkspaceMemoryCache(now);
|
||||||
return { store, renderedPrompt };
|
return { store, renderedPrompt };
|
||||||
@@ -567,42 +443,38 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
const { sessionID } = hookInput;
|
const { sessionID } = hookInput;
|
||||||
if (!sessionID) return;
|
if (!sessionID) return;
|
||||||
|
|
||||||
try {
|
pruneFrozenWorkspaceMemoryCache();
|
||||||
pruneFrozenWorkspaceMemoryCache();
|
pruneProcessedUserMessagesCache();
|
||||||
pruneProcessedUserMessagesCache();
|
|
||||||
|
|
||||||
// Sub-agents are short-lived - skip memory system
|
// Sub-agents are short-lived - skip memory system
|
||||||
if (await isSubAgent(sessionID)) return;
|
if (await isSubAgent(sessionID)) return;
|
||||||
|
|
||||||
// Process explicit user memory even on no-tool turns. Keep this after the
|
// Process explicit user memory even on no-tool turns. Keep this after the
|
||||||
// sub-agent guard so child sessions never append to the parent journal.
|
// sub-agent guard so child sessions never append to the parent journal.
|
||||||
await processLatestUserMessage(sessionID);
|
await processLatestUserMessage(sessionID);
|
||||||
|
|
||||||
// Before first snapshot in this session, promote durable unowned backlog from
|
// Before first snapshot in this session, promote durable unowned backlog from
|
||||||
// prior sessions. Current-turn owned explicit memory remains pending and only
|
// prior sessions. Current-turn owned explicit memory remains pending and only
|
||||||
// appears in hot state for this transform.
|
// appears in hot state for this transform.
|
||||||
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
||||||
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
|
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get frozen workspace memory snapshot (loaded and rendered once per session)
|
// Get frozen workspace memory snapshot (loaded and rendered once per session)
|
||||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||||
|
|
||||||
// Get current hot session state
|
// Get current hot session state
|
||||||
const sessionState = await loadSessionState(directory, sessionID);
|
const sessionState = await loadSessionState(directory, sessionID);
|
||||||
|
|
||||||
// Inject frozen workspace memory snapshot
|
// Inject frozen workspace memory snapshot
|
||||||
if (workspaceSnapshot.renderedPrompt) {
|
if (workspaceSnapshot.renderedPrompt) {
|
||||||
output.system.push(workspaceSnapshot.renderedPrompt);
|
output.system.push(workspaceSnapshot.renderedPrompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render and inject hot session state
|
// Render and inject hot session state
|
||||||
const hotPrompt = renderHotSessionState(sessionState, directory);
|
const hotPrompt = renderHotSessionState(sessionState, directory);
|
||||||
if (hotPrompt) {
|
if (hotPrompt) {
|
||||||
output.system.push(hotPrompt);
|
output.system.push(hotPrompt);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await warnMemoryHook("chat.system.transform", error, directory);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -615,54 +487,50 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
// Sub-agents don't need memory tracking
|
// Sub-agents don't need memory tracking
|
||||||
if (await isSubAgent(sessionID)) return;
|
if (await isSubAgent(sessionID)) return;
|
||||||
|
|
||||||
try {
|
await updateSessionState(directory, sessionID, state => {
|
||||||
await updateSessionState(directory, sessionID, state => {
|
// Track active files from tool usage
|
||||||
// Track active files from tool usage
|
if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") {
|
||||||
if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") {
|
const files = extractActiveFiles(
|
||||||
const files = extractActiveFiles(
|
toolName,
|
||||||
toolName,
|
args as Record<string, unknown>,
|
||||||
args as Record<string, unknown>,
|
toolOutput ?? ""
|
||||||
toolOutput ?? ""
|
);
|
||||||
);
|
for (const { path, action } of files) {
|
||||||
for (const { path, action } of files) {
|
touchActiveFile(state, path, action);
|
||||||
touchActiveFile(state, path, action);
|
if (action === "edit" || action === "write") {
|
||||||
if (action === "edit" || action === "write") {
|
markErrorsMaybeFixedForFile(state, path, directory);
|
||||||
markErrorsMaybeFixedForFile(state, path, directory);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track errors from failed bash commands
|
// Track errors from failed bash commands
|
||||||
if (toolName === "bash") {
|
if (toolName === "bash") {
|
||||||
const argsRecord = args as Record<string, unknown>;
|
const argsRecord = args as Record<string, unknown>;
|
||||||
const command: string = typeof argsRecord?.command === "string"
|
const command: string = typeof argsRecord?.command === "string"
|
||||||
? argsRecord.command
|
? argsRecord.command
|
||||||
: "";
|
: "";
|
||||||
const outputText: string = toolOutput ?? "";
|
const outputText: string = toolOutput ?? "";
|
||||||
|
|
||||||
// Check if command succeeded - clear errors for that category
|
// Check if command succeeded - clear errors for that category
|
||||||
const exitCode = bashExitCode(hookOutput);
|
const exitCode = bashExitCode(hookOutput);
|
||||||
if (typeof exitCode !== "number") {
|
if (typeof exitCode !== "number") {
|
||||||
// Unknown exit status: do not extract and do not clear
|
// Unknown exit status: do not extract and do not clear
|
||||||
} else if (exitCode === 0 && command) {
|
} else if (exitCode === 0 && command) {
|
||||||
clearErrorsForSuccessfulCommand(state, command);
|
clearErrorsForSuccessfulCommand(state, command);
|
||||||
} else if (command) {
|
} else if (command) {
|
||||||
// Only extract errors for commands with explicit non-zero exit
|
// Only extract errors for commands with explicit non-zero exit
|
||||||
const errors = extractErrorsFromBash(command, outputText);
|
const errors = extractErrorsFromBash(command, outputText);
|
||||||
for (const error of errors) {
|
for (const error of errors) {
|
||||||
upsertOpenError(state, error);
|
upsertOpenError(state, error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return state;
|
}
|
||||||
});
|
return state;
|
||||||
|
});
|
||||||
|
|
||||||
// Process explicit memory from latest user message
|
// Process explicit memory from latest user message
|
||||||
// Only process once per message ID
|
// Only process once per message ID
|
||||||
await processLatestUserMessage(sessionID);
|
await processLatestUserMessage(sessionID);
|
||||||
} catch (error) {
|
|
||||||
await warnMemoryHook("tool.execute.after", error, directory);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -680,110 +548,95 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
const { sessionID } = hookInput;
|
const { sessionID } = hookInput;
|
||||||
if (!sessionID) return;
|
if (!sessionID) return;
|
||||||
|
|
||||||
try {
|
// Sub-agents don't need compaction support
|
||||||
// Sub-agents don't need compaction support
|
if (await isSubAgent(sessionID)) return;
|
||||||
if (await isSubAgent(sessionID)) return;
|
|
||||||
|
|
||||||
// Preserve context injected by other plugins that ran before us.
|
// Preserve context injected by other plugins that ran before us.
|
||||||
// Setting output.prompt bypasses the default prompt + context join,
|
// Setting output.prompt bypasses the default prompt + context join,
|
||||||
// so we must explicitly carry forward any existing output.context.
|
// so we must explicitly carry forward any existing output.context.
|
||||||
const otherContext = output.context.filter(Boolean).join("\n\n");
|
const otherContext = output.context.filter(Boolean).join("\n\n");
|
||||||
|
|
||||||
// Build our private context (workspace memory, hot state, todos)
|
// Build our private context (workspace memory, hot state, todos)
|
||||||
const contextParts: string[] = [];
|
const contextParts: string[] = [];
|
||||||
|
|
||||||
// 1. Frozen workspace memory snapshot
|
// 1. Frozen workspace memory snapshot
|
||||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||||
if (workspaceSnapshot.renderedPrompt) {
|
if (workspaceSnapshot.renderedPrompt) {
|
||||||
contextParts.push(workspaceSnapshot.renderedPrompt);
|
contextParts.push(workspaceSnapshot.renderedPrompt);
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Hot session state
|
|
||||||
const sessionState = await loadSessionState(directory, sessionID);
|
|
||||||
const hotPrompt = renderHotSessionState(sessionState, directory);
|
|
||||||
if (hotPrompt) {
|
|
||||||
contextParts.push(hotPrompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Pending todos from OpenCode
|
|
||||||
const todos = await pendingTodos(client, sessionID);
|
|
||||||
const todosPrompt = renderTodosForCompaction(todos);
|
|
||||||
if (todosPrompt) {
|
|
||||||
contextParts.push(todosPrompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine: other plugins' context first, then our private context
|
|
||||||
const privateContext = [otherContext, ...contextParts]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n\n");
|
|
||||||
|
|
||||||
// Replace the default prompt entirely with our ---free template
|
|
||||||
output.prompt = buildCompactionPrompt(privateContext);
|
|
||||||
|
|
||||||
// Clear context array since we consumed it into output.prompt.
|
|
||||||
// Subsequent plugins that set output.prompt will also need to check
|
|
||||||
// output.context if they want to preserve other plugin contributions.
|
|
||||||
output.context.length = 0;
|
|
||||||
} catch (error) {
|
|
||||||
await warnMemoryHook("session.compacting", error, directory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Hot session state
|
||||||
|
const sessionState = await loadSessionState(directory, sessionID);
|
||||||
|
const hotPrompt = renderHotSessionState(sessionState, directory);
|
||||||
|
if (hotPrompt) {
|
||||||
|
contextParts.push(hotPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Pending todos from OpenCode
|
||||||
|
const todos = await pendingTodos(client, sessionID);
|
||||||
|
const todosPrompt = renderTodosForCompaction(todos);
|
||||||
|
if (todosPrompt) {
|
||||||
|
contextParts.push(todosPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine: other plugins' context first, then our private context
|
||||||
|
const privateContext = [otherContext, ...contextParts]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
// Replace the default prompt entirely with our ---free template
|
||||||
|
output.prompt = buildCompactionPrompt(privateContext);
|
||||||
|
|
||||||
|
// Clear context array since we consumed it into output.prompt.
|
||||||
|
// Subsequent plugins that set output.prompt will also need to check
|
||||||
|
// output.context if they want to preserve other plugin contributions.
|
||||||
|
output.context.length = 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Handle session events
|
// Handle session events
|
||||||
event: async ({ event }) => {
|
event: async ({ event }) => {
|
||||||
if (event.type === "session.compacted") {
|
if (event.type === "session.compacted") {
|
||||||
|
const sessionID = sessionIDFromEventProperties(event.properties);
|
||||||
|
if (!sessionID) return;
|
||||||
|
|
||||||
|
// Sub-agents don't need post-compaction processing
|
||||||
|
if (await isSubAgent(sessionID)) return;
|
||||||
|
|
||||||
|
// Parse latest compaction summary for memory candidates, stage them into
|
||||||
|
// durable pending journal, then promote pending memories.
|
||||||
|
const summary = await latestCompactionSummary(client, sessionID);
|
||||||
|
const candidates = summary ? parseWorkspaceMemoryCandidates(summary) : [];
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
await appendPendingMemories(directory, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionID = sessionIDFromEventProperties(event.properties);
|
|
||||||
if (!sessionID) return;
|
|
||||||
|
|
||||||
// Sub-agents don't need post-compaction processing
|
|
||||||
if (await isSubAgent(sessionID)) return;
|
|
||||||
|
|
||||||
// Parse latest compaction summary for memory candidates, stage them into
|
|
||||||
// durable pending journal, then promote pending memories.
|
|
||||||
const summary = await latestCompactionSummary(client, sessionID);
|
|
||||||
const parseResult = summary
|
|
||||||
? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory))
|
|
||||||
: { entries: [], evidence: [] };
|
|
||||||
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
|
|
||||||
...event,
|
|
||||||
sessionHash: sessionID,
|
|
||||||
})));
|
|
||||||
const candidates = parseResult.entries;
|
|
||||||
if (candidates.length > 0) {
|
|
||||||
await appendPendingMemories(directory, candidates);
|
|
||||||
await appendEvidenceEvents(directory, candidates.map(memory => ({
|
|
||||||
...pendingAppendedEvidence(memory),
|
|
||||||
sessionHash: sessionID,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Keep pending memories in session/journal for retry on next event/session.
|
// Keep pending memories in session/journal for retry on next event/session.
|
||||||
await warnMemoryHook("event.session.compacted", error, directory);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
try {
|
const sessionID = sessionIDFromEventProperties(event.properties);
|
||||||
const sessionID = sessionIDFromEventProperties(event.properties);
|
if (sessionID) {
|
||||||
if (sessionID) {
|
|
||||||
// Promote pending memories before deleting per-session state.
|
// Promote pending memories before deleting per-session state.
|
||||||
// If promotion fails, leave session state and journal intact.
|
// If promotion fails, leave session state and journal intact.
|
||||||
let promoted = false;
|
let promoted = false;
|
||||||
|
try {
|
||||||
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
|
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
|
||||||
promoted = true;
|
promoted = true;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
if (promoted) {
|
if (promoted) {
|
||||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||||
processedUserMessages.delete(sessionID);
|
processedUserMessages.delete(sessionID);
|
||||||
sessionParentCache.delete(sessionID);
|
sessionParentCache.delete(sessionID);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rm(await sessionStatePath(directory, sessionID), { force: true });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
await warnMemoryHook("event.session.deleted", error, directory);
|
await rm(await sessionStatePath(directory, sessionID), { force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-118
@@ -2,7 +2,6 @@ import type { LongTermMemoryEntry } from "./types.ts";
|
|||||||
import { memoryKey } from "./pending-journal.ts";
|
import { memoryKey } from "./pending-journal.ts";
|
||||||
import type { MemoryConsolidationEvent } from "./workspace-memory.ts";
|
import type { MemoryConsolidationEvent } from "./workspace-memory.ts";
|
||||||
import { workspaceMemoryIdentityKey } from "./workspace-memory.ts";
|
import { workspaceMemoryIdentityKey } from "./workspace-memory.ts";
|
||||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
|
||||||
|
|
||||||
export type PendingPromotionAccounting = {
|
export type PendingPromotionAccounting = {
|
||||||
promotedKeys: Set<string>;
|
promotedKeys: Set<string>;
|
||||||
@@ -60,7 +59,7 @@ export function accountPendingPromotions(input: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (terminal.reason === "rejected_capacity") {
|
if (terminal.reason === "rejected_capacity" || terminal.reason === "rejected_stale") {
|
||||||
rejectedKeys.add(key);
|
rejectedKeys.add(key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -81,7 +80,10 @@ export function accountPendingPromotions(input: {
|
|||||||
...input.pending
|
...input.pending
|
||||||
.filter(memory => {
|
.filter(memory => {
|
||||||
const terminal = terminalEventByKey.get(memoryKey(memory));
|
const terminal = terminalEventByKey.get(memoryKey(memory));
|
||||||
return memory.source === "compaction" && terminal?.reason === "rejected_capacity";
|
return memory.source === "compaction" && (
|
||||||
|
terminal?.reason === "rejected_capacity" ||
|
||||||
|
terminal?.reason === "rejected_stale"
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.map(memory => memoryKey(memory)),
|
.map(memory => memoryKey(memory)),
|
||||||
]);
|
]);
|
||||||
@@ -106,118 +108,3 @@ export function accountPendingPromotions(input: {
|
|||||||
clearableKeys,
|
clearableKeys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function memoryRef(memory: LongTermMemoryEntry | undefined): MemoryEvidenceRef | undefined {
|
|
||||||
if (!memory) return undefined;
|
|
||||||
return {
|
|
||||||
memoryId: memory.id,
|
|
||||||
memoryKeyHash: memoryKey(memory),
|
|
||||||
identityKeyHash: workspaceMemoryIdentityKey(memory),
|
|
||||||
type: memory.type,
|
|
||||||
source: memory.source,
|
|
||||||
status: memory.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function retainedMemoryFor(
|
|
||||||
pending: LongTermMemoryEntry,
|
|
||||||
event: MemoryConsolidationEvent | undefined,
|
|
||||||
after: LongTermMemoryEntry[],
|
|
||||||
): LongTermMemoryEntry | undefined {
|
|
||||||
if (event?.retainedId) {
|
|
||||||
const byId = after.find(memory => memory.id === event.retainedId);
|
|
||||||
if (byId) return byId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exactKey = memoryKey(pending);
|
|
||||||
const identityKey = workspaceMemoryIdentityKey(pending);
|
|
||||||
return after.find(memory => memory.status !== "superseded" && (
|
|
||||||
memoryKey(memory) === exactKey || workspaceMemoryIdentityKey(memory) === identityKey
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function promotionEventBase(
|
|
||||||
type: EvidenceEventInput["type"],
|
|
||||||
outcome: EvidenceEventInput["outcome"],
|
|
||||||
memory: LongTermMemoryEntry,
|
|
||||||
reasonCodes: string[],
|
|
||||||
): EvidenceEventInput {
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
phase: "promotion",
|
|
||||||
outcome,
|
|
||||||
memory: memoryRef(memory),
|
|
||||||
reasonCodes,
|
|
||||||
textPreview: memory.text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function promotionAccountingEvidenceEvents(input: {
|
|
||||||
pending: LongTermMemoryEntry[];
|
|
||||||
after: LongTermMemoryEntry[];
|
|
||||||
events?: MemoryConsolidationEvent[];
|
|
||||||
accounting: PendingPromotionAccounting;
|
|
||||||
exhaustedRejectedKeys?: Set<string>;
|
|
||||||
}): EvidenceEventInput[] {
|
|
||||||
const terminalByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event]));
|
|
||||||
const exhaustedRejectedKeys = input.exhaustedRejectedKeys ?? new Set<string>();
|
|
||||||
const evidence: EvidenceEventInput[] = [];
|
|
||||||
|
|
||||||
for (const pending of input.pending) {
|
|
||||||
const key = memoryKey(pending);
|
|
||||||
const terminal = terminalByKey.get(key);
|
|
||||||
const retained = retainedMemoryFor(pending, terminal, input.after);
|
|
||||||
|
|
||||||
if (input.accounting.promotedKeys.has(key)) {
|
|
||||||
evidence.push({
|
|
||||||
...promotionEventBase("promotion_promoted", "promoted", pending, ["new_workspace_entry"]),
|
|
||||||
relations: [
|
|
||||||
{ role: "promoted", memory: memoryRef(retained ?? pending) },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.accounting.absorbedKeys.has(key)) {
|
|
||||||
const exact = terminal?.reason !== "absorbed_identity";
|
|
||||||
evidence.push({
|
|
||||||
...promotionEventBase(
|
|
||||||
exact ? "promotion_absorbed_exact" : "promotion_absorbed_identity",
|
|
||||||
"absorbed",
|
|
||||||
pending,
|
|
||||||
[exact ? "same_exact_key" : "same_identity_key"],
|
|
||||||
),
|
|
||||||
relations: [
|
|
||||||
{ role: "absorbed" as const, memory: memoryRef(pending) },
|
|
||||||
{ role: "retained" as const, memory: memoryRef(retained) },
|
|
||||||
].filter(relation => relation.memory),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.accounting.supersededKeys.has(key)) {
|
|
||||||
evidence.push({
|
|
||||||
...promotionEventBase("promotion_superseded", "superseded", pending, ["superseded_existing"]),
|
|
||||||
relations: [
|
|
||||||
{ role: "superseded" as const, memory: memoryRef(pending) },
|
|
||||||
{ role: "superseded_by" as const, memory: memoryRef(retained) },
|
|
||||||
].filter(relation => relation.memory),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.accounting.rejectedKeys.has(key)) {
|
|
||||||
evidence.push(promotionEventBase("promotion_rejected_capacity", "rejected", pending, ["capacity_rejected"]));
|
|
||||||
if (input.accounting.retryableRejectedKeys.has(key)) {
|
|
||||||
evidence.push(promotionEventBase(
|
|
||||||
exhaustedRejectedKeys.has(key) ? "promotion_retry_exhausted" : "promotion_retry_scheduled",
|
|
||||||
exhaustedRejectedKeys.has(key) ? "exhausted" : "retried",
|
|
||||||
pending,
|
|
||||||
[exhaustedRejectedKeys.has(key) ? "max_attempts_reached" : "retryable_capacity_rejection"],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return evidence;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
|
||||||
|
|
||||||
// Retention decay model constants (v1.5)
|
|
||||||
export const BASE_HALF_LIFE_DAYS = 45;
|
|
||||||
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
|
||||||
export const REINFORCEMENT_MAX_COUNT = 6;
|
|
||||||
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
||||||
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
|
||||||
export const DORMANT_DECAY_MULTIPLIER = 0.25;
|
|
||||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
export const TYPE_FACTOR = {
|
|
||||||
reference: 1.0,
|
|
||||||
project: 1.25,
|
|
||||||
feedback: 2.25,
|
|
||||||
decision: 2.5,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const SOURCE_FACTOR = {
|
|
||||||
compaction: 1.0,
|
|
||||||
manual: 1.4,
|
|
||||||
explicit: 2.0,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const USER_IMPORTANCE_FACTOR = {
|
|
||||||
low: 0.7,
|
|
||||||
normal: 1.0,
|
|
||||||
high: 1.5,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const RETENTION_TYPE_MAX = {
|
|
||||||
feedback: 10,
|
|
||||||
decision: 10,
|
|
||||||
project: 8,
|
|
||||||
reference: 6,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
|
|
||||||
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
|
|
||||||
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
|
|
||||||
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
|
|
||||||
|
|
||||||
return typeFactor * sourceFactor * importanceFactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
|
|
||||||
const reinforcementCount = Math.min(
|
|
||||||
memory.reinforcementCount ?? 0,
|
|
||||||
REINFORCEMENT_MAX_COUNT,
|
|
||||||
);
|
|
||||||
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
|
|
||||||
return BASE_HALF_LIFE_DAYS / factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timestampMs(value: unknown, fallback: number): number {
|
|
||||||
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
|
|
||||||
return Number.isFinite(ms) ? ms : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateRetentionStrength(
|
|
||||||
memory: LongTermMemoryEntry,
|
|
||||||
now: number,
|
|
||||||
lastActivityAt?: string,
|
|
||||||
): number {
|
|
||||||
const initialStrength = calculateInitialStrength(memory);
|
|
||||||
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
|
|
||||||
|
|
||||||
// Use retentionClock if available, fallback to updatedAt.
|
|
||||||
const retentionStart = Number.isFinite(memory.retentionClock)
|
|
||||||
? memory.retentionClock
|
|
||||||
: memory.updatedAt ?? memory.createdAt;
|
|
||||||
const createdAtMs = timestampMs(retentionStart, now);
|
|
||||||
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
|
|
||||||
|
|
||||||
// Calculate strength using exponential decay.
|
|
||||||
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
|
|
||||||
|
|
||||||
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
|
|
||||||
const lastActivity = store.lastActivityAt
|
|
||||||
? new Date(store.lastActivityAt).getTime()
|
|
||||||
: now;
|
|
||||||
if (!Number.isFinite(lastActivity)) return 0;
|
|
||||||
|
|
||||||
const daysSinceActivity = (now - lastActivity) / DAY_MS;
|
|
||||||
return Math.max(0, daysSinceActivity);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateEffectiveAgeDays(
|
|
||||||
entryStartMs: number,
|
|
||||||
now: number,
|
|
||||||
lastActivityAt?: string,
|
|
||||||
): number {
|
|
||||||
const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS);
|
|
||||||
|
|
||||||
if (!lastActivityAt) return wallAgeDays;
|
|
||||||
|
|
||||||
const lastActivityMs = new Date(lastActivityAt).getTime();
|
|
||||||
if (!Number.isFinite(lastActivityMs)) return wallAgeDays;
|
|
||||||
|
|
||||||
const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS;
|
|
||||||
const overlapStartMs = Math.max(entryStartMs, dormantStartMs);
|
|
||||||
const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS);
|
|
||||||
const activeDays = wallAgeDays - dormantOverlapDays;
|
|
||||||
|
|
||||||
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
|
|
||||||
const d1 = new Date(ts1);
|
|
||||||
const d2 = new Date(ts2);
|
|
||||||
return d1.getUTCFullYear() === d2.getUTCFullYear()
|
|
||||||
&& d1.getUTCMonth() === d2.getUTCMonth()
|
|
||||||
&& d1.getUTCDate() === d2.getUTCDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reinforceMemory(
|
|
||||||
memory: LongTermMemoryEntry,
|
|
||||||
sessionId: string,
|
|
||||||
now: number,
|
|
||||||
): LongTermMemoryEntry {
|
|
||||||
if (memory.lastReinforcedSessionID === sessionId) {
|
|
||||||
return memory;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
|
|
||||||
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
|
|
||||||
return memory;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
|
|
||||||
return memory;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
|
|
||||||
return memory;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...memory,
|
|
||||||
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
|
|
||||||
lastReinforcedAt: now,
|
|
||||||
lastReinforcedSessionID: sessionId,
|
|
||||||
retentionClock: now,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-70
@@ -3,66 +3,17 @@ import { randomUUID } from "crypto";
|
|||||||
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
||||||
import type { FileHandle } from "fs/promises";
|
import type { FileHandle } from "fs/promises";
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
import { appendEvidenceEventForWorkspaceKey } from "./evidence-log.ts";
|
|
||||||
|
|
||||||
const fileLocks = new Map<string, Promise<unknown>>();
|
const fileLocks = new Map<string, Promise<unknown>>();
|
||||||
const LOCK_WAIT_TIMEOUT_MS = 5000;
|
const LOCK_WAIT_TIMEOUT_MS = 5000;
|
||||||
const LOCK_STALE_MS = 30_000;
|
const LOCK_STALE_MS = 30_000;
|
||||||
const LOCK_HEARTBEAT_MS = 1_000;
|
const LOCK_HEARTBEAT_MS = 1_000;
|
||||||
|
|
||||||
function workspaceKeyFromStorePath(path: string): string | undefined {
|
|
||||||
return path.match(/[\\/]opencode-working-memory[\\/]workspaces[\\/]([a-f0-9]{16})[\\/]/i)?.[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeKindFromPath(path: string): string {
|
|
||||||
if (path.endsWith("workspace-memory.json")) return "workspace_memory";
|
|
||||||
if (path.endsWith("workspace-pending-journal.json")) return "pending_journal";
|
|
||||||
if (path.includes(`${"/"}sessions${"/"}`) || path.includes(`${"\\"}sessions${"\\"}`)) return "session_state";
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function emitStorageEvidence(path: string, event: Parameters<typeof appendEvidenceEventForWorkspaceKey>[1]): Promise<void> {
|
|
||||||
const key = workspaceKeyFromStorePath(path);
|
|
||||||
if (!key) return;
|
|
||||||
await appendEvidenceEventForWorkspaceKey(key, event).catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function quarantineCorruptJSON(path: string): Promise<string | null> {
|
|
||||||
const quarantinePath = `${path}.corrupt-${Date.now()}-${process.pid}-${randomUUID()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await rename(path, quarantinePath);
|
|
||||||
return quarantinePath;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
||||||
if (!existsSync(path)) return fallback();
|
if (!existsSync(path)) return fallback();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await readFile(path, "utf8")) as T;
|
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||||
} catch (error) {
|
} catch {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
const quarantinePath = await quarantineCorruptJSON(path);
|
|
||||||
|
|
||||||
if (quarantinePath) {
|
|
||||||
console.error(`[memory] invalid JSON in ${path}; quarantined to ${quarantinePath}: ${message}`);
|
|
||||||
await emitStorageEvidence(path, {
|
|
||||||
type: "storage_corrupt_json_quarantined",
|
|
||||||
phase: "storage",
|
|
||||||
outcome: "quarantined",
|
|
||||||
reasonCodes: ["invalid_json"],
|
|
||||||
details: {
|
|
||||||
storeKind: storeKindFromPath(path),
|
|
||||||
quarantined: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error(`[memory] invalid JSON in ${path}; using fallback without quarantine: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback();
|
return fallback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,30 +82,10 @@ async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
|||||||
|
|
||||||
if (await isLockStale(lockPath)) {
|
if (await isLockStale(lockPath)) {
|
||||||
await rm(lockPath, { force: true });
|
await rm(lockPath, { force: true });
|
||||||
await emitStorageEvidence(path, {
|
|
||||||
type: "storage_stale_lock_recovered",
|
|
||||||
phase: "storage",
|
|
||||||
outcome: "recovered",
|
|
||||||
reasonCodes: ["stale_lock"],
|
|
||||||
details: {
|
|
||||||
storeKind: storeKindFromPath(path),
|
|
||||||
waitMs: Date.now() - started,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) {
|
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) {
|
||||||
await emitStorageEvidence(path, {
|
|
||||||
type: "storage_lock_timeout",
|
|
||||||
phase: "storage",
|
|
||||||
outcome: "failed",
|
|
||||||
reasonCodes: ["lock_wait_timeout"],
|
|
||||||
details: {
|
|
||||||
storeKind: storeKindFromPath(path),
|
|
||||||
waitMs: LOCK_WAIT_TIMEOUT_MS,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
throw new Error(`Timed out waiting for lock ${lockPath}`);
|
throw new Error(`Timed out waiting for lock ${lockPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-10
@@ -20,12 +20,6 @@ export type LongTermMemoryEntry = {
|
|||||||
promotionAttempts?: number;
|
promotionAttempts?: number;
|
||||||
lastPromotionAttemptAt?: string;
|
lastPromotionAttemptAt?: string;
|
||||||
lastPromotionFailureReason?: 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 = {
|
export type WorkspaceMemoryStore = {
|
||||||
@@ -41,7 +35,6 @@ export type WorkspaceMemoryStore = {
|
|||||||
entries: LongTermMemoryEntry[];
|
entries: LongTermMemoryEntry[];
|
||||||
migrations?: string[];
|
migrations?: string[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastActivityAt?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PendingMemoryJournalStore = {
|
export type PendingMemoryJournalStore = {
|
||||||
@@ -95,15 +88,15 @@ export type SessionState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const LONG_TERM_LIMITS = {
|
export const LONG_TERM_LIMITS = {
|
||||||
maxRenderedChars: 3600,
|
maxRenderedChars: 5200,
|
||||||
targetRenderedChars: 3000,
|
targetRenderedChars: 4200,
|
||||||
maxEntries: 28,
|
maxEntries: 28,
|
||||||
maxEntryTextChars: 260,
|
maxEntryTextChars: 260,
|
||||||
maxRationaleChars: 180,
|
maxRationaleChars: 180,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const HOT_STATE_LIMITS = {
|
export const HOT_STATE_LIMITS = {
|
||||||
maxRenderedChars: 700,
|
maxRenderedChars: 1200,
|
||||||
maxActiveFilesStored: 20,
|
maxActiveFilesStored: 20,
|
||||||
maxActiveFilesRendered: 8,
|
maxActiveFilesRendered: 8,
|
||||||
maxOpenErrorsStored: 5,
|
maxOpenErrorsStored: 5,
|
||||||
|
|||||||
+56
-248
@@ -6,25 +6,19 @@ import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts"
|
|||||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||||
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||||
import { redactCredentials } from "./redaction.ts";
|
import { redactCredentials } from "./redaction.ts";
|
||||||
import {
|
|
||||||
RETENTION_TYPE_MAX,
|
|
||||||
calculateRetentionStrength,
|
|
||||||
reinforceMemory,
|
|
||||||
} from "./retention.ts";
|
|
||||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
|
||||||
|
|
||||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||||
const MIN_ENVELOPE_LENGTH = 80;
|
const MIN_ENVELOPE_LENGTH = 80;
|
||||||
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
||||||
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
|
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
|
||||||
const RETENTION_CLOCK_BACKFILL_MIGRATION_ID = "2026-05-01-retention-clock-backfill";
|
|
||||||
|
|
||||||
export type MemoryConsolidationReason =
|
export type MemoryConsolidationReason =
|
||||||
| "promoted"
|
| "promoted"
|
||||||
| "absorbed_exact"
|
| "absorbed_exact"
|
||||||
| "absorbed_identity"
|
| "absorbed_identity"
|
||||||
| "superseded_existing"
|
| "superseded_existing"
|
||||||
| "rejected_capacity";
|
| "rejected_capacity"
|
||||||
|
| "rejected_stale";
|
||||||
|
|
||||||
export type MemoryConsolidationEvent = {
|
export type MemoryConsolidationEvent = {
|
||||||
memoryKey: string;
|
memoryKey: string;
|
||||||
@@ -40,7 +34,6 @@ export type LongTermLimitResult = {
|
|||||||
dropped: MemoryConsolidationEvent[];
|
dropped: MemoryConsolidationEvent[];
|
||||||
absorbed: MemoryConsolidationEvent[];
|
absorbed: MemoryConsolidationEvent[];
|
||||||
superseded: MemoryConsolidationEvent[];
|
superseded: MemoryConsolidationEvent[];
|
||||||
evidence: EvidenceEventInput[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||||||
@@ -48,16 +41,6 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
|||||||
events: MemoryConsolidationEvent[];
|
events: MemoryConsolidationEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceMemoryRenderAccounting = {
|
|
||||||
rendered: LongTermMemoryEntry[];
|
|
||||||
omitted: Array<{
|
|
||||||
memory: LongTermMemoryEntry;
|
|
||||||
reason: "superseded" | "type_cap" | "global_cap" | "char_budget" | "empty_render_budget";
|
|
||||||
}>;
|
|
||||||
evidence: EvidenceEventInput[];
|
|
||||||
prompt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type QualityCleanupMigrationLogEntry = {
|
export type QualityCleanupMigrationLogEntry = {
|
||||||
migrationId: string;
|
migrationId: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -74,7 +57,6 @@ export type QualityCleanupMigrationLogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
workspace: { root, key: await workspaceKey(root) },
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
@@ -84,8 +66,7 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
|
|||||||
},
|
},
|
||||||
entries: [],
|
entries: [],
|
||||||
migrations: [],
|
migrations: [],
|
||||||
updatedAt: nowIso,
|
updatedAt: new Date().toISOString(),
|
||||||
lastActivityAt: nowIso,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +85,6 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
|
|||||||
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
|
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
|
||||||
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
|
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
|
||||||
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
|
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
|
||||||
lastActivityAt: loaded.lastActivityAt ?? loaded.updatedAt ?? fallback.lastActivityAt,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always normalize on load so redaction/migrations are always-on.
|
// Always normalize on load so redaction/migrations are always-on.
|
||||||
@@ -130,12 +110,10 @@ function hasSecurityOrMigrationChange(
|
|||||||
if (beforeEntry.text !== afterEntry.text) return true;
|
if (beforeEntry.text !== afterEntry.text) return true;
|
||||||
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
|
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
|
||||||
if (beforeEntry.status !== afterEntry.status) return true;
|
if (beforeEntry.status !== afterEntry.status) return true;
|
||||||
if ((beforeEntry.retentionClock ?? null) !== (afterEntry.retentionClock ?? null)) return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeMigrations = JSON.stringify(before.migrations ?? []);
|
const beforeMigrations = JSON.stringify(before.migrations ?? []);
|
||||||
const afterMigrations = JSON.stringify(after.migrations ?? []);
|
const afterMigrations = JSON.stringify(after.migrations ?? []);
|
||||||
if ((before.lastActivityAt ?? "") !== (after.lastActivityAt ?? "")) return true;
|
|
||||||
return beforeMigrations !== afterMigrations;
|
return beforeMigrations !== afterMigrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +148,6 @@ export async function updateWorkspaceMemoryWithAccounting(
|
|||||||
dropped: [],
|
dropped: [],
|
||||||
absorbed: [],
|
absorbed: [],
|
||||||
superseded: [],
|
superseded: [],
|
||||||
evidence: [],
|
|
||||||
events: [],
|
events: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -186,8 +163,7 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
root: string,
|
root: string,
|
||||||
store: WorkspaceMemoryStore,
|
store: WorkspaceMemoryStore,
|
||||||
): Promise<WorkspaceMemoryNormalizationResult> {
|
): Promise<WorkspaceMemoryNormalizationResult> {
|
||||||
const nowMs = Date.now();
|
const nowIso = new Date().toISOString();
|
||||||
const nowIso = new Date(nowMs).toISOString();
|
|
||||||
|
|
||||||
let result: WorkspaceMemoryStore = {
|
let result: WorkspaceMemoryStore = {
|
||||||
...store,
|
...store,
|
||||||
@@ -239,28 +215,18 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
result = runMigrationP0Cleanup(result, nowIso);
|
result = runMigrationP0Cleanup(result, nowIso);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs));
|
|
||||||
if (!result.migrations.includes(RETENTION_CLOCK_BACKFILL_MIGRATION_ID)) {
|
|
||||||
result = {
|
|
||||||
...result,
|
|
||||||
migrations: [...result.migrations, RETENTION_CLOCK_BACKFILL_MIGRATION_ID],
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// P0 accounting only considers active entries. Entries that were already
|
// P0 accounting only considers active entries. Entries that were already
|
||||||
// superseded before this normalization are preserved in storage; entries that
|
// superseded before this normalization are preserved in storage; entries that
|
||||||
// lose during this enforcement are reported via accounting events but are not
|
// lose during this enforcement are reported via accounting events but are not
|
||||||
// archived as superseded records in this wave.
|
// archived as superseded records in this wave.
|
||||||
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
|
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
|
||||||
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
|
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
|
||||||
const accounting = enforceLongTermLimitsWithAccounting(activeEntries, result);
|
const accounting = enforceLongTermLimitsWithAccounting(activeEntries);
|
||||||
|
|
||||||
const normalizedStore = {
|
const normalizedStore = {
|
||||||
...result,
|
...result,
|
||||||
entries: [...accounting.kept, ...supersededEntries],
|
entries: [...accounting.kept, ...supersededEntries],
|
||||||
updatedAt: nowIso,
|
updatedAt: nowIso,
|
||||||
lastActivityAt: nowIso,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -269,29 +235,10 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
dropped: accounting.dropped,
|
dropped: accounting.dropped,
|
||||||
absorbed: accounting.absorbed,
|
absorbed: accounting.absorbed,
|
||||||
superseded: accounting.superseded,
|
superseded: accounting.superseded,
|
||||||
evidence: accounting.evidence,
|
|
||||||
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function backfillRetentionClock(entry: LongTermMemoryEntry, nowMs: number): LongTermMemoryEntry {
|
|
||||||
if (Number.isFinite(entry.retentionClock)) {
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdAtMs = new Date(entry.createdAt).getTime();
|
|
||||||
if (Number.isFinite(createdAtMs)) {
|
|
||||||
return { ...entry, retentionClock: createdAtMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedAtMs = new Date(entry.updatedAt).getTime();
|
|
||||||
if (Number.isFinite(updatedAtMs)) {
|
|
||||||
return { ...entry, retentionClock: updatedAtMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...entry, retentionClock: nowMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runMigrationP0Cleanup(
|
export function runMigrationP0Cleanup(
|
||||||
store: WorkspaceMemoryStore,
|
store: WorkspaceMemoryStore,
|
||||||
nowIso: string,
|
nowIso: string,
|
||||||
@@ -500,29 +447,17 @@ function consolidationEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function capacityRemovalEvidence(
|
/** Check if entry should be pruned by age (for compaction/manual entries only) */
|
||||||
memory: LongTermMemoryEntry,
|
function isPrunableByAge(entry: LongTermMemoryEntry, now: number): boolean {
|
||||||
reason: "type_cap" | "global_cap" | "capacity",
|
// Never prune feedback or explicit entries
|
||||||
): EvidenceEventInput {
|
if (entry.type === "feedback") return false;
|
||||||
return {
|
if (entry.source === "explicit") return false;
|
||||||
type: "memory_removed_capacity",
|
if (!entry.staleAfterDays) return false;
|
||||||
phase: "storage",
|
|
||||||
outcome: "removed",
|
const createdAt = new Date(entry.createdAt).getTime();
|
||||||
reasonCodes: [reason],
|
const ageDays = (now - createdAt) / 86400000;
|
||||||
memory: memoryEvidenceRef(memory),
|
const grace = 30; // 30-day grace period
|
||||||
relations: [{
|
return ageDays > entry.staleAfterDays + grace;
|
||||||
role: "removed",
|
|
||||||
memory: memoryEvidenceRef(memory),
|
|
||||||
}],
|
|
||||||
details: {
|
|
||||||
type: memory.type,
|
|
||||||
globalCap: LONG_TERM_LIMITS.maxEntries,
|
|
||||||
...(reason === "type_cap" ? { typeCap: RETENTION_TYPE_MAX[memory.type] } : {}),
|
|
||||||
...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}),
|
|
||||||
...(memory.createdAt ? { createdAt: memory.createdAt } : {}),
|
|
||||||
...(memory.source ? { source: memory.source } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Choose better memory when identity/topic keys conflict */
|
/** Choose better memory when identity/topic keys conflict */
|
||||||
@@ -560,75 +495,40 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
|
|||||||
return enforceLongTermLimitsWithAccounting(entries).kept;
|
return enforceLongTermLimitsWithAccounting(entries).kept;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enforceLongTermLimitsWithAccounting(
|
export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
|
||||||
entries: LongTermMemoryEntry[],
|
|
||||||
store?: WorkspaceMemoryStore,
|
|
||||||
): LongTermLimitResult {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastActivityAt = store?.lastActivityAt;
|
const staleDropped: MemoryConsolidationEvent[] = [];
|
||||||
|
|
||||||
// Phase 1: filter active entries and trim text. Retention removal is by
|
// Phase 1: filter active, prune by age
|
||||||
// strength/cap competition, not hard stale pruning.
|
|
||||||
const phase1: LongTermMemoryEntry[] = [];
|
const phase1: LongTermMemoryEntry[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.status === "superseded") continue;
|
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) });
|
phase1.push({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
|
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
|
||||||
const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, lastActivityAt));
|
const sorted = [...dedupeResult.kept].sort(compareLongTermMemoryForRetention);
|
||||||
const capped = applyTypeMaxCaps(sorted);
|
const kept = sorted.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||||
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
|
|
||||||
const keptIds = new Set(kept.map(entry => entry.id));
|
const keptIds = new Set(kept.map(entry => entry.id));
|
||||||
const cappedIds = new Set(capped.map(entry => entry.id));
|
|
||||||
const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id));
|
|
||||||
const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id));
|
|
||||||
const capacityEvidence: EvidenceEventInput[] = [
|
|
||||||
...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap")),
|
|
||||||
...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap")),
|
|
||||||
];
|
|
||||||
const capacityDropped = sorted
|
const capacityDropped = sorted
|
||||||
.filter(entry => !keptIds.has(entry.id))
|
.filter(entry => !keptIds.has(entry.id))
|
||||||
.map(entry => consolidationEvent(entry, "rejected_capacity"));
|
.map(entry => consolidationEvent(entry, "rejected_capacity"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kept,
|
kept,
|
||||||
dropped: [...dedupeResult.dropped, ...capacityDropped],
|
dropped: [...staleDropped, ...dedupeResult.dropped, ...capacityDropped],
|
||||||
absorbed: dedupeResult.absorbed,
|
absorbed: dedupeResult.absorbed,
|
||||||
superseded: dedupeResult.superseded,
|
superseded: dedupeResult.superseded,
|
||||||
evidence: [...dedupeResult.evidence, ...capacityEvidence],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
|
||||||
return applyTypeMaxCapsWithOmissions(entries).kept;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTypeMaxCapsWithOmissions(entries: LongTermMemoryEntry[]): { kept: LongTermMemoryEntry[]; omitted: LongTermMemoryEntry[] } {
|
|
||||||
const capped: LongTermMemoryEntry[] = [];
|
|
||||||
const omitted: LongTermMemoryEntry[] = [];
|
|
||||||
const typeCounts: Partial<Record<LongTermMemoryEntry["type"], number>> = {};
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const count = typeCounts[entry.type] ?? 0;
|
|
||||||
const max = RETENTION_TYPE_MAX[entry.type] ?? Infinity;
|
|
||||||
if (count >= max) {
|
|
||||||
omitted.push(entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
capped.push(entry);
|
|
||||||
typeCounts[entry.type] = count + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { kept: capped, omitted };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
|
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
|
||||||
const now = Date.now();
|
|
||||||
const absorbed: MemoryConsolidationEvent[] = [];
|
const absorbed: MemoryConsolidationEvent[] = [];
|
||||||
const superseded: MemoryConsolidationEvent[] = [];
|
const superseded: MemoryConsolidationEvent[] = [];
|
||||||
const evidence: EvidenceEventInput[] = [];
|
|
||||||
|
|
||||||
// For project/reference/feedback: dedupe by concrete identity or exact canonical text.
|
// For project/reference/feedback: dedupe by concrete identity or exact canonical text.
|
||||||
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
||||||
@@ -647,16 +547,12 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
|||||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||||
? "absorbed_exact" as const
|
? "absorbed_exact" as const
|
||||||
: "absorbed_identity" as const;
|
: "absorbed_identity" as const;
|
||||||
const reinforced = reinforceMemory(
|
|
||||||
retained,
|
|
||||||
reinforcementSessionId(retained, dropped),
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
|
|
||||||
if (reinforcedEvent) evidence.push(reinforcedEvent);
|
|
||||||
|
|
||||||
absorbed.push(consolidationEvent(dropped, reason, reinforced));
|
absorbed.push(consolidationEvent(dropped, reason, retained));
|
||||||
entityDeduped.set(key, reinforced);
|
|
||||||
|
if (retained === entry) {
|
||||||
|
entityDeduped.set(key, entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,21 +571,16 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
|||||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||||
? "absorbed_exact" as const
|
? "absorbed_exact" as const
|
||||||
: "superseded_existing" as const;
|
: "superseded_existing" as const;
|
||||||
const reinforced = reinforceMemory(
|
|
||||||
retained,
|
|
||||||
reinforcementSessionId(retained, dropped),
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason);
|
|
||||||
if (reinforcedEvent) evidence.push(reinforcedEvent);
|
|
||||||
|
|
||||||
if (reason === "superseded_existing") {
|
if (reason === "superseded_existing") {
|
||||||
superseded.push(consolidationEvent(dropped, reason, reinforced));
|
superseded.push(consolidationEvent(dropped, reason, retained));
|
||||||
} else {
|
} else {
|
||||||
absorbed.push(consolidationEvent(dropped, reason, reinforced));
|
absorbed.push(consolidationEvent(dropped, reason, retained));
|
||||||
}
|
}
|
||||||
|
|
||||||
decisionDeduped.set(key, reinforced);
|
if (retained === entry) {
|
||||||
|
decisionDeduped.set(key, entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,57 +595,13 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
|||||||
dropped: [],
|
dropped: [],
|
||||||
absorbed,
|
absorbed,
|
||||||
superseded,
|
superseded,
|
||||||
evidence,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
|
function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMemoryEntry): number {
|
||||||
return {
|
const pA = priority(a);
|
||||||
memoryId: memory.id,
|
const pB = priority(b);
|
||||||
memoryKeyHash: workspaceMemoryExactKey(memory),
|
if (pB !== pA) return pB - pA;
|
||||||
identityKeyHash: workspaceMemoryIdentityKey(memory),
|
|
||||||
type: memory.type,
|
|
||||||
source: memory.source,
|
|
||||||
status: memory.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function reinforcementEvidence(
|
|
||||||
retained: LongTermMemoryEntry,
|
|
||||||
dropped: LongTermMemoryEntry,
|
|
||||||
reinforced: LongTermMemoryEntry,
|
|
||||||
reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing",
|
|
||||||
): EvidenceEventInput | undefined {
|
|
||||||
if ((reinforced.reinforcementCount ?? 0) <= (retained.reinforcementCount ?? 0)) return undefined;
|
|
||||||
const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact";
|
|
||||||
return {
|
|
||||||
type: "memory_reinforced",
|
|
||||||
phase: "reinforcement",
|
|
||||||
outcome: "reinforced",
|
|
||||||
memory: memoryEvidenceRef(reinforced),
|
|
||||||
relations: [
|
|
||||||
{ role: "reinforced", memory: memoryEvidenceRef(reinforced) },
|
|
||||||
{ role: "reinforced_by", memory: memoryEvidenceRef(dropped) },
|
|
||||||
],
|
|
||||||
reasonCodes: [duplicateReason, "reinforcement_window_allowed"],
|
|
||||||
textPreview: reinforced.text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
|
||||||
if (sourceDiff !== 0) return sourceDiff;
|
if (sourceDiff !== 0) return sourceDiff;
|
||||||
const createdDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
const createdDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
@@ -762,6 +609,18 @@ function compareLongTermMemoryForRetention(
|
|||||||
return a.id.localeCompare(b.id);
|
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(
|
function wouldFit(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
nextLine: string,
|
nextLine: string,
|
||||||
@@ -772,49 +631,20 @@ function wouldFit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
|
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
|
||||||
return accountWorkspaceMemoryRender(store).prompt;
|
const active = enforceLongTermLimits(store.entries);
|
||||||
}
|
if (active.length === 0) return "";
|
||||||
|
|
||||||
export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting {
|
|
||||||
const now = Date.now();
|
|
||||||
const maxChars = Math.min(
|
const maxChars = Math.min(
|
||||||
store.limits.maxRenderedChars,
|
store.limits.maxRenderedChars,
|
||||||
LONG_TERM_LIMITS.maxRenderedChars
|
LONG_TERM_LIMITS.maxRenderedChars
|
||||||
);
|
);
|
||||||
const omitted: WorkspaceMemoryRenderAccounting["omitted"] = [];
|
|
||||||
const evidence: EvidenceEventInput[] = [];
|
|
||||||
|
|
||||||
for (const entry of store.entries) {
|
|
||||||
if (entry.status === "superseded") {
|
|
||||||
omitted.push({ memory: entry, reason: "superseded" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeEntries = store.entries.filter(entry => entry.status !== "superseded");
|
|
||||||
const phase1 = activeEntries.map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) }));
|
|
||||||
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
|
|
||||||
const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, store.lastActivityAt));
|
|
||||||
const typeCapResult = applyTypeMaxCapsWithOmissions(sorted);
|
|
||||||
for (const memory of typeCapResult.omitted) omitted.push({ memory, reason: "type_cap" });
|
|
||||||
const active = typeCapResult.kept.slice(0, LONG_TERM_LIMITS.maxEntries);
|
|
||||||
for (const memory of typeCapResult.kept.slice(LONG_TERM_LIMITS.maxEntries)) omitted.push({ memory, reason: "global_cap" });
|
|
||||||
|
|
||||||
if (active.length === 0) {
|
|
||||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
|
||||||
return { rendered: [], omitted, evidence, prompt: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// If maxChars smaller than minimum envelope, return empty string
|
// If maxChars smaller than minimum envelope, return empty string
|
||||||
if (maxChars < MIN_ENVELOPE_LENGTH) {
|
if (maxChars < MIN_ENVELOPE_LENGTH) return "";
|
||||||
for (const memory of active) omitted.push({ memory, reason: "empty_render_budget" });
|
|
||||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
|
||||||
return { rendered: [], omitted, evidence, prompt: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
"Workspace memory (cross-session, verify if stale):",
|
"Workspace memory (cross-session, verify if stale):",
|
||||||
];
|
];
|
||||||
const rendered: LongTermMemoryEntry[] = [];
|
|
||||||
|
|
||||||
for (const type of ["feedback", "project", "decision", "reference"] as const) {
|
for (const type of ["feedback", "project", "decision", "reference"] as const) {
|
||||||
const items = active.filter(entry => entry.type === type);
|
const items = active.filter(entry => entry.type === type);
|
||||||
@@ -826,9 +656,6 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works
|
|||||||
const line = `- ${renderEntry(item)}`;
|
const line = `- ${renderEntry(item)}`;
|
||||||
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
|
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
|
||||||
sectionLines.push(line);
|
sectionLines.push(line);
|
||||||
rendered.push(item);
|
|
||||||
} else {
|
|
||||||
omitted.push({ memory: item, reason: "char_budget" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,26 +664,7 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered"));
|
return lines.join("\n");
|
||||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
|
||||||
|
|
||||||
return { rendered, omitted, evidence, prompt: lines.join("\n") };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEvidence(
|
|
||||||
memory: LongTermMemoryEntry,
|
|
||||||
outcome: "rendered" | "omitted",
|
|
||||||
reason?: WorkspaceMemoryRenderAccounting["omitted"][number]["reason"],
|
|
||||||
): EvidenceEventInput {
|
|
||||||
return {
|
|
||||||
type: outcome === "rendered" ? "render_selected" : "render_omitted",
|
|
||||||
phase: "render",
|
|
||||||
outcome,
|
|
||||||
memory: memoryEvidenceRef(memory),
|
|
||||||
relations: [{ role: outcome === "rendered" ? "rendered" : "omitted", memory: memoryEvidenceRef(memory) }],
|
|
||||||
reasonCodes: outcome === "rendered" ? ["within_caps", "within_char_budget"] : [reason ?? "char_budget"],
|
|
||||||
textPreview: memory.text,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEntry(entry: LongTermMemoryEntry): string {
|
function renderEntry(entry: LongTermMemoryEntry): string {
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { realpath } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import {
|
|
||||||
EVIDENCE_LOG_LIMITS,
|
|
||||||
appendEvidenceEvent,
|
|
||||||
appendEvidenceEvents,
|
|
||||||
queryEvidenceEvents,
|
|
||||||
summarizeMemoryEvidence,
|
|
||||||
traceMemoryLifecycle,
|
|
||||||
type EvidenceEventInput,
|
|
||||||
type EvidenceEventV1,
|
|
||||||
} from "../src/evidence-log.ts";
|
|
||||||
import { workspaceEvidenceLogPath, workspaceKey } from "../src/paths.ts";
|
|
||||||
|
|
||||||
async function tempRoot(): Promise<string> {
|
|
||||||
return mkdtemp(join(tmpdir(), "opencode-evidence-log-"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventInput(overrides: Partial<EvidenceEventInput> = {}): EvidenceEventInput {
|
|
||||||
return {
|
|
||||||
type: "promotion_promoted",
|
|
||||||
phase: "promotion",
|
|
||||||
outcome: "promoted",
|
|
||||||
reasonCodes: ["new_workspace_entry"],
|
|
||||||
memory: { memoryId: "mem-a", type: "decision", source: "explicit", status: "active" },
|
|
||||||
textPreview: "Use npm test before release",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readLog(root: string): Promise<string> {
|
|
||||||
return readFile(await workspaceEvidenceLogPath(root), "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function privacyHash(value: string): string {
|
|
||||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function workspaceRootHash(root: string): Promise<string> {
|
|
||||||
const resolved = await realpath(root).catch(() => root);
|
|
||||||
return privacyHash(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
function manualEvent(rootKey: string, rootHash: string, id: string, createdAt: string): EvidenceEventV1 {
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
eventId: id,
|
|
||||||
createdAt,
|
|
||||||
workspaceKey: rootKey,
|
|
||||||
workspaceRootHash: rootHash,
|
|
||||||
type: "render_selected",
|
|
||||||
phase: "render",
|
|
||||||
outcome: "rendered",
|
|
||||||
memory: { memoryId: id, type: "decision" },
|
|
||||||
reasonCodes: ["within_caps", "within_char_budget"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("appendEvidenceEvent writes versioned JSONL with workspace hashes", async () => {
|
|
||||||
const root = await tempRoot();
|
|
||||||
try {
|
|
||||||
const event = await appendEvidenceEvent(root, eventInput({ sessionHash: "session-1", messageHash: "message-1" }));
|
|
||||||
const raw = await readLog(root);
|
|
||||||
const stored = JSON.parse(raw.trim()) as EvidenceEventV1;
|
|
||||||
|
|
||||||
assert.equal(stored.version, 1);
|
|
||||||
assert.match(stored.eventId, /^evt_\d+_[a-z0-9]{8}$/);
|
|
||||||
assert.equal(stored.eventId, event.eventId);
|
|
||||||
assert.equal(stored.workspaceKey, await workspaceKey(root));
|
|
||||||
assert.equal(stored.workspaceRootHash, await workspaceRootHash(root));
|
|
||||||
assert.equal(stored.sessionHash, privacyHash("session-1"));
|
|
||||||
assert.equal(stored.messageHash, privacyHash("message-1"));
|
|
||||||
assert.equal(stored.type, "promotion_promoted");
|
|
||||||
assert.equal(stored.outcome, "promoted");
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("appendEvidenceEvent redacts text previews before writing", async () => {
|
|
||||||
const root = await tempRoot();
|
|
||||||
try {
|
|
||||||
await appendEvidenceEvent(root, eventInput({
|
|
||||||
type: "extraction_candidate_rejected",
|
|
||||||
phase: "extraction",
|
|
||||||
outcome: "rejected",
|
|
||||||
reasonCodes: ["raw_error"],
|
|
||||||
textPreview: "password: sushi\nAdmin PIN 是 456123\nBearer abc.def.ghi\nThis candidate is rejected and should be short",
|
|
||||||
details: {
|
|
||||||
note: "password: sushi Admin PIN 是 456123 Bearer abc.def.ghi",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const raw = await readLog(root);
|
|
||||||
const stored = JSON.parse(raw.trim()) as EvidenceEventV1;
|
|
||||||
assert.ok(!raw.includes("sushi"));
|
|
||||||
assert.ok(!raw.includes("456123"));
|
|
||||||
assert.ok(!raw.includes("abc.def.ghi"));
|
|
||||||
assert.ok(stored.textPreview?.includes("[REDACTED]"));
|
|
||||||
assert.ok((stored.textPreview?.length ?? 0) <= 80);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("queryEvidenceEvents filters by type outcome and memory id", async () => {
|
|
||||||
const root = await tempRoot();
|
|
||||||
try {
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
eventInput({ type: "promotion_promoted", outcome: "promoted", memory: { memoryId: "mem-a" } }),
|
|
||||||
eventInput({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["type_cap"], memory: { memoryId: "mem-a" } }),
|
|
||||||
eventInput({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["global_cap"], memory: { memoryId: "mem-b" } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await queryEvidenceEvents(root, {
|
|
||||||
types: ["render_omitted"],
|
|
||||||
outcomes: ["omitted"],
|
|
||||||
memoryId: "mem-a",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.length, 1);
|
|
||||||
assert.equal(result[0].type, "render_omitted");
|
|
||||||
assert.equal(result[0].memory?.memoryId, "mem-a");
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("memory_removed_capacity event round-trips through append and query", async () => {
|
|
||||||
const root = await tempRoot();
|
|
||||||
try {
|
|
||||||
await appendEvidenceEvent(root, eventInput({
|
|
||||||
type: "memory_removed_capacity",
|
|
||||||
phase: "storage",
|
|
||||||
outcome: "removed",
|
|
||||||
reasonCodes: ["global_cap"],
|
|
||||||
memory: { memoryId: "removed-memory", type: "reference", source: "compaction", status: "active" },
|
|
||||||
relations: [{ role: "removed", memory: { memoryId: "removed-memory", type: "reference", source: "compaction", status: "active" } }],
|
|
||||||
details: {
|
|
||||||
type: "reference",
|
|
||||||
globalCap: 28,
|
|
||||||
retentionClock: Date.UTC(2026, 4, 1),
|
|
||||||
createdAt: "2026-05-01T00:00:00.000Z",
|
|
||||||
source: "compaction",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = await queryEvidenceEvents(root, {
|
|
||||||
types: ["memory_removed_capacity"],
|
|
||||||
phases: ["storage"],
|
|
||||||
outcomes: ["removed"],
|
|
||||||
memoryId: "removed-memory",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.length, 1);
|
|
||||||
assert.equal(result[0].type, "memory_removed_capacity");
|
|
||||||
assert.equal(result[0].phase, "storage");
|
|
||||||
assert.equal(result[0].outcome, "removed");
|
|
||||||
assert.deepEqual(result[0].details, {
|
|
||||||
type: "reference",
|
|
||||||
globalCap: 28,
|
|
||||||
retentionClock: Date.UTC(2026, 4, 1),
|
|
||||||
createdAt: "2026-05-01T00:00:00.000Z",
|
|
||||||
source: "compaction",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("queryEvidenceEvents supports newestFirst and limit", async () => {
|
|
||||||
const root = await tempRoot();
|
|
||||||
try {
|
|
||||||
const events = await appendEvidenceEvents(root, [
|
|
||||||
eventInput({ memory: { memoryId: "oldest" } }),
|
|
||||||
eventInput({ memory: { memoryId: "middle" } }),
|
|
||||||
eventInput({ memory: { memoryId: "newest" } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await queryEvidenceEvents(root, { newestFirst: true, limit: 2 });
|
|
||||||
|
|
||||||
assert.deepEqual(result.map(event => event.eventId), [events[2].eventId, events[1].eventId]);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("appendEvidenceEvent returns a record when appendFile fails", async () => {
|
|
||||||
const root = await tempRoot();
|
|
||||||
try {
|
|
||||||
const path = await workspaceEvidenceLogPath(root);
|
|
||||||
await mkdir(path, { recursive: true });
|
|
||||||
|
|
||||||
const event = await appendEvidenceEvent(root, eventInput());
|
|
||||||
|
|
||||||
assert.equal(event.version, 1);
|
|
||||||
assert.match(event.eventId, /^evt_\d+_[a-z0-9]{8}$/);
|
|
||||||
assert.equal(event.workspaceKey, await workspaceKey(root));
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("evidence log pruning drops old events, caps count, and quarantines invalid lines", async () => {
|
|
||||||
const root = await tempRoot();
|
|
||||||
try {
|
|
||||||
const path = await workspaceEvidenceLogPath(root);
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
const rootKey = await workspaceKey(root);
|
|
||||||
const rootHash = await workspaceRootHash(root);
|
|
||||||
const old = manualEvent(rootKey, rootHash, "old-event", new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString());
|
|
||||||
const recentEvents = Array.from({ length: EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace + 1 }, (_, i) =>
|
|
||||||
manualEvent(rootKey, rootHash, `recent-${i}`, new Date(Date.now() - (EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace - i) * 1000).toISOString())
|
|
||||||
);
|
|
||||||
await writeFile(path, [
|
|
||||||
JSON.stringify(old),
|
|
||||||
"{not valid json",
|
|
||||||
...recentEvents.map(event => JSON.stringify(event)),
|
|
||||||
].join("\n") + "\n", "utf8");
|
|
||||||
|
|
||||||
const appended = await appendEvidenceEvents(root, Array.from({ length: EVIDENCE_LOG_LIMITS.pruneEveryAppendCount }, (_, i) =>
|
|
||||||
eventInput({ memory: { memoryId: `appended-${i}` }, reasonCodes: ["new_workspace_entry"] })
|
|
||||||
));
|
|
||||||
|
|
||||||
const events = await queryEvidenceEvents(root);
|
|
||||||
const memoryIds = new Set(events.map(event => event.memory?.memoryId));
|
|
||||||
const files = await readdir(dirname(path));
|
|
||||||
|
|
||||||
assert.ok(events.length <= EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace);
|
|
||||||
assert.equal(memoryIds.has("old-event"), false);
|
|
||||||
assert.equal(memoryIds.has("recent-0"), false, "oldest events over the count cap should be pruned");
|
|
||||||
assert.equal(memoryIds.has(appended.at(-1)?.memory?.memoryId), true);
|
|
||||||
assert.ok(files.some(file => file.startsWith("events.jsonl.corrupt-lines-")));
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("memory evidence summary and lifecycle trace derive latest status", async () => {
|
|
||||||
const root = await tempRoot();
|
|
||||||
try {
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
eventInput({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", reasonCodes: ["quality_gate_passed"], memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }),
|
|
||||||
eventInput({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", reasonCodes: ["new_workspace_entry"], memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }),
|
|
||||||
eventInput({ type: "memory_reinforced", phase: "reinforcement", outcome: "reinforced", reasonCodes: ["duplicate_exact"], relations: [{ role: "reinforced", memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }] }),
|
|
||||||
eventInput({ type: "render_selected", phase: "render", outcome: "rendered", reasonCodes: ["within_caps", "within_char_budget"], memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const summary = await summarizeMemoryEvidence(root, { memoryId: "mem-life" });
|
|
||||||
const trace = await traceMemoryLifecycle(root, { memoryId: "mem-life" });
|
|
||||||
|
|
||||||
assert.equal(summary.latestOutcome, "rendered");
|
|
||||||
assert.equal(summary.latestRenderStatus, "rendered");
|
|
||||||
assert.ok(summary.reasonCodes.includes("duplicate_exact"));
|
|
||||||
assert.equal(trace.currentStatus, "rendered");
|
|
||||||
assert.ok(trace.acceptedBy);
|
|
||||||
assert.ok(trace.promotedBy);
|
|
||||||
assert.equal(trace.reinforcedBy.length, 1);
|
|
||||||
assert.equal(trace.latestRender?.type, "render_selected");
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("evidence relation roles reject sublimation placeholders at compile-time surface", () => {
|
|
||||||
const allowedRoles: Array<NonNullable<EvidenceEventInput["relations"]>[number]["role"]> = [
|
|
||||||
"candidate",
|
|
||||||
"pending",
|
|
||||||
"promoted",
|
|
||||||
"retained",
|
|
||||||
"absorbed",
|
|
||||||
"superseded",
|
|
||||||
"superseded_by",
|
|
||||||
"reinforced",
|
|
||||||
"reinforced_by",
|
|
||||||
"rendered",
|
|
||||||
"omitted",
|
|
||||||
"removed",
|
|
||||||
];
|
|
||||||
|
|
||||||
assert.equal(allowedRoles.includes("candidate"), true);
|
|
||||||
});
|
|
||||||
@@ -3,13 +3,7 @@ import assert from "node:assert/strict";
|
|||||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import { extractErrorsFromBash, extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||||
extractErrorsFromBash,
|
|
||||||
extractExplicitMemories,
|
|
||||||
extractExplicitMemoriesWithEvidence,
|
|
||||||
parseWorkspaceMemoryCandidates,
|
|
||||||
parseWorkspaceMemoryCandidatesWithEvidence,
|
|
||||||
} from "../src/extractors.ts";
|
|
||||||
|
|
||||||
async function waitForFile(path: string, attempts = 20): Promise<string> {
|
async function waitForFile(path: string, attempts = 20): Promise<string> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
@@ -97,13 +91,9 @@ test("extractExplicitMemories does not treat always as memory trigger", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("extractExplicitMemories still captures going forward", () => {
|
test("extractExplicitMemories still captures going forward", () => {
|
||||||
const before = Date.now();
|
|
||||||
const items = extractExplicitMemories("going forward: use pnpm instead of npm");
|
const items = extractExplicitMemories("going forward: use pnpm instead of npm");
|
||||||
const after = Date.now();
|
|
||||||
assert.equal(items.length, 1);
|
assert.equal(items.length, 1);
|
||||||
assert.match(items[0].text, /pnpm/);
|
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", () => {
|
test("extractExplicitMemories captures from now on", () => {
|
||||||
@@ -151,20 +141,6 @@ test("extractExplicitMemories captures multiple memories in same message", () =>
|
|||||||
assert.equal(items.length, 2);
|
assert.equal(items.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("explicit memory extraction returns detected and ignored evidence", () => {
|
|
||||||
const result = extractExplicitMemoriesWithEvidence([
|
|
||||||
"remember this: Prefer deterministic tests.",
|
|
||||||
"don't remember this: temporary password: sushi",
|
|
||||||
"remember this: later",
|
|
||||||
].join("\n"));
|
|
||||||
|
|
||||||
assert.equal(result.entries.length, 1);
|
|
||||||
assert.ok(result.evidence.some(event => event.type === "explicit_memory_detected"));
|
|
||||||
assert.ok(result.evidence.some(event => event.type === "explicit_memory_ignored" && event.reasonCodes.includes("negated_request")));
|
|
||||||
assert.ok(result.evidence.some(event => event.type === "explicit_memory_ignored" && event.reasonCodes.includes("deferral")));
|
|
||||||
assert.equal(JSON.stringify(result.evidence).includes("sushi"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Task 7: Compaction quality gate tests
|
// Task 7: Compaction quality gate tests
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -196,41 +172,6 @@ test("parseWorkspaceMemoryCandidates rejects raw error", () => {
|
|||||||
assert.equal(items.length, 0);
|
assert.equal(items.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("compaction accepted candidate returns privacy-safe extraction evidence", () => {
|
|
||||||
const summary = `
|
|
||||||
Memory candidates:
|
|
||||||
- [decision] Use accounting evidence events to explain promoted memories in diagnostics.
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
|
|
||||||
|
|
||||||
assert.equal(result.entries.length, 1);
|
|
||||||
assert.equal(result.evidence.length, 1);
|
|
||||||
assert.equal(result.evidence[0].type, "extraction_candidate_accepted");
|
|
||||||
assert.ok(result.evidence[0].reasonCodes.includes("quality_gate_passed"));
|
|
||||||
assert.ok(result.evidence[0].reasonCodes.includes("valid_candidate_format"));
|
|
||||||
assert.match(result.evidence[0].textPreview ?? "", /accounting evidence events/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compaction rejected candidate returns rejection evidence without secrets", () => {
|
|
||||||
const summary = `
|
|
||||||
Memory candidates:
|
|
||||||
- [feedback] password: sushi Admin PIN 是 456123 Bearer abc.def.ghi TypeError: Cannot read property x
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary);
|
|
||||||
const raw = JSON.stringify(result.evidence);
|
|
||||||
|
|
||||||
assert.equal(result.entries.length, 0);
|
|
||||||
assert.equal(result.evidence.length, 1);
|
|
||||||
assert.equal(result.evidence[0].type, "extraction_candidate_rejected");
|
|
||||||
assert.ok(result.evidence[0].reasonCodes.length > 0);
|
|
||||||
assert.equal(raw.includes("sushi"), false);
|
|
||||||
assert.equal(raw.includes("456123"), false);
|
|
||||||
assert.equal(raw.includes("abc.def.ghi"), false);
|
|
||||||
assert.ok((result.evidence[0].textPreview?.length ?? 0) <= 80);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
|
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
|
||||||
const summary = `
|
const summary = `
|
||||||
## Memory Candidates
|
## Memory Candidates
|
||||||
@@ -259,18 +200,14 @@ test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
|
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
|
||||||
const before = Date.now();
|
|
||||||
const summary = `
|
const summary = `
|
||||||
## Memory Candidates
|
## Memory Candidates
|
||||||
- [decision] Use pnpm instead of npm for package management
|
- [decision] Use pnpm instead of npm for package management
|
||||||
`;
|
`;
|
||||||
const items = parseWorkspaceMemoryCandidates(summary);
|
const items = parseWorkspaceMemoryCandidates(summary);
|
||||||
const after = Date.now();
|
|
||||||
assert.equal(items.length, 1);
|
assert.equal(items.length, 1);
|
||||||
assert.equal(items[0].type, "decision");
|
assert.equal(items[0].type, "decision");
|
||||||
assert.match(items[0].text, /pnpm/);
|
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", () => {
|
test("parseWorkspaceMemoryCandidates accepts valid project info", () => {
|
||||||
@@ -387,36 +324,6 @@ Memory candidates:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("new rejection records include workspaceKey and workspaceRootHash when provided", async () => {
|
|
||||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-scope-data-"));
|
|
||||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
|
||||||
process.env.XDG_DATA_HOME = dataHome;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const summary = `
|
|
||||||
Memory candidates:
|
|
||||||
- feedback Wave 1 completed successfully and all tests passed
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = parseWorkspaceMemoryCandidatesWithEvidence(summary, {
|
|
||||||
workspaceKey: "testkey1234567",
|
|
||||||
workspaceRootHash: "abcdef123456",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.entries.length, 0);
|
|
||||||
const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl");
|
|
||||||
const lines = (await waitForFile(logPath)).trim().split("\n");
|
|
||||||
assert.equal(lines.length, 1);
|
|
||||||
const event = JSON.parse(lines[0]);
|
|
||||||
assert.equal(event.workspaceKey, "testkey1234567");
|
|
||||||
assert.equal(event.workspaceRootHash, "abcdef123456");
|
|
||||||
} finally {
|
|
||||||
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
||||||
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
|
||||||
await rm(dataHome, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseWorkspaceMemoryCandidates redacts secrets in extraction rejection log", async () => {
|
test("parseWorkspaceMemoryCandidates redacts secrets in extraction rejection log", async () => {
|
||||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-"));
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-"));
|
||||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
|||||||
@@ -1,523 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { appendEvidenceEvents, type EvidenceEventInput } from "../src/evidence-log.ts";
|
|
||||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
|
||||||
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS, type PendingMemoryJournalStore } from "../src/types.ts";
|
|
||||||
import { extractionRejectionLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } 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> {
|
|
||||||
return runMemoryDiag(["health", "--workspace", root]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runMemoryDiag(args: string[]): Promise<string> {
|
|
||||||
const { stdout } = await execFileAsync(process.execPath, [
|
|
||||||
"--experimental-strip-types",
|
|
||||||
"scripts/memory-diag.ts",
|
|
||||||
...args,
|
|
||||||
], { cwd: repoRoot });
|
|
||||||
|
|
||||||
return stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writePendingJournal(root: string, entries: LongTermMemoryEntry[]): Promise<void> {
|
|
||||||
const key = await workspaceKey(root);
|
|
||||||
const path = await workspacePendingJournalPath(root);
|
|
||||||
const store: PendingMemoryJournalStore = {
|
|
||||||
version: 1,
|
|
||||||
workspace: { root, key },
|
|
||||||
entries,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(path, JSON.stringify(store, null, 2), "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeRejectionRecords(records: unknown[]): Promise<void> {
|
|
||||||
const path = extractionRejectionLogPath();
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(path, records.map(record => JSON.stringify(record)).join("\n") + "\n", "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function evidence(overrides: Partial<EvidenceEventInput>): EvidenceEventInput {
|
|
||||||
return {
|
|
||||||
type: "promotion_promoted",
|
|
||||||
phase: "promotion",
|
|
||||||
outcome: "promoted",
|
|
||||||
memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" },
|
|
||||||
reasonCodes: ["new_workspace_entry"],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("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 deprecations", 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 .*deprecated.* WARNING/);
|
|
||||||
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 \(deprecated field\)\n/);
|
|
||||||
assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("memory health --json prints parseable privacy-safe diagnostics matching human counts", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-json-"));
|
|
||||||
try {
|
|
||||||
const rendered = { ...entry("mem-rendered", "Prefer small focused changes", "feedback"), source: "explicit" as const };
|
|
||||||
const secret = { ...entry("mem-secret", "Use password: sushi only in test fixtures", "decision"), source: "manual" as const };
|
|
||||||
const superseded = { ...entry("mem-old", "Old decision that was superseded", "decision"), status: "superseded" as const };
|
|
||||||
const pending = { ...entry("mem-pending", "Retry this pending memory later", "project"), promotionAttempts: 1 };
|
|
||||||
await writeWorkspaceStore(root, [rendered, secret, superseded]);
|
|
||||||
await writePendingJournal(root, [pending]);
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
evidence({ type: "extraction_candidate_rejected", phase: "extraction", outcome: "rejected", memory: { memoryId: "mem-rejected", type: "feedback", source: "explicit" }, reasonCodes: ["raw_secret"], textPreview: "password: sushi should not leak" }),
|
|
||||||
evidence({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined", memory: undefined, reasonCodes: ["invalid_json"] }),
|
|
||||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["new_workspace_entry"] }),
|
|
||||||
evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["within_caps", "within_char_budget"] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const human = await runMemoryDiagHealth(root);
|
|
||||||
const jsonText = await runMemoryDiag(["health", "--workspace", root, "--json"]);
|
|
||||||
const parsed = JSON.parse(jsonText) as {
|
|
||||||
version: 1;
|
|
||||||
summary: { storedActive: number; rendered: number; pending: number; rejectedLast7Days: number; corruptStoresQuarantinedLast30Days: number };
|
|
||||||
memories: Array<{ id: string; status: string; reasonCodes: string[]; evidenceEventIds: string[]; textPreview?: string }>;
|
|
||||||
recentEvents: Array<{ eventId: string; type: string; outcome: string; createdAt: string; reasonCodes: string[] }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(parsed.version, 1);
|
|
||||||
assert.equal(parsed.summary.storedActive, Number(human.match(/Stored active memories: (\d+)/)?.[1]));
|
|
||||||
assert.equal(parsed.summary.rendered, Number(human.match(/Rendered candidates: (\d+)/)?.[1]));
|
|
||||||
assert.equal(parsed.summary.pending, Number(human.match(/Pending journal:\n\s+total: (\d+)/)?.[1]));
|
|
||||||
assert.equal(parsed.summary.rejectedLast7Days, 1);
|
|
||||||
assert.equal(parsed.summary.corruptStoresQuarantinedLast30Days, 1);
|
|
||||||
assert.ok(parsed.recentEvents.some(event => event.eventId && event.type === "render_selected" && event.outcome === "rendered" && event.createdAt && event.reasonCodes.includes("within_caps")));
|
|
||||||
assert.ok(parsed.memories.find(memory => memory.id === "mem-rendered")?.evidenceEventIds.length);
|
|
||||||
assert.ok(!jsonText.includes("sushi"));
|
|
||||||
assert.ok(jsonText.trim().startsWith("{"));
|
|
||||||
assert.ok(jsonText.trim().endsWith("}"));
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("memory-diag explain shows rendered, omitted, pending, and evidence reason status", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-explain-"));
|
|
||||||
try {
|
|
||||||
const rendered = { ...entry("mem-rendered", "Rendered feedback wins the render set", "feedback"), source: "explicit" as const };
|
|
||||||
const superseded = { ...entry("mem-superseded", "Superseded memory is not rendered", "decision"), status: "superseded" as const };
|
|
||||||
const typeCapped = Array.from({ length: 11 }, (_, i) => entry(`mem-type-${i}`, `Type cap feedback memory ${i}`, "feedback"));
|
|
||||||
const globalCapped = [
|
|
||||||
...Array.from({ length: 10 }, (_, i) => entry(`mem-g-feedback-${i}`, `Global cap feedback memory ${i}`, "feedback")),
|
|
||||||
...Array.from({ length: 10 }, (_, i) => entry(`mem-g-decision-${i}`, `Global cap decision memory ${i}`, "decision")),
|
|
||||||
...Array.from({ length: 8 }, (_, i) => entry(`mem-g-project-${i}`, `Global cap project memory ${i}`, "project")),
|
|
||||||
...Array.from({ length: 7 }, (_, i) => entry(`mem-g-reference-${i}`, `Global cap reference memory ${i}`, "reference")),
|
|
||||||
];
|
|
||||||
const charBudget = { ...entry("mem-char-budget", "This active memory cannot fit the tiny character budget", "project") };
|
|
||||||
await writeWorkspaceStore(root, [rendered, superseded, ...typeCapped, ...globalCapped, charBudget]);
|
|
||||||
const key = await workspaceKey(root);
|
|
||||||
const path = await workspaceMemoryPath(root);
|
|
||||||
const raw = JSON.parse(await readFile(path, "utf8")) as WorkspaceMemoryStore;
|
|
||||||
raw.limits.maxRenderedChars = 100;
|
|
||||||
raw.workspace = { root, key };
|
|
||||||
await writeFile(path, JSON.stringify(raw, null, 2), "utf8");
|
|
||||||
|
|
||||||
const retry = { ...entry("mem-pending-retry", "Pending retry memory", "project"), promotionAttempts: 1, lastPromotionFailureReason: "capacity_rejected" };
|
|
||||||
const exhausted = { ...entry("mem-pending-capacity", "Pending capacity rejected memory", "project"), promotionAttempts: PROMOTION_RETRY_LIMITS.maxExplicitAttempts, lastPromotionFailureReason: "capacity_rejected" };
|
|
||||||
await writePendingJournal(root, [retry, exhausted]);
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
evidence({ type: "promotion_absorbed_exact", phase: "promotion", outcome: "absorbed", memory: { memoryId: "mem-absorbed", type: "feedback", source: "explicit" }, reasonCodes: ["same_exact_key"] }),
|
|
||||||
evidence({ type: "storage_corrupt_json_quarantined", phase: "storage", outcome: "quarantined", memory: undefined, reasonCodes: ["invalid_json"] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stdout = await runMemoryDiag(["explain", "--workspace", root]);
|
|
||||||
|
|
||||||
assert.match(stdout, /Memory mem-rendered: rendered/);
|
|
||||||
assert.match(stdout, /Memory mem-superseded: omitted_superseded/);
|
|
||||||
assert.match(stdout, /omitted_type_cap/);
|
|
||||||
assert.match(stdout, /omitted_global_cap/);
|
|
||||||
assert.match(stdout, /omitted_char_budget/);
|
|
||||||
assert.match(stdout, /Memory mem-pending-retry: pending_retry/);
|
|
||||||
assert.match(stdout, /Memory mem-pending-capacity: pending_rejected_capacity/);
|
|
||||||
assert.match(stdout, /Memory mem-absorbed: omitted_absorbed_duplicate/);
|
|
||||||
assert.match(stdout, /quarantined_corrupt_store/);
|
|
||||||
assert.match(stdout, /- strength=\d+\.\d{3}, type=feedback, source=explicit/);
|
|
||||||
assert.match(stdout, /- evidence: .*promotion_absorbed_exact/);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("memory-diag trace prints lifecycle relations and redacts secrets", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-"));
|
|
||||||
try {
|
|
||||||
await writeWorkspaceStore(root, [
|
|
||||||
{ ...entry("mem-life", "Old token password: sushi should be redacted", "decision"), status: "superseded" as const },
|
|
||||||
entry("mem-new", "Replacement memory", "decision"),
|
|
||||||
]);
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
evidence({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, reasonCodes: ["quality_gate_passed"], textPreview: "password: sushi" }),
|
|
||||||
evidence({ type: "pending_memory_appended", phase: "pending_journal", outcome: "accepted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "pending", memory: { memoryId: "mem-life" } }], reasonCodes: ["pending_journal_append"] }),
|
|
||||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
|
||||||
evidence({ type: "memory_reinforced", phase: "reinforcement", outcome: "reinforced", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "reinforced_by", memory: { memoryId: "mem-duplicate" } }], reasonCodes: ["duplicate_exact"] }),
|
|
||||||
evidence({ type: "promotion_superseded", phase: "promotion", outcome: "superseded", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "superseded_by", memory: { memoryId: "mem-new" } }], reasonCodes: ["superseded_existing"] }),
|
|
||||||
evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "omitted", memory: { memoryId: "mem-life" } }], reasonCodes: ["superseded"] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "mem-life"]);
|
|
||||||
|
|
||||||
assert.match(stdout, /Memory mem-life: omitted_superseded/);
|
|
||||||
assert.match(stdout, /Lifecycle:/);
|
|
||||||
assert.match(stdout, /extraction_candidate_accepted: accepted; reasons=quality_gate_passed/);
|
|
||||||
assert.match(stdout, /pending_memory_appended: accepted; reasons=pending_journal_append/);
|
|
||||||
assert.match(stdout, /promotion_superseded: superseded; reasons=superseded_existing; .*superseded_by=mem-new/);
|
|
||||||
assert.match(stdout, /memory_reinforced: reinforced; reasons=duplicate_exact; .*reinforced_by=mem-duplicate/);
|
|
||||||
assert.match(stdout, /Superseded by:\n- mem-new/);
|
|
||||||
assert.match(stdout, /Reinforced by:\n- mem-duplicate/);
|
|
||||||
assert.ok(!stdout.includes("sushi"));
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("memory-diag trace requires --memory and reports unknown IDs", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-"));
|
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
execFileAsync(process.execPath, ["--experimental-strip-types", "scripts/memory-diag.ts", "trace", "--workspace", root], { cwd: repoRoot }),
|
|
||||||
(error: unknown) => {
|
|
||||||
const err = error as { code?: number; stderr?: string };
|
|
||||||
assert.notEqual(err.code, 0);
|
|
||||||
assert.match(err.stderr ?? "", /--memory requires an id/);
|
|
||||||
assert.match(err.stderr ?? "", /Usage:/);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "missing-memory"]);
|
|
||||||
assert.match(stdout, /Memory missing-memory: unknown/);
|
|
||||||
assert.match(stdout, /Lifecycle:\n\(none\)/);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function setupQualityFixture(): Promise<string> {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-quality-"));
|
|
||||||
const key = await workspaceKey(root);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries }, (_, i) => ({
|
|
||||||
...entry(`quality-${i}`, `Quality fixture memory ${i}`, i % 2 === 0 ? "feedback" as const : "decision" as const),
|
|
||||||
retentionClock: i === 0 ? -1 : Date.now(),
|
|
||||||
}));
|
|
||||||
await writeWorkspaceStore(root, entries);
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "quality-1", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
|
||||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "quality-historical", type: "reference", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
|
||||||
]);
|
|
||||||
await writeRejectionRecords([
|
|
||||||
{ timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] },
|
|
||||||
{ timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] },
|
|
||||||
]);
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("quality human output includes summary and aggregate inspection counts", async () => {
|
|
||||||
const root = await setupQualityFixture();
|
|
||||||
try {
|
|
||||||
const stdout = await runMemoryDiag(["quality", "--workspace", root]);
|
|
||||||
|
|
||||||
assert.match(stdout, /Memory quality inspection/);
|
|
||||||
assert.match(stdout, /Summary: Workspace memory quality is degraded:/);
|
|
||||||
assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/);
|
|
||||||
assert.match(stdout, /decision: 14 \/ 10 FULL/);
|
|
||||||
assert.match(stdout, /feedback: 14 \/ 10 FULL/);
|
|
||||||
assert.match(stdout, /Retention clocks:\n\s+present: 27\n\s+missing: 0\n\s+invalid: 1/);
|
|
||||||
assert.match(stdout, /Evidence:\n\s+current with evidence: 1\n\s+current without evidence: 27/);
|
|
||||||
assert.match(stdout, /Rejection scoping:\n\s+total records: 2\n\s+workspace scoped: 1\n\s+legacy unscoped: 1/);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("quality --json includes summaryText, caps, retention, evidence, and rejections", async () => {
|
|
||||||
const root = await setupQualityFixture();
|
|
||||||
try {
|
|
||||||
const stdout = await runMemoryDiag(["quality", "--workspace", root, "--json"]);
|
|
||||||
const parsed = JSON.parse(stdout) as {
|
|
||||||
summaryText: string;
|
|
||||||
caps: { active: number; capsFull: boolean };
|
|
||||||
retention: { invalid: number };
|
|
||||||
evidence: { currentWithEvidence: number; unknownDisappearances: number };
|
|
||||||
rejections: { workspaceScopedCount: number; legacyUnscopedCount: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.match(parsed.summaryText, /Workspace memory quality is degraded/);
|
|
||||||
assert.equal(parsed.caps.active, LONG_TERM_LIMITS.maxEntries);
|
|
||||||
assert.equal(parsed.caps.capsFull, true);
|
|
||||||
assert.equal(parsed.retention.invalid, 1);
|
|
||||||
assert.equal(parsed.evidence.currentWithEvidence, 1);
|
|
||||||
assert.equal(parsed.evidence.unknownDisappearances, 1);
|
|
||||||
assert.equal(parsed.rejections.workspaceScopedCount, 1);
|
|
||||||
assert.equal(parsed.rejections.legacyUnscopedCount, 1);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coverage human output includes class counts and per-memory rows", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-coverage-"));
|
|
||||||
try {
|
|
||||||
await writeWorkspaceStore(root, [
|
|
||||||
entry("mem-full", "Full lifecycle memory", "feedback"),
|
|
||||||
entry("mem-render-only", "Render only memory", "decision"),
|
|
||||||
entry("mem-no-evidence", "No evidence memory", "project"),
|
|
||||||
]);
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
evidence({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" }, reasonCodes: ["quality_gate_passed"] }),
|
|
||||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
|
||||||
evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-render-only", type: "decision", source: "compaction" }, reasonCodes: ["within_caps"] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stdout = await runMemoryDiag(["coverage", "--workspace", root]);
|
|
||||||
|
|
||||||
assert.match(stdout, /Class counts:/);
|
|
||||||
assert.match(stdout, /full_lifecycle: 1/);
|
|
||||||
assert.match(stdout, /render_only: 1/);
|
|
||||||
assert.match(stdout, /no_evidence: 1/);
|
|
||||||
assert.match(stdout, /Per-memory rows:/);
|
|
||||||
assert.match(stdout, /mem-full full_lifecycle .*extraction=1 promotion=1/);
|
|
||||||
assert.match(stdout, /mem-render-only render_only .*render=1/);
|
|
||||||
assert.match(stdout, /mem-no-evidence no_evidence .*total=0/);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coverage --json includes event counts", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-coverage-json-"));
|
|
||||||
try {
|
|
||||||
await writeWorkspaceStore(root, [entry("mem-full", "Full lifecycle memory", "feedback")]);
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "mem-full", type: "feedback", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stdout = await runMemoryDiag(["coverage", "--workspace", root, "--json"]);
|
|
||||||
const parsed = JSON.parse(stdout) as { memories: Array<{ id: string; eventCounts: { total: number; byType: Record<string, number> } }> };
|
|
||||||
const row = parsed.memories.find(memory => memory.id === "mem-full");
|
|
||||||
|
|
||||||
assert.equal(row?.eventCounts.total, 1);
|
|
||||||
assert.equal(row?.eventCounts.byType.promotion_promoted, 1);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("disappearances labels historical evidence-only memory unknown without terminal event", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-"));
|
|
||||||
try {
|
|
||||||
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stdout = await runMemoryDiag(["disappearances", "--workspace", root]);
|
|
||||||
|
|
||||||
assert.match(stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("disappearances --explain shows capacity details and render omitted type-cap observations", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-explain-"));
|
|
||||||
try {
|
|
||||||
await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]);
|
|
||||||
await appendEvidenceEvents(root, [
|
|
||||||
evidence({
|
|
||||||
type: "memory_removed_capacity",
|
|
||||||
phase: "storage",
|
|
||||||
outcome: "removed",
|
|
||||||
memory: { memoryId: "capacity-loser", type: "decision", source: "compaction", status: "active" },
|
|
||||||
relations: [{ role: "removed", memory: { memoryId: "capacity-loser", type: "decision", source: "compaction", status: "active" } }],
|
|
||||||
reasonCodes: ["type_cap"],
|
|
||||||
details: { type: "decision", globalCap: 28, typeCap: 10, source: "compaction" },
|
|
||||||
}),
|
|
||||||
evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "render-loser", type: "feedback", source: "compaction" }, reasonCodes: ["type_cap"] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stdout = await runMemoryDiag(["disappearances", "--workspace", root, "--explain"]);
|
|
||||||
|
|
||||||
assert.match(stdout, /capacity-loser: historical_absent_with_reason terminal=memory_removed_capacity reasons=type_cap/);
|
|
||||||
assert.match(stdout, /memory_removed_capacity details: .*globalCap=28 .*typeCap=10/);
|
|
||||||
assert.match(stdout, /render-loser: historical_absent_with_reason terminal=render_omitted reasons=type_cap/);
|
|
||||||
assert.match(stdout, /render_omitted type-cap observation: reasons=type_cap/);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejections --quality --reason bad_decision --unique groups architecture-like samples heuristically", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-quality-"));
|
|
||||||
try {
|
|
||||||
const key = await workspaceKey(root);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
await writeRejectionRecords([
|
|
||||||
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] },
|
|
||||||
{ timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] },
|
|
||||||
{ timestamp: now, type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--reason", "bad_decision", "--unique"]);
|
|
||||||
|
|
||||||
assert.match(stdout, /Possible false-positive grouping is heuristic, not deterministic truth/);
|
|
||||||
assert.match(stdout, /architecture_like_possible_false_positive: 1/);
|
|
||||||
assert.match(stdout, /clearly_garbage: 1/);
|
|
||||||
assert.match(stdout, /ambiguous: 1/);
|
|
||||||
assert.doesNotMatch(stdout, /deterministic truth\s*:/);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejections --quality --json includes scoping, unique reasons, and possible false-positive groups", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-json-"));
|
|
||||||
try {
|
|
||||||
const key = await workspaceKey(root);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
await writeRejectionRecords([
|
|
||||||
{ timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] },
|
|
||||||
{ timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--json"]);
|
|
||||||
const parsed = JSON.parse(stdout) as {
|
|
||||||
workspaceScopedCount: number;
|
|
||||||
legacyUnscopedCount: number;
|
|
||||||
uniqueReasonDistribution: Record<string, number>;
|
|
||||||
possibleFalsePositiveGroups: Record<string, { count: number }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(parsed.workspaceScopedCount, 1);
|
|
||||||
assert.equal(parsed.legacyUnscopedCount, 1);
|
|
||||||
assert.equal(parsed.uniqueReasonDistribution.bad_decision, 1);
|
|
||||||
assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -136,33 +136,6 @@ test("decision must be future-facing rule, not completed implementation note", (
|
|||||||
assert.equal(assessMemoryQuality({ type: "decision", text: "Added semantic merge tests in the previous wave", source: "compaction" }).accepted, false);
|
assert.equal(assessMemoryQuality({ type: "decision", text: "Added semantic merge tests in the previous wave", source: "compaction" }).accepted, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bad_decision 3-tier gate: architecture-like decisions accepted without future-rule imperative", () => {
|
|
||||||
const architectureLikeCases = [
|
|
||||||
{ text: "Rule 不在記憶系統 schema 內,歸用戶(agent.md / claude.md),系統最多到 Preference + Suggestion", type: "decision" as const },
|
|
||||||
{ text: "Ghost memory root cause: normalization 把 capacity losers 從 store 移除時沒有 emit terminal evidence", type: "decision" as const },
|
|
||||||
{ text: "BASE_HALF_LIFE_DAYS 應從 60 降低到 45", type: "decision" as const },
|
|
||||||
{ text: "採用 decay-rate 模型取代 priority+penalty 模型", type: "decision" as const },
|
|
||||||
{ text: "從 scoring 移除 confidence,目前是固定值無意義", type: "decision" as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
const stillRejectedCases = [
|
|
||||||
{ text: "Implemented phase 2 and updated tests", type: "decision" as const },
|
|
||||||
{ text: "Implemented CI_SCHEMA_UPDATE for compatibility run 42", type: "decision" as const },
|
|
||||||
{ text: "Session reviewed the architecture model changes", type: "decision" as const },
|
|
||||||
{ text: "Some random text with no architecture keywords or future rules", type: "decision" as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const entry of architectureLikeCases) {
|
|
||||||
const result = assessMemoryQuality({ ...entry, source: "compaction" });
|
|
||||||
assert.equal(result.reasons.includes("bad_decision"), false, `${entry.text} -> ${result.reasons.join(",")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of stillRejectedCases) {
|
|
||||||
const result = assessMemoryQuality({ ...entry, source: "compaction" });
|
|
||||||
assert.equal(result.reasons.includes("bad_decision"), true, `${entry.text} -> ${result.reasons.join(",")}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shared quality gate owns extractor low-quality syntax rejections", () => {
|
test("shared quality gate owns extractor low-quality syntax rejections", () => {
|
||||||
const rejected = [
|
const rejected = [
|
||||||
{ type: "project" as const, text: "fix: add new feature" },
|
{ type: "project" as const, text: "fix: add new feature" },
|
||||||
|
|||||||
+10
-305
@@ -1,6 +1,6 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { MemoryV2Plugin } from "../src/plugin.ts";
|
import { MemoryV2Plugin } from "../src/plugin.ts";
|
||||||
@@ -8,10 +8,9 @@ import { loadSessionState, saveSessionState } from "../src/session-state.ts";
|
|||||||
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||||
import type { OpenError } from "../src/types.ts";
|
import type { OpenError } from "../src/types.ts";
|
||||||
import { PROMOTION_RETRY_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "../src/types.ts";
|
import { PROMOTION_RETRY_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "../src/types.ts";
|
||||||
import { sessionStatePath, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
import { workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||||
import { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts";
|
import { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts";
|
||||||
import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts";
|
import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts";
|
||||||
import { queryEvidenceEvents } from "../src/evidence-log.ts";
|
|
||||||
|
|
||||||
// Mock client for root session (not a sub-agent)
|
// Mock client for root session (not a sub-agent)
|
||||||
function mockRootClient() {
|
function mockRootClient() {
|
||||||
@@ -451,62 +450,6 @@ test("chat system transform reuses frozen rendered workspace snapshot", async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("chat system transform degrades gracefully when workspace memory JSON is corrupt", async () => {
|
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const path = await workspaceMemoryPath(tmpDir);
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(path, "{ invalid json", "utf8");
|
|
||||||
|
|
||||||
const plugin = await MemoryV2Plugin({
|
|
||||||
directory: tmpDir,
|
|
||||||
client: mockRootClient(),
|
|
||||||
});
|
|
||||||
const output = { system: ["base header"] };
|
|
||||||
|
|
||||||
await assert.doesNotReject(async () => {
|
|
||||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
|
||||||
{ sessionID: "corrupt-json-session", model: {} },
|
|
||||||
output,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(output.system, ["base header"]);
|
|
||||||
} finally {
|
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hook failure emits hook_failed evidence without raw tool output", async () => {
|
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statePath = await sessionStatePath(tmpDir, "hook-failure-session");
|
|
||||||
await mkdir(dirname(statePath), { recursive: true });
|
|
||||||
await mkdir(statePath, { recursive: true });
|
|
||||||
|
|
||||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
|
||||||
await (plugin as Record<string, Function>)["tool.execute.after"](
|
|
||||||
{
|
|
||||||
tool: "bash",
|
|
||||||
sessionID: "hook-failure-session",
|
|
||||||
args: { command: "cat secret-output.txt" },
|
|
||||||
},
|
|
||||||
{ output: "raw tool output password: sushi should not be in evidence", exitCode: 1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const events = await queryEvidenceEvents(tmpDir, { types: ["hook_failed"] });
|
|
||||||
const raw = JSON.stringify(events);
|
|
||||||
assert.equal(events.length, 1);
|
|
||||||
assert.equal(events[0].reasonCodes.includes("tool.execute.after"), true);
|
|
||||||
assert.equal(raw.includes("raw tool output"), false);
|
|
||||||
assert.equal(raw.includes("sushi"), false);
|
|
||||||
} finally {
|
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no compaction: owned explicit memory is not promoted by unrelated next session start", async () => {
|
test("no compaction: owned explicit memory is not promoted by unrelated next session start", async () => {
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||||
|
|
||||||
@@ -829,69 +772,6 @@ test("session.compacted promotes pending memories to workspace memory and clears
|
|||||||
|
|
||||||
const workspacePrompt = after.system.find((part: string) => part.startsWith("Workspace memory"));
|
const workspacePrompt = after.system.find((part: string) => part.startsWith("Workspace memory"));
|
||||||
assert.match(workspacePrompt ?? "", /Use frozen rendered snapshots for cache stability/);
|
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 {
|
} finally {
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -933,85 +813,6 @@ test("integration: explicit memory flows from user message through pending journ
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("explicit memory lifecycle emits detected appended and promoted evidence", async () => {
|
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const plugin = await MemoryV2Plugin({
|
|
||||||
directory: tmpDir,
|
|
||||||
client: mockClientWithLatestUser("remember this: Prefer evidence-backed lifecycle tests.", "msg-evidence-life"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
|
||||||
{ sessionID: "evidence-life-session", model: {} },
|
|
||||||
{ system: ["base header"] },
|
|
||||||
);
|
|
||||||
|
|
||||||
await (plugin as Record<string, Function>)["event"]({
|
|
||||||
event: { type: "session.compacted", properties: { sessionID: "evidence-life-session" } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = await queryEvidenceEvents(tmpDir, { newestFirst: false });
|
|
||||||
const eventTypes = events.map(event => event.type);
|
|
||||||
|
|
||||||
assert.ok(eventTypes.includes("explicit_memory_detected"));
|
|
||||||
assert.ok(eventTypes.includes("pending_memory_appended"));
|
|
||||||
assert.ok(eventTypes.includes("promotion_promoted"));
|
|
||||||
|
|
||||||
const output = { system: ["base header"] };
|
|
||||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
|
||||||
{ sessionID: "evidence-life-session", model: {} },
|
|
||||||
output,
|
|
||||||
);
|
|
||||||
|
|
||||||
const finalEvents = await queryEvidenceEvents(tmpDir, { newestFirst: false });
|
|
||||||
assert.ok(finalEvents.map(event => event.type).includes("render_selected"));
|
|
||||||
} finally {
|
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("session.compacted promotes first-time explicit memory without self-reinforcement", async () => {
|
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const plugin = await MemoryV2Plugin({
|
|
||||||
directory: tmpDir,
|
|
||||||
client: mockClientWithLatestUser(
|
|
||||||
"remember this: Prefer no self reinforcement during first promotion.",
|
|
||||||
"msg-no-self-reinforce",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
|
|
||||||
{ sessionID: "no-self-reinforce-session", model: {} },
|
|
||||||
{ system: ["base header"] },
|
|
||||||
);
|
|
||||||
|
|
||||||
await (plugin as Record<string, Function>)["event"]({
|
|
||||||
event: {
|
|
||||||
type: "session.compacted",
|
|
||||||
properties: { sessionID: "no-self-reinforce-session" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const workspace = await loadWorkspaceMemory(tmpDir);
|
|
||||||
const promoted = workspace.entries.find(entry =>
|
|
||||||
/Prefer no self reinforcement/.test(entry.text)
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(promoted, "explicit memory should be promoted");
|
|
||||||
assert.equal(promoted.reinforcementCount ?? 0, 0);
|
|
||||||
assert.equal(promoted.lastReinforcedSessionID, undefined);
|
|
||||||
|
|
||||||
const state = await loadSessionState(tmpDir, "no-self-reinforce-session");
|
|
||||||
assert.equal(state.pendingMemories.length, 0);
|
|
||||||
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0);
|
|
||||||
} finally {
|
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("integration: compaction candidate flows through journal promotion and clears pending journal", async () => {
|
test("integration: compaction candidate flows through journal promotion and clears pending journal", async () => {
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||||
|
|
||||||
@@ -1621,9 +1422,9 @@ test("session.compacted clears compaction pending memory rejected by workspace e
|
|||||||
try {
|
try {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await updateWorkspaceMemory(tmpDir, store => {
|
await updateWorkspaceMemory(tmpDir, store => {
|
||||||
for (let i = 0; i < 10; i += 1) {
|
for (let i = 0; i < 28; i += 1) {
|
||||||
store.entries.push({
|
store.entries.push({
|
||||||
id: `mem_high_feedback_${i}`,
|
id: `mem_high_${i}`,
|
||||||
type: "feedback",
|
type: "feedback",
|
||||||
text: `High priority user feedback memory ${i} that should outrank low priority references.`,
|
text: `High priority user feedback memory ${i} that should outrank low priority references.`,
|
||||||
source: "explicit",
|
source: "explicit",
|
||||||
@@ -1633,30 +1434,6 @@ test("session.compacted clears compaction pending memory rejected by workspace e
|
|||||||
updatedAt: now,
|
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;
|
return store;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1699,9 +1476,9 @@ test("session.compacted keeps explicit pending memory rejected by workspace entr
|
|||||||
try {
|
try {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await updateWorkspaceMemory(tmpDir, store => {
|
await updateWorkspaceMemory(tmpDir, store => {
|
||||||
for (let i = 0; i < 10; i += 1) {
|
for (let i = 0; i < 28; i += 1) {
|
||||||
store.entries.push({
|
store.entries.push({
|
||||||
id: `mem_high_explicit_reject_feedback_${i}`,
|
id: `mem_high_explicit_reject_${i}`,
|
||||||
type: "feedback",
|
type: "feedback",
|
||||||
text: `Pinned high priority feedback for explicit reject ${i}.`,
|
text: `Pinned high priority feedback for explicit reject ${i}.`,
|
||||||
source: "explicit",
|
source: "explicit",
|
||||||
@@ -1711,30 +1488,6 @@ test("session.compacted keeps explicit pending memory rejected by workspace entr
|
|||||||
updatedAt: now,
|
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;
|
return store;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1778,9 +1531,9 @@ test("explicit capacity rejection records bounded retry metadata", async () => {
|
|||||||
try {
|
try {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await updateWorkspaceMemory(tmpDir, store => {
|
await updateWorkspaceMemory(tmpDir, store => {
|
||||||
for (let i = 0; i < 10; i += 1) {
|
for (let i = 0; i < 28; i += 1) {
|
||||||
store.entries.push({
|
store.entries.push({
|
||||||
id: `mem_high_bounded_reject_feedback_${i}`,
|
id: `mem_high_bounded_reject_${i}`,
|
||||||
type: "feedback",
|
type: "feedback",
|
||||||
text: `Pinned high priority feedback for bounded rejection ${i}.`,
|
text: `Pinned high priority feedback for bounded rejection ${i}.`,
|
||||||
source: "explicit",
|
source: "explicit",
|
||||||
@@ -1790,30 +1543,6 @@ test("explicit capacity rejection records bounded retry metadata", async () => {
|
|||||||
updatedAt: now,
|
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;
|
return store;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1885,9 +1614,9 @@ test("session.compacted clears compaction pending memories when all rejected by
|
|||||||
try {
|
try {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await updateWorkspaceMemory(tmpDir, store => {
|
await updateWorkspaceMemory(tmpDir, store => {
|
||||||
for (let i = 0; i < 10; i += 1) {
|
for (let i = 0; i < 28; i += 1) {
|
||||||
store.entries.push({
|
store.entries.push({
|
||||||
id: `mem_high_all_rejected_feedback_${i}`,
|
id: `mem_high_all_rejected_${i}`,
|
||||||
type: "feedback",
|
type: "feedback",
|
||||||
text: `Pinned high priority feedback ${i} that keeps the workspace entry cap full.`,
|
text: `Pinned high priority feedback ${i} that keeps the workspace entry cap full.`,
|
||||||
source: "explicit",
|
source: "explicit",
|
||||||
@@ -1897,30 +1626,6 @@ test("session.compacted clears compaction pending memories when all rejected by
|
|||||||
updatedAt: now,
|
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;
|
return store;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import type { LongTermMemoryEntry } from "../src/types.ts";
|
import type { LongTermMemoryEntry } from "../src/types.ts";
|
||||||
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "../src/promotion-accounting.ts";
|
import { accountPendingPromotions } from "../src/promotion-accounting.ts";
|
||||||
import { memoryKey } from "../src/pending-journal.ts";
|
import { memoryKey } from "../src/pending-journal.ts";
|
||||||
import type { MemoryConsolidationEvent } from "../src/workspace-memory.ts";
|
import type { MemoryConsolidationEvent } from "../src/workspace-memory.ts";
|
||||||
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
|
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
|
||||||
@@ -230,56 +230,19 @@ test("accountPendingPromotions marks manual capacity rejection as retryable", ()
|
|||||||
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
|
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("promotionAccountingEvidenceEvents maps every promotion outcome with relations", () => {
|
test("accountPendingPromotions clears compaction stale rejection from accounting", () => {
|
||||||
const promoted = mem("promoted", "Promoted memory should produce evidence.", { source: "explicit" });
|
const pending = [mem("pending_stale", "Stale compaction reference should be terminal.", {
|
||||||
const absorbed = mem("absorbed", "Absorbed memory should produce evidence.", { source: "explicit" });
|
type: "reference",
|
||||||
const retained = mem("retained", "absorbed memory should produce evidence.", { source: "explicit" });
|
source: "compaction",
|
||||||
const identityAbsorbed = mem("identity-absorbed", "Project config lives in `src/config.ts`", { type: "reference" });
|
})];
|
||||||
const identityRetained = mem("identity-retained", "Project config lives in `./src/config.ts`", { type: "reference" });
|
|
||||||
const superseded = mem("superseded", "Parser supports 3 formats.", { source: "compaction" });
|
|
||||||
const replacement = mem("replacement", "Parser supports 4 formats.", { source: "compaction" });
|
|
||||||
const capacity = mem("capacity", "Capacity rejected explicit memory should retry.", { source: "explicit", type: "reference" });
|
|
||||||
const exhausted = mem("exhausted", "Exhausted explicit memory should stop retrying.", { source: "explicit", type: "reference" });
|
|
||||||
const pending = [promoted, absorbed, identityAbsorbed, superseded, capacity, exhausted];
|
|
||||||
const accounting = {
|
|
||||||
promotedKeys: new Set([memoryKey(promoted)]),
|
|
||||||
absorbedKeys: new Set([memoryKey(absorbed), memoryKey(identityAbsorbed)]),
|
|
||||||
supersededKeys: new Set([memoryKey(superseded)]),
|
|
||||||
rejectedKeys: new Set([memoryKey(capacity), memoryKey(exhausted)]),
|
|
||||||
retryableRejectedKeys: new Set([memoryKey(capacity), memoryKey(exhausted)]),
|
|
||||||
clearableKeys: new Set([memoryKey(promoted), memoryKey(absorbed), memoryKey(identityAbsorbed), memoryKey(superseded), memoryKey(exhausted)]),
|
|
||||||
};
|
|
||||||
const events = [
|
|
||||||
{ ...event(absorbed, "absorbed_exact"), retainedId: retained.id },
|
|
||||||
{ ...event(identityAbsorbed, "absorbed_identity"), retainedId: identityRetained.id },
|
|
||||||
{ ...event(superseded, "superseded_existing"), retainedId: replacement.id, supersededId: superseded.id },
|
|
||||||
event(capacity, "rejected_capacity"),
|
|
||||||
event(exhausted, "rejected_capacity"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const evidence = promotionAccountingEvidenceEvents({
|
const result = accountPendingPromotions({
|
||||||
pending,
|
pending,
|
||||||
after: [promoted, retained, identityRetained, replacement],
|
before: [],
|
||||||
events,
|
after: [],
|
||||||
accounting,
|
events: [event(pending[0], "rejected_stale")],
|
||||||
exhaustedRejectedKeys: new Set([memoryKey(exhausted)]),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedPromotionEventTypes = new Set([
|
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
|
||||||
"promotion_promoted",
|
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||||
"promotion_absorbed_exact",
|
|
||||||
"promotion_absorbed_identity",
|
|
||||||
"promotion_superseded",
|
|
||||||
"promotion_rejected_capacity",
|
|
||||||
"promotion_retry_scheduled",
|
|
||||||
"promotion_retry_exhausted",
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.deepEqual(new Set(evidence.map(event => event.type)), expectedPromotionEventTypes);
|
|
||||||
const absorbedEvent = evidence.find(event => event.type === "promotion_absorbed_exact");
|
|
||||||
assert.ok(absorbedEvent?.relations?.some(relation => relation.role === "absorbed" && relation.memory?.memoryId === absorbed.id));
|
|
||||||
assert.ok(absorbedEvent?.relations?.some(relation => relation.role === "retained" && relation.memory?.memoryId === retained.id));
|
|
||||||
const supersededEvent = evidence.find(event => event.type === "promotion_superseded");
|
|
||||||
assert.ok(supersededEvent?.relations?.some(relation => relation.role === "superseded" && relation.memory?.memoryId === superseded.id));
|
|
||||||
assert.ok(supersededEvent?.relations?.some(relation => relation.role === "superseded_by" && relation.memory?.memoryId === replacement.id));
|
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-124
@@ -1,13 +1,11 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { dirname, join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { readJSON, updateJSON } from "../src/storage.ts";
|
import { updateJSON } from "../src/storage.ts";
|
||||||
import { queryEvidenceEvents } from "../src/evidence-log.ts";
|
|
||||||
import { workspaceMemoryPath } from "../src/paths.ts";
|
|
||||||
|
|
||||||
test("updateJSON serializes concurrent increments", async () => {
|
test("updateJSON serializes concurrent increments", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-"));
|
const root = await mkdtemp(join(tmpdir(), "wm-storage-"));
|
||||||
@@ -39,44 +37,6 @@ test("updateJSON does not replace corrupt JSON with fallback", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("readJSON quarantines corrupt JSON and returns fallback", async () => {
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), "wm-storage-corrupt-"));
|
|
||||||
const path = join(dir, "store.json");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await writeFile(path, "{ invalid json", "utf8");
|
|
||||||
|
|
||||||
const loaded = await readJSON(path, () => ({ ok: true }));
|
|
||||||
|
|
||||||
assert.deepEqual(loaded, { ok: true });
|
|
||||||
|
|
||||||
const files = await readdir(dir);
|
|
||||||
assert.equal(files.includes("store.json"), false);
|
|
||||||
assert.equal(files.some(file => file.startsWith("store.json.corrupt-")), true);
|
|
||||||
} finally {
|
|
||||||
await rm(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("readJSON emits corrupt JSON quarantine evidence for workspace stores", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-corrupt-"));
|
|
||||||
try {
|
|
||||||
const path = await workspaceMemoryPath(root);
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(path, "{ invalid json", "utf8");
|
|
||||||
|
|
||||||
const loaded = await readJSON(path, () => ({ ok: true }));
|
|
||||||
const events = await queryEvidenceEvents(root, { types: ["storage_corrupt_json_quarantined"] });
|
|
||||||
|
|
||||||
assert.deepEqual(loaded, { ok: true });
|
|
||||||
assert.equal(events.length, 1);
|
|
||||||
assert.equal(events[0].reasonCodes.includes("invalid_json"), true);
|
|
||||||
assert.equal(JSON.stringify(events).includes("invalid json"), false);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updateJSON recovers stale lock files left by crashed process", async () => {
|
test("updateJSON recovers stale lock files left by crashed process", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-stale-lock-"));
|
const root = await mkdtemp(join(tmpdir(), "wm-storage-stale-lock-"));
|
||||||
try {
|
try {
|
||||||
@@ -92,47 +52,6 @@ test("updateJSON recovers stale lock files left by crashed process", async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("updateJSON emits stale lock recovery evidence for workspace stores", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-stale-lock-"));
|
|
||||||
try {
|
|
||||||
const path = await workspaceMemoryPath(root);
|
|
||||||
const lockPath = `${path}.lock`;
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(lockPath, `999999\n0\n`, "utf8");
|
|
||||||
|
|
||||||
await updateJSON(path, () => ({ count: 0 }), current => ({ count: current.count + 1 }));
|
|
||||||
const events = await queryEvidenceEvents(root, { types: ["storage_stale_lock_recovered"] });
|
|
||||||
|
|
||||||
assert.equal(events.length, 1);
|
|
||||||
assert.equal(events[0].reasonCodes.includes("stale_lock"), true);
|
|
||||||
assert.equal(JSON.stringify(events).includes("999999"), false);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updateJSON emits lock timeout evidence and still throws", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-timeout-"));
|
|
||||||
try {
|
|
||||||
const path = await workspaceMemoryPath(root);
|
|
||||||
const lockPath = `${path}.lock`;
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(lockPath, `${process.pid}\n${Date.now()}\n`, "utf8");
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
updateJSON(path, () => ({ count: 0 }), current => current),
|
|
||||||
/Timed out waiting for lock/,
|
|
||||||
);
|
|
||||||
const events = await queryEvidenceEvents(root, { types: ["storage_lock_timeout"] });
|
|
||||||
|
|
||||||
assert.equal(events.length, 1);
|
|
||||||
assert.equal(events[0].reasonCodes.includes("lock_wait_timeout"), true);
|
|
||||||
assert.equal(JSON.stringify(events).includes(String(process.pid)), false);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updateJSON serializes writes across separate node processes", async () => {
|
test("updateJSON serializes writes across separate node processes", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-xproc-"));
|
const root = await mkdtemp(join(tmpdir(), "wm-storage-xproc-"));
|
||||||
try {
|
try {
|
||||||
@@ -162,43 +81,3 @@ test("updateJSON serializes writes across separate node processes", async () =>
|
|||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("updateJSON waits for a live cross-process lock and preserves both updates", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "wm-storage-live-lock-"));
|
|
||||||
try {
|
|
||||||
const path = join(root, "counter.json");
|
|
||||||
const worker = `
|
|
||||||
import { updateJSON } from ${JSON.stringify(new URL("../src/storage.ts", import.meta.url).href)};
|
|
||||||
const path = process.argv[1];
|
|
||||||
await updateJSON(path, () => ({ count: 0, order: [] }), async current => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 250));
|
|
||||||
return { count: current.count + 1, order: [...current.order, "child"] };
|
|
||||||
});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const child = spawn(
|
|
||||||
process.execPath,
|
|
||||||
["--experimental-strip-types", "--input-type=module", "-e", worker, path],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
await updateJSON(path, () => ({ count: 0, order: [] as string[] }), current => ({
|
|
||||||
count: current.count + 1,
|
|
||||||
order: [...current.order, "parent"],
|
|
||||||
}));
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`child exited ${code}`)));
|
|
||||||
child.on("error", reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
const final = await updateJSON(path, () => ({ count: 0, order: [] as string[] }), current => current);
|
|
||||||
assert.equal(final.count, 2);
|
|
||||||
assert.deepEqual(new Set(final.order), new Set(["child", "parent"]));
|
|
||||||
assert.equal(existsSync(`${path}.lock`), false);
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
+45
-871
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user