mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c6e143f4b | |||
| 73384ca0a4 | |||
| 04233f8452 | |||
| ffb612226c | |||
| 4097815f3e | |||
| bb7e4e2927 | |||
| 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 | |||
| fd8d730e3b | |||
| 4309cb855f | |||
| 2437a9dc71 | |||
| 3560868f52 | |||
| e7c7a5cfb2 | |||
| 026c75a5e4 | |||
| eb74a9f03e | |||
| f6f35e87c1 | |||
| 6603fe869d | |||
| 3d44269228 | |||
| a154139b27 | |||
| 7527765207 | |||
| f9acfd6136 | |||
| ca71c20a8f | |||
| 5e9ada6859 | |||
| 721544e7a8 | |||
| 32fa2bd454 | |||
| af539a42f3 | |||
| eff0d3784c | |||
| 2354b62350 | |||
| 92e90124de |
@@ -0,0 +1,32 @@
|
||||
name: compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
|
||||
jobs:
|
||||
locked:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
|
||||
opencode-latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npm install --no-save @opencode-ai/plugin@latest
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
@@ -48,3 +48,10 @@ pnpm-lock.yaml
|
||||
.opencode/
|
||||
.opencode-agenthub/
|
||||
.opencode-agenthub.user.json
|
||||
|
||||
# Superpowers local planning artifacts
|
||||
docs/superpowers/plans/
|
||||
|
||||
# Local dev/admin script inputs
|
||||
scripts/dev/run-migration-roots.local.txt
|
||||
scripts/dev/dry-run-roots.local.txt
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# AGENTS.md - OpenCode Working Memory Plugin Development Guide
|
||||
# AGENTS.md - OpenCode Working Memory Development Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
The **OpenCode Working Memory Plugin** provides a **three-layer memory architecture** for AI agents:
|
||||
**OpenCode Working Memory** provides a **three-layer memory architecture** for AI agents:
|
||||
|
||||
1. **Workspace Memory** - Long-term memory that persists across sessions (decisions, project info, references)
|
||||
2. **Hot Session State** - Automatic tracking of active files, open errors, and recent decisions
|
||||
@@ -111,7 +111,7 @@ export type LongTermSource = "explicit" | "compaction" | "manual";
|
||||
|
||||
// ✅ USE: const assertions for limits
|
||||
export const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 5200,
|
||||
maxRenderedChars: 3600,
|
||||
maxEntries: 28,
|
||||
} as const;
|
||||
```
|
||||
@@ -140,8 +140,8 @@ const maxEntries = 28;
|
||||
async function loadWorkspaceMemory() { }
|
||||
|
||||
// ✅ REQUIRED: SCREAMING_SNAKE_CASE for constants
|
||||
const LONG_TERM_LIMITS = { maxRenderedChars: 5200, maxEntries: 28 };
|
||||
const HOT_STATE_LIMITS = { maxRenderedChars: 1200 };
|
||||
const LONG_TERM_LIMITS = { maxRenderedChars: 3600, maxEntries: 28 };
|
||||
const HOT_STATE_LIMITS = { maxRenderedChars: 700 };
|
||||
|
||||
// ✅ REQUIRED: PascalCase for types
|
||||
type WorkspaceMemoryStore = { ... };
|
||||
@@ -236,7 +236,7 @@ export default {
|
||||
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
|
||||
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
|
||||
- **Schema**: See `src/types.ts:WorkspaceMemoryStore`
|
||||
- **Limits**: 5200 chars, 28 entries max
|
||||
- **Limits**: 3600 chars, 28 entries max
|
||||
|
||||
### Session State Files
|
||||
|
||||
@@ -299,9 +299,9 @@ Extracts workspace memory candidates from conversation, applies quality gate and
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Workspace memory budget**: 5200 chars injected into system prompt
|
||||
- **Session state budget**: 1200 chars injected into system prompt
|
||||
- **Total overhead**: ~1500-6000 chars per message (minimal)
|
||||
- **Workspace memory budget**: 3600 chars injected into system prompt
|
||||
- **Session state budget**: 700 chars injected into system prompt
|
||||
- **Total overhead**: typically well below configured maximums
|
||||
- **Storage footprint**: ~2-5 KB per workspace for memory, ~1-3 KB per session
|
||||
|
||||
## Contributing
|
||||
@@ -325,4 +325,4 @@ See `docs/architecture.md` for detailed technical documentation including:
|
||||
---
|
||||
|
||||
**Last Updated**: April 2026
|
||||
**Plugin Status**: Production (Memory V2 architecture)
|
||||
**Plugin Status**: Production (Memory V2 architecture)
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.5.0] - 2026-04-29
|
||||
|
||||
### Added
|
||||
|
||||
- Strength-based workspace memory retention using exponential decay instead of additive priority scoring.
|
||||
- Per-type rendered caps for workspace memory candidates: feedback 10, decision 10, project 8, and reference 6.
|
||||
- Safety-critical memory weighting and type-cap exemption so important entries survive type floods while still competing under the global rendered cap.
|
||||
- Dormant-workspace effective age: after 14 days without activity, additional dormant time counts at 0.25x for retention decay.
|
||||
- Reinforcement tracking for repeated memories, with same-session and one-hour guards to prevent accidental reinforcement spam.
|
||||
- Memory health diagnostics for stored vs rendered counts, type caps, global cap overflow, dormancy, retention monitoring, and strength-ranked top/weakest entries.
|
||||
- CLI smoke tests and regression fixtures covering retention decay, stale-prune removal, type caps, reinforcement, invalid timestamps, and diagnostics.
|
||||
|
||||
### Changed
|
||||
|
||||
- Workspace memory rendering now ranks entries by retention strength, not the previous priority/penalty model.
|
||||
- Confidence is retained for compatibility but no longer affects retention scoring.
|
||||
- Old or stale-marked memories are no longer hard-pruned; they remain stored and only fall out of rendered context through strength and cap competition.
|
||||
- Existing duplicate promotion and dedupe paths now reinforce the surviving memory instead of only absorbing the duplicate.
|
||||
- Health output now separates stored active memories from rendered candidates to make cap behavior easier to understand.
|
||||
- Default prompt budgets are lower after calibration against observed rendered output: workspace memory is 3600 characters and hot session state is 700 characters.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Invalid `updatedAt` or `retentionClock` values no longer produce `NaN` retention strength or unstable sorting.
|
||||
- Dormant age calculation only discounts the dormant overlap since an entry was created, so new memories do not inherit old workspace dormancy.
|
||||
- Type max totals above the global cap are handled correctly: the global rendered limit still wins.
|
||||
|
||||
### Not Included Yet
|
||||
|
||||
- Delete tombstones and explicit `supersedes` chain enforcement remain deferred follow-up work.
|
||||
- Hot/warm/cold tiered storage remains a future v1.6 direction.
|
||||
|
||||
## [1.4.0] - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- Local migration audit log for the `2026-04-28-quality-cleanup` migration:
|
||||
`~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`.
|
||||
- Local extraction rejection log for rejected compaction memory candidates:
|
||||
`~/.local/share/opencode-working-memory/extraction-rejections.jsonl`.
|
||||
- Sanitized real-workspace regression fixtures for memory cleanup migration behavior.
|
||||
- Safe workspace residue cleanup tooling that dry-runs by default and quarantines definite temp/test workspace stores instead of deleting them.
|
||||
|
||||
### Changed
|
||||
|
||||
- Unified memory quality rules in a shared quality gate for compaction memory candidates and cleanup checks.
|
||||
- Rewritten compaction memory prompt to reduce over-production of low-quality memories.
|
||||
- Changed quality cleanup migration to be conservative: it supersedes only high-confidence garbage patterns, including progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries.
|
||||
- Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, user workflow preferences, and architecture decisions.
|
||||
- Isolated test runs under a temporary `XDG_DATA_HOME` so test workspaces no longer pollute real local workspace memory data.
|
||||
|
||||
### Recovery note
|
||||
|
||||
The cleanup migration changes matching entries to `status: "superseded"`; it does not delete the entry. If a useful memory is superseded, inspect the migration audit log and restore by changing that entry back to `status: "active"` in the workspace's `workspace-memory.json`. The migration runs once per workspace.
|
||||
|
||||
## [1.3.3] - 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added atomic cross-process storage writes with stale-lock recovery and heartbeat refresh to prevent concurrent memory-file corruption.
|
||||
- Scoped pending-memory promotion by owner/session so global unowned cleanup no longer removes active owned entries.
|
||||
- Retained source-aware pending memories until they are actually promoted, absorbed, superseded, or rejected.
|
||||
- Persisted load-time security redaction and expanded Bearer-token redaction to reduce secret retention risk.
|
||||
- Hardened workspace normalization, cache bounds, rejected-entry retention, and session cleanup behavior.
|
||||
|
||||
## [1.3.2] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Compatibility CI now installs dependencies with `npm install` so it works in this no-lockfile repository.
|
||||
- Compatibility CI now runs on Node 24, matching the test command's `--experimental-strip-types` requirement.
|
||||
|
||||
## [1.3.1] - 2026-04-27
|
||||
|
||||
### Added
|
||||
|
||||
- Pending journal retention: max 50 entries, 30-day TTL, automatic pruning on save.
|
||||
- Plugin capability test to catch missing OpenCode hooks before release.
|
||||
- CI workflow for weekly OpenCode plugin API compatibility testing.
|
||||
- Indirect prompt-injection filtering for workspace memory candidates.
|
||||
- Expanded credential redaction for common API key, token, secret, credential, auth, and private-key labels.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pending memory journal entries are now bounded and pruned instead of growing indefinitely.
|
||||
- Adversarial memory candidates that try to override system instructions are rejected before storage.
|
||||
- Broader credential-like labels are redacted from workspace memory text.
|
||||
|
||||
### Changed
|
||||
|
||||
- Memory dedupe is now repo-agnostic: project/reference entries use exact canonical text plus generic URL/path identity, while decision/feedback entries no longer use repository-specific topic heuristics.
|
||||
- OpenCode plugin compatibility is documented and declared as `>=1.2.0 <2.0.0`.
|
||||
- README limitations now concisely document compatibility, secret handling, semantic-memory scope, plugin ordering, and multi-process write boundaries.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Compatibility is tested against OpenCode plugin API `>=1.2.0 <2.0.0`.
|
||||
- Credential redaction is best-effort; do not store secrets.
|
||||
- This is working memory, not semantic search.
|
||||
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||
- Multi-process writes to the same workspace are not fully serialized.
|
||||
|
||||
## [1.3.0] - 2026-04-27
|
||||
|
||||
### Added
|
||||
|
||||
- P0 consolidation accounting for workspace memory promotion.
|
||||
- Accounting-aware deduplication (`dedupeLongTermEntriesWithAccounting`).
|
||||
- Accounting-aware normalization (`normalizeWorkspaceMemoryWithAccounting`).
|
||||
- Promotion classification: promoted, absorbed, superseded, rejected.
|
||||
- Remove absorbed/superseded keys from rejected set to avoid duplicate rejection tracking.
|
||||
- Memory quality evaluation fixtures covering accepted durable facts and rejected noisy facts.
|
||||
- Sharper compaction memory extraction prompt with concrete good/bad memory examples.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Promotion accounting now clears only pending memories that survive workspace normalization/cap limits.
|
||||
- `session.deleted` now uses shared session ID extraction, matching `session.compacted` behavior.
|
||||
- Absorbed duplicate pending memories are accounted for instead of retrying forever.
|
||||
- Active vs superseded boundary when promoting pending memories (superseded entries no longer block promotion of same-key active memories).
|
||||
- Removed unused `rejected_duplicate_lower_quality` type.
|
||||
|
||||
### Changed
|
||||
|
||||
- Deferred pending journal safety cap implementation (see TODO in `src/pending-journal.ts`).
|
||||
- Clarified superseded accounting semantics: P0 emits events only, does not archive newly superseded records.
|
||||
- README structure was streamlined around the automatic memory flow and ongoing memory-quality work.
|
||||
- Architecture docs now describe `Memory candidates:` as the primary extraction format and XML candidate blocks as legacy.
|
||||
- Superpowers implementation plans are no longer tracked in git.
|
||||
|
||||
## [1.2.3] - 2026-04-26
|
||||
|
||||
### Added
|
||||
|
||||
- Frozen workspace memory snapshot in `system[1]` for better OpenCode prompt-cache stability.
|
||||
- Ephemeral hot session state and pending memories in later system messages.
|
||||
- Durable pending journal so explicit memories survive until promotion.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Explicit memories no longer mutate the frozen workspace snapshot mid-session.
|
||||
- Pending memories are promoted at safe cache-epoch boundaries.
|
||||
|
||||
## [1.2.0] - 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
- Memory V2 three-layer architecture.
|
||||
- Workspace memory for durable cross-session decisions, preferences, project facts, and references.
|
||||
- Hot session state for active files, open errors, and recent context.
|
||||
- Hook-based memory extraction during OpenCode compaction.
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed manual memory tools in favor of automatic prompt injection.
|
||||
- Moved storage to `~/.local/share/opencode-working-memory/`.
|
||||
|
||||
## [1.1.0] - 2026-04-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved pre-V2 memory documentation and installation flow.
|
||||
|
||||
## [1.0.0] - 2026-04-23
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release with three-layer memory architecture.
|
||||
- Initial OpenCode memory integration.
|
||||
- Basic memory extraction and prompt injection.
|
||||
@@ -1,31 +1,41 @@
|
||||
# OpenCode Working Memory Plugin
|
||||
# OpenCode Working Memory
|
||||
|
||||
[](https://www.npmjs.com/package/opencode-working-memory)
|
||||
[](LICENSE)
|
||||
|
||||
**Automatic memory system that keeps your AI agent context-aware across compactions.**
|
||||
Automatic memory for OpenCode agents.
|
||||
|
||||
Stop losing context when OpenCode compacts your conversation. This plugin automatically tracks what matters — decisions, active files, open errors — and preserves it across sessions.
|
||||
Working memory is context that **remembers what matters, fades what changes, and stays out of the way.**
|
||||
|
||||
## What You Get
|
||||
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.
|
||||
|
||||
**Three-layer memory, zero extra API calls:**
|
||||
## Why This Exists
|
||||
|
||||
| Layer | Scope | What It Tracks | Persists? |
|
||||
|-------|-------|----------------|-----------|
|
||||
| **Workspace Memory** | Cross-session | Decisions, project info, references | ✅ Yes |
|
||||
| **Hot Session State** | Per-session | Active files, open errors | ❌ Resets |
|
||||
| **Native OpenCode** | Per-session | Todos | ✅ Built-in |
|
||||
OpenCode compaction keeps conversations manageable, but important context can still get lost over time.
|
||||
|
||||
**Key benefits:**
|
||||
- 🧠 **Remembers across sessions** — Workspace memory survives restarts
|
||||
- 🔌 **No extra API calls** — Piggybacks on existing compaction
|
||||
- 📡 **Zero configuration** — Works out of the box
|
||||
- 🔧 **Zero tools** — No manual memory management needed
|
||||
It adds a workspace-aware memory layer so your agent can remember durable facts while keeping short-term session state fresh and lightweight.
|
||||
|
||||
Use it when you want your agent to remember things like:
|
||||
|
||||
- Project conventions
|
||||
- User preferences
|
||||
- Architecture decisions
|
||||
- Important file paths or references
|
||||
- Current active files and unresolved errors
|
||||
|
||||
## Features
|
||||
|
||||
- **Workspace memory** — durable project facts, preferences, decisions, and references across sessions.
|
||||
- **Hot session state** — active files, open errors, and current working context for the current session.
|
||||
- **Explicit memory triggers** — users can say “remember this”, “記住”, “覚えて”, or “기억해” to save durable facts.
|
||||
- **Compaction-based extraction** — memory extraction piggybacks on OpenCode’s existing compaction flow.
|
||||
- **No manual tools** — memory is injected automatically into the system prompt.
|
||||
- **Quality guards** — filters noisy memories, temporary progress snapshots, stack traces, raw errors, and credentials.
|
||||
- **Retention decay** — keeps the strongest memories in prompt context while older or weaker memories fade out naturally; important and reinforced memories decay more slowly.
|
||||
|
||||
## Installation
|
||||
|
||||
Add to your `~/.config/opencode/opencode.json`:
|
||||
Add OpenCode Working Memory to your OpenCode config:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -33,181 +43,199 @@ Add to your `~/.config/opencode/opencode.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Restart OpenCode. The plugin activates automatically — no manual setup needed.
|
||||
Then restart OpenCode. It activates automatically.
|
||||
|
||||
## How It Works
|
||||
|
||||
**Three layers, zero API calls, automatic persistence:**
|
||||
OpenCode Working Memory adds durable memory without making extra LLM/API calls.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LAYER 1: WORKSPACE MEMORY │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 📦 Persists across sessions (in same workspace) │ │
|
||||
│ │ 📦 Survives compaction & restart │ │
|
||||
│ │ │ │
|
||||
│ │ Stored in: ~/.local/share/.../workspace-memory.json │ │
|
||||
│ │ Contains: decisions • project info • references │ │
|
||||
│ │ Written: during compaction (no extra LLM call!) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
↑ extracted during compaction (piggyback, no API call)
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LAYER 2: HOT SESSION STATE │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔥 Per-session, auto-tracked, resets on new session │ │
|
||||
│ │ │ │
|
||||
│ │ Active files (what you're editing) │ │
|
||||
│ │ Open errors (typecheck, test, lint failures) │ │
|
||||
│ │ Recent decisions (candidates for Layer 1) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
↑ harvested during compaction → promoted to Layer 1
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LAYER 3: NATIVE OPENCODE STATE │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ✅ Uses OpenCode's built-in todos │ │
|
||||
│ │ ✅ No plugin storage needed │ │
|
||||
│ │ ✅ Delegates to native features │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
|
||||
KEY INSIGHT: Layer 1 memories are extracted during OpenCode's
|
||||
built-in compaction summary — NO additional LLM call!
|
||||
```text
|
||||
┌──────────────────────────────────────┐
|
||||
│ 🧭 Conversation Events │
|
||||
│ edits, commands, errors, remembers │
|
||||
└──────────────────┬───────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ 🔥 Hot Session State │
|
||||
│ active files, open errors, pending │
|
||||
│ │
|
||||
│ ~/.local/share/opencode-working- │
|
||||
│ memory/workspaces/{hash}/sessions/ │
|
||||
│ {sessionID}.json │
|
||||
└──────────────────┬───────────────────┘
|
||||
│ when OpenCode compacts
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ 🧠 OpenCode Compaction │
|
||||
│ existing LLM/API call │
|
||||
│ + memory extraction instructions │
|
||||
│ │
|
||||
│ zero extra API calls │
|
||||
└──────────────────┬───────────────────┘
|
||||
│ filter, redact, dedupe
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ 📦 Workspace Memory │
|
||||
│ decisions, preferences, refs │
|
||||
│ │
|
||||
│ ~/.local/share/opencode-working- │
|
||||
│ memory/workspaces/{hash}/ │
|
||||
│ workspace-memory.json │
|
||||
└──────────────────┬───────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ ⚡ Prompt Context │
|
||||
│ system[1]: frozen workspace memory │
|
||||
│ system[2+]: hot session state │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### The Compaction Flow (No Extra API Call)
|
||||
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
|
||||
|
||||
```
|
||||
User Message ───────────────┐
|
||||
│
|
||||
Agent Response ─────────────┤
|
||||
│ normal conversation
|
||||
... more turns ... ─────────┤
|
||||
│
|
||||
▼
|
||||
╔═══════════════════════════════════════════╗
|
||||
║ COMPACTION (OpenCode built-in) ║
|
||||
║ ║
|
||||
║ OpenCode already calls LLM to summarize ║
|
||||
║ ──────────────────────────────────────── ║
|
||||
║ Plugin piggybacks on THIS call ║
|
||||
║ to extract workspace memory candidates ║
|
||||
║ ║
|
||||
║ Output includes: ║
|
||||
║ <workspace_memory_candidates> ║
|
||||
║ - [decision] Use npm cache for plugins ║
|
||||
║ - [project] React 18 with TypeScript ║
|
||||
║ </workspace_memory_candidates> ║
|
||||
╚═══════════════════════════════════════════╝
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Workspace Memory Updated │
|
||||
│ (persists across sessions) │
|
||||
└─────────────────────────────────┘
|
||||
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
|
||||
|
||||
The runtime context has three layers:
|
||||
|
||||
| Layer | Purpose | Lifetime |
|
||||
|---|---|---|
|
||||
| Workspace Memory | Durable decisions, preferences, project facts, references | Cross-session |
|
||||
| Hot Session State | Active files, open errors, recent context | Current session |
|
||||
| Native OpenCode State | Todos and built-in state | OpenCode-managed |
|
||||
|
||||
## Workspace Memory
|
||||
|
||||
Workspace memory is for durable information that should help future sessions.
|
||||
|
||||
Examples:
|
||||
|
||||
```md
|
||||
- [decision] Use npm cache for plugin loading, not npm link.
|
||||
- [project] This repo uses TypeScript and Node.js test runner.
|
||||
- [feedback] User prefers concise implementation summaries.
|
||||
- [reference] Storage lives under ~/.local/share/opencode-working-memory/.
|
||||
```
|
||||
|
||||
### Workspace Memory (Long-term)
|
||||
Memory types:
|
||||
|
||||
Persists across sessions within the same workspace. Automatically extracted during compaction when the agent marks something with "remember" or "note":
|
||||
- `feedback` — user preferences or recurring feedback
|
||||
- `project` — stable project-level facts
|
||||
- `decision` — important implementation or architecture decisions
|
||||
- `reference` — useful paths, commands, or configuration references
|
||||
|
||||
```
|
||||
<workspace_memory>
|
||||
- [decision] Use npm cache for plugin loading, not npm link
|
||||
- [project] This repo uses opencode-agenthub plugin system
|
||||
- [reference] Storage: ~/.local/share/opencode-working-memory/...
|
||||
</workspace_memory>
|
||||
### 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
|
||||
```
|
||||
|
||||
**Memory types:**
|
||||
- `feedback` - User preferences for this workspace
|
||||
- `project` - Project-level information
|
||||
- `decision` - Important decisions made
|
||||
- `reference` - Key references (paths, patterns)
|
||||
## Explicit Memory Triggers
|
||||
|
||||
**Sources:**
|
||||
- `explicit` - User explicitly said "remember this" (confidence: 1.0)
|
||||
- `compaction` - Extracted during compaction (confidence: 0.75)
|
||||
- `manual` - Added programmatically (confidence: varies)
|
||||
You can explicitly ask the agent to remember durable facts.
|
||||
|
||||
### Hot Session State (Short-term)
|
||||
Examples:
|
||||
|
||||
Automatically tracks current session context:
|
||||
|
||||
- **Active Files**: What files you're working on (ranked by recency and action type)
|
||||
- **Open Errors**: Errors that haven't been fixed yet (typecheck, test failures, etc.)
|
||||
- **Recent Decisions**: Decisions made this session (candidates for long-term promotion)
|
||||
|
||||
Injected into system prompt:
|
||||
```
|
||||
<workspace_memory>
|
||||
- [decision] Use npm cache for plugin loading, not npm link
|
||||
- [project] This repo uses opencode-agenthub plugin system
|
||||
- [reference] Storage: ~/.local/share/opencode-working-memory/workspaces/{hash}/
|
||||
</workspace_memory>
|
||||
|
||||
<hot_session_state>
|
||||
active_files:
|
||||
- src/plugin.ts (edit, 18x)
|
||||
- tests/plugin.test.ts (edit, 5x)
|
||||
- src/extractors.ts (grep, 3x)
|
||||
open_errors:
|
||||
- [typecheck] TS2345: Argument of type 'string' is not assignable...
|
||||
</hot_session_state>
|
||||
```md
|
||||
Remember this: we prefer Vitest for new frontend tests.
|
||||
記住:這個 repo 發 release 前要先跑 npm test。
|
||||
覚えておいて: API clients should use the shared retry helper.
|
||||
기억해줘: this project uses pnpm, not npm.
|
||||
```
|
||||
|
||||
## Quality Guarantees
|
||||
Supported trigger languages include:
|
||||
|
||||
The plugin includes several quality guards:
|
||||
| Language | Examples |
|
||||
|---|---|
|
||||
| English | `remember this`, `save to memory`, `from now on`, `my preference` |
|
||||
| Chinese | `記住`, `记住`, `記得`, `请帮我记住` |
|
||||
| Japanese | `覚えて`, `覚えておいて`, `メモして` |
|
||||
| Korean | `기억해`, `기억해줘`, `메모해줘` |
|
||||
|
||||
- **No false positive errors**: Bash commands like `git log` or `cat` with "error" in output are not misidentified
|
||||
- **Negative memory filtering**: "Don't remember this" is correctly interpreted
|
||||
- **Compaction quality gate**: Rejects git hashes, stack traces, path-heavy facts from becoming long-term memories
|
||||
- **Canonical deduplication**: Memories are deduplicated with case/punctuation normalization
|
||||
Negative requests are respected too:
|
||||
|
||||
## No Tools Required
|
||||
```md
|
||||
Don't remember this.
|
||||
不要記住這個。
|
||||
覚えないで。
|
||||
기억하지 마.
|
||||
```
|
||||
|
||||
Unlike other memory plugins, **this plugin has no manual tools**. Everything is automatic:
|
||||
Avoid saving:
|
||||
|
||||
- No `core_memory_update` — memory is extracted automatically
|
||||
- No `core_memory_read` — memory is injected into system prompt
|
||||
- No `working_memory_add` — active files are tracked automatically
|
||||
- Secrets, passwords, tokens, or credentials
|
||||
- Temporary progress updates
|
||||
- Raw command output
|
||||
- Short-lived session details
|
||||
|
||||
Just install and let it run. The plugin hooks into OpenCode's lifecycle events and does the right thing.
|
||||
## Quality Guards
|
||||
|
||||
OpenCode Working Memory tries to keep memory useful and low-noise.
|
||||
|
||||
It includes guards for:
|
||||
|
||||
- Credential redaction
|
||||
- Duplicate memory cleanup
|
||||
- Accounting for promoted, absorbed, superseded, and rejected memories
|
||||
- Strength-based retention so useful memories stay visible without hard age pruning
|
||||
- Filtering stack traces, git hashes, raw errors, and noisy path-heavy facts
|
||||
- Rejecting temporary project progress snapshots
|
||||
|
||||
The goal is to remember durable facts, not every detail.
|
||||
|
||||
**Good memory is selective memory.**
|
||||
|
||||
Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y".
|
||||
|
||||
For local development cleanup, use:
|
||||
|
||||
```bash
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
npm run cleanup:workspaces -- --quarantine
|
||||
```
|
||||
|
||||
The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin works out of the box with sensible defaults:
|
||||
OpenCode Working Memory works out of the box.
|
||||
|
||||
- **Workspace Memory**: 5200 chars, 28 entries max
|
||||
- **Hot State**: 1200 chars rendered, 8 active files, 3 errors shown
|
||||
- **Storage**: `~/.local/share/opencode-working-memory/workspaces/{hash}/`
|
||||
Default behavior:
|
||||
|
||||
See [Configuration Guide](docs/configuration.md) for customization options.
|
||||
- Workspace memory budget: 3600 characters (~900 tokens)
|
||||
- Workspace memory limit: 28 entries
|
||||
- Hot session state budget: 700 characters (~175 tokens)
|
||||
- Active files shown: 8
|
||||
- Open errors shown: 3
|
||||
|
||||
## For AI Agents
|
||||
See [Configuration](docs/configuration.md) for customization options.
|
||||
|
||||
When using this plugin, the memory context appears in your system prompt. You can:
|
||||
## Roadmap
|
||||
|
||||
1. **Tell users about memories**: "I remember you decided to use npm cache for plugins"
|
||||
2. **Ask about preferences**: "Should I add this to my memory for this workspace?"
|
||||
3. **Note important decisions**: These will be extracted during compaction
|
||||
Current focus:
|
||||
|
||||
To add something to long-term memory explicitly:
|
||||
```
|
||||
Remember this: [your note here]
|
||||
```
|
||||
|
||||
The plugin captures this during compaction.
|
||||
- Add explicit delete tombstones so removed memories do not get re-extracted.
|
||||
- Enforce explicit `supersedes` chains for safer replacement of obsolete memories.
|
||||
- Explore tiered hot/warm/cold storage after the retention model has more real-world data.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture Overview](docs/architecture.md) - How the three layers work
|
||||
- [Configuration](docs/configuration.md) - Customization options
|
||||
- [Installation Guide](docs/installation.md) - Step-by-step setup
|
||||
- [Architecture Overview](docs/architecture.md)
|
||||
- [Configuration](docs/configuration.md)
|
||||
- [Installation Guide](docs/installation.md)
|
||||
|
||||
## Development
|
||||
|
||||
@@ -217,22 +245,31 @@ cd opencode-working-memory
|
||||
npm install
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenCode >= 1.0.0
|
||||
- OpenCode plugin API `>=1.2.0 <2.0.0`
|
||||
- Node.js >= 18.0.0
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires OpenCode plugin API `>=1.2.0 <2.0.0`; OpenCode hook changes may break compatibility.
|
||||
- Not a secret manager. Credential redaction is best-effort. Do not store secrets.
|
||||
- Working memory only. No semantic search, embeddings, or vector knowledge base.
|
||||
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||
- Multiple OpenCode processes on the same workspace may race on local files.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
MIT License. See [LICENSE](LICENSE) for details.
|
||||
|
||||
## Support
|
||||
|
||||
- 📖 [Documentation](docs/)
|
||||
- 🐛 [Report Issues](https://github.com/sdwolf4103/opencode-working-memory/issues)
|
||||
- [Documentation](docs/)
|
||||
- [Report Issues](https://github.com/sdwolf4103/opencode-working-memory/issues)
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for the OpenCode community**
|
||||
Made with ❤️ for the OpenCode community.
|
||||
|
||||
+403
-1
@@ -1,5 +1,407 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.5.0 (2026-04-29)
|
||||
|
||||
### Retention Decay Model
|
||||
|
||||
This release changes workspace memory retention from hard stale pruning and additive priority scoring to a strength-based decay model.
|
||||
|
||||
Think of it like a forgetting curve: memories fade over time, but important, reinforced, and safety-critical memories decay slower. Weak entries fall out of rendered prompt context by cap competition, not hard deletion.
|
||||
|
||||
> **Memory should fade, so the agent can keep learning.**
|
||||
> Important memories decay slower, but every memory must leave room for newer project reality and avoid long-term memory pollution.
|
||||
|
||||
```text
|
||||
strength
|
||||
│
|
||||
██ │╲____ reinforced: slower decline
|
||||
│ ╲______
|
||||
▒▒ │ ╲__ ordinary memory
|
||||
│ ╲
|
||||
├ ─ ─ ─ ─ ─ ─ ─ ─╲─ dynamic cap competition zone
|
||||
░░ │ ╲ easier for new memories to replace
|
||||
│ ↑ still stored, not deleted
|
||||
└──────────────────────────────→ time / sessions
|
||||
```
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Strength-based retention**: workspace memory now uses exponential decay: initial strength × age decay.
|
||||
- **Better initial strength**: type, source, user importance, and safety-critical status now determine how strong a memory starts.
|
||||
- **No confidence scoring**: confidence remains in stored data for compatibility, but it no longer affects retention ranking.
|
||||
- **Type caps**: rendered workspace memory now caps feedback, decisions, project facts, and references separately so one type cannot monopolize all 28 slots.
|
||||
- **Safety-critical protection**: safety-critical entries get stronger retention and are exempt from per-type caps, while still competing under the global rendered cap.
|
||||
- **Dormant-aware age**: after 14 inactive days, additional dormant workspace time counts at 0.25x so paused projects do not forget too aggressively.
|
||||
- **Reinforcement**: repeated matching memories reinforce the survivor and slow future decay, with same-session and one-hour guards to avoid accidental spam.
|
||||
- **No hard stale pruning**: old or stale-marked memories are no longer automatically dropped by age; they lose rendered space only through cap competition.
|
||||
- **Calibrated prompt budgets**: observed rendered output was typically under ~2000 characters for workspace memory and ~500 characters for hot session state, so defaults were reduced to 3600 and 700 characters to keep overhead lower while retaining buffer.
|
||||
- **Clearer health output**: `memory-diag health` now reports stored vs rendered counts, type caps, global cap overflow, dormancy, retention monitoring, and strength-ranked top/weakest entries.
|
||||
|
||||
### Why This Helps
|
||||
|
||||
- User preferences and explicit memories are less likely to disappear just because inferred project facts are newer.
|
||||
- Feedback, decisions, project facts, and references share prompt space more fairly.
|
||||
- Returning to an old workspace is less punishing because dormant time decays more slowly.
|
||||
- Maintainers can see why memories are rendered or capped instead of guessing from a single active-memory count.
|
||||
- Stale entries can fade out of prompt context without destructive cleanup.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Maintainers can inspect retention behavior with:
|
||||
|
||||
```bash
|
||||
bun scripts/memory-diag.ts health
|
||||
```
|
||||
|
||||
The health output now includes sections like:
|
||||
|
||||
```txt
|
||||
Stored active memories: 28
|
||||
Rendered candidates: 20
|
||||
|
||||
By type:
|
||||
feedback stored=17 rendered=10 typeCap=10
|
||||
decision stored=11 rendered=10 typeCap=10
|
||||
|
||||
Retention caps:
|
||||
type-capped entries: 8
|
||||
global-cap overflow: 0
|
||||
|
||||
Dormancy:
|
||||
dormant discount active: no
|
||||
|
||||
Retention monitoring:
|
||||
high_importance_ratio: 0.0% (alert > 30%)
|
||||
```
|
||||
|
||||
### Not Included Yet
|
||||
|
||||
- Delete tombstones are not implemented in this release.
|
||||
- Explicit `supersedes` chain enforcement is still deferred.
|
||||
- Hot/warm/cold tiered storage remains future work.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes required.
|
||||
- Existing workspace memory files remain compatible.
|
||||
- Existing entries without a `retentionClock` fall back safely to existing timestamps.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm test` — 237 tests passing
|
||||
- `bun scripts/memory-diag.ts health`
|
||||
|
||||
---
|
||||
|
||||
## 1.4.0 (2026-04-28)
|
||||
|
||||
### Memory Quality Cleanup
|
||||
|
||||
This release improves automatic workspace memory quality without risking broad cleanup of useful existing memories.
|
||||
|
||||
The quality gate is now shared across compaction extraction and migration checks, the compaction prompt is stricter about what should become durable memory, and the one-time migration is intentionally conservative.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Unified quality rules**: memory quality checks now live in one shared module and apply consistently across feedback, decisions, project facts, and references.
|
||||
- **Stricter compaction output**: the compaction prompt now tells the model to save fewer memories and prefer durable facts, user preferences, architecture decisions, and hard-to-rediscover references.
|
||||
- **Conservative migration cleanup**: the `2026-04-28-quality-cleanup` migration only supersedes high-confidence garbage patterns, not every rejected memory.
|
||||
- **Audit logs**: automatic migration cleanup writes local JSONL audit records so superseded entries can be inspected and restored.
|
||||
- **Extraction rejection logs**: newly rejected compaction candidates are logged locally to help calibrate future quality rules.
|
||||
- **Regression coverage**: migration behavior is tested against sanitized real-workspace patterns to prevent mass false positives from coming back.
|
||||
- **Workspace cleanup tooling**: a dev/admin cleanup command can dry-run or quarantine definite temp/test workspace residues without deleting unknown missing-root workspaces.
|
||||
- **Test storage isolation**: test runs now use a temporary `XDG_DATA_HOME`, preventing fixture workspaces from polluting real local memory data.
|
||||
|
||||
### What Gets Cleaned Up
|
||||
|
||||
The migration may supersede existing `source: "compaction"` memories only when they match hard garbage patterns:
|
||||
|
||||
- Empty entries
|
||||
- Progress snapshots, such as "Wave 1 completed successfully"
|
||||
- Test or suite count snapshots, such as "180 tests passed"
|
||||
- Raw errors and stack traces
|
||||
- Commit or CI snapshots
|
||||
- Temporary status notes, such as "Currently running npm test"
|
||||
- Active file snapshots
|
||||
- Code or API signatures
|
||||
- Path-heavy entries that are just rediscoverable file lists
|
||||
|
||||
### What Is Protected
|
||||
|
||||
The migration does not supersede entries whose only issue is a soft heuristic failure, such as:
|
||||
|
||||
- `bad_feedback`
|
||||
- `bad_decision`
|
||||
|
||||
This protects useful declarative memories like:
|
||||
|
||||
- Product branding rules
|
||||
- API facts
|
||||
- Release rules
|
||||
- Architecture decisions
|
||||
- User workflow preferences
|
||||
|
||||
Explicit and manual memories are also protected.
|
||||
|
||||
### Migration Behavior
|
||||
|
||||
- Runs once per workspace.
|
||||
- Only affects active `source: "compaction"` entries.
|
||||
- Marks matching entries as `status: "superseded"` instead of deleting them.
|
||||
- Adds `quality_cleanup` and `quality:<reason>` tags to superseded entries.
|
||||
- Writes audit logs to:
|
||||
`~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`
|
||||
- Writes extraction rejection logs to:
|
||||
`~/.local/share/opencode-working-memory/extraction-rejections.jsonl`
|
||||
|
||||
### Recovery
|
||||
|
||||
If a useful memory is superseded, inspect the migration audit log and restore the entry by changing its status back to `"active"` in the workspace's `workspace-memory.json`.
|
||||
|
||||
### Workspace Residue Cleanup
|
||||
|
||||
If old test/temp workspace stores already exist locally, inspect them first:
|
||||
|
||||
```bash
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
```
|
||||
|
||||
To move definite temp/test residues into a local quarantine folder instead of deleting them:
|
||||
|
||||
```bash
|
||||
npm run cleanup:workspaces -- --quarantine
|
||||
```
|
||||
|
||||
The cleanup command skips existing workspace roots and unknown missing-root workspaces by default.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes required.
|
||||
- Existing workspace memory files remain compatible.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm test`
|
||||
- `npm run typecheck`
|
||||
|
||||
---
|
||||
|
||||
## 1.3.2 (2026-04-27)
|
||||
|
||||
### CI Compatibility Patch
|
||||
|
||||
- Fixed the compatibility workflow so dependency installation works without a committed lockfile.
|
||||
- Moved compatibility CI to Node 24 so TypeScript-stripping tests run correctly.
|
||||
- No runtime or storage changes.
|
||||
|
||||
---
|
||||
|
||||
## 1.3.1 (2026-04-27)
|
||||
|
||||
### Security and Reliability Patch
|
||||
|
||||
This patch release keeps the v1.3 memory-consolidation model intact while tightening storage safety, compatibility checks, and repository-agnostic dedupe behavior.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Bounded pending journal**: pending memories are capped at 50 entries and pruned after 30 days.
|
||||
- **Security hardening**: workspace memory candidates now reject indirect prompt-injection attempts, and redaction covers broader token, secret, credential, auth, and private-key labels.
|
||||
- **Compatibility coverage**: plugin capability tests and weekly OpenCode plugin API compatibility CI help catch hook drift before release.
|
||||
- **Repo-agnostic dedupe**: long-term memory dedupe no longer depends on hardcoded project-specific topic rules; project/reference memories use generic URL/path identity plus exact canonical matching.
|
||||
- **Clearer limitations**: README and changelog now document compatibility, best-effort secret redaction, working-memory scope, plugin ordering, and multi-process write boundaries.
|
||||
|
||||
### Thanks
|
||||
|
||||
- Thanks @StevenChoo for the security hardening contribution in #3.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No user migration is required.
|
||||
- Existing workspace memory and pending journal files remain compatible.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm test`
|
||||
- `npm run typecheck`
|
||||
|
||||
---
|
||||
|
||||
## 1.3.0 (2026-04-27)
|
||||
|
||||
### Better Memory Consolidation
|
||||
|
||||
This release makes OpenCode Working Memory smarter about what happens to saved memories after compaction. Instead of treating every pending memory as simply "kept" or "not kept", it now understands four outcomes:
|
||||
|
||||
- **Promoted** — a new memory was saved to workspace memory.
|
||||
- **Absorbed** — the memory was a duplicate of something already remembered.
|
||||
- **Superseded** — a newer same-topic decision or preference replaced an older one.
|
||||
- **Rejected** — the memory was stale, noisy, or over the workspace memory limit.
|
||||
|
||||
### What This Improves
|
||||
|
||||
- **Fewer repeated pending memories**: duplicate or superseded memories no longer keep coming back for promotion.
|
||||
- **Cleaner long-term memory**: old same-topic decisions are replaced more predictably.
|
||||
- **Safer promotion accounting**: pending memories are only cleared when the final normalized workspace memory confirms what happened to them.
|
||||
- **More useful compaction output**: the compaction prompt now includes clearer examples of what should and should not become durable memory.
|
||||
|
||||
### Also Included
|
||||
|
||||
- Memory quality regression fixtures: 5 examples that should be kept and 7 noisy examples that should be rejected.
|
||||
- Fix for `session.deleted` session ID extraction so cleanup and promotion use the same event parsing path.
|
||||
- Fix for active-vs-superseded promotion behavior: archived superseded entries no longer block a fresh active memory.
|
||||
- README and architecture documentation updates.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No user migration is required.
|
||||
- Existing workspace memory files remain compatible.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
- **135 tests pass**.
|
||||
|
||||
---
|
||||
|
||||
## 1.2.3 (2026-04-27)
|
||||
|
||||
### Prompt Cache Optimization — Frozen Snapshot + Ephemeral Delta
|
||||
|
||||
This release optimizes OpenCode Working Memory's impact on OpenCode's prompt cache, following Hermes-style architecture patterns.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Frozen workspace snapshot**: Workspace memory is now rendered once at session start and cached as immutable `system[1]`. No mid-session re-render that could invalidate the cache.
|
||||
- **Ephemeral hot state**: Hot session state (active files, errors) is rendered in `system[2+]`, which is excluded from the first-two-system cache control.
|
||||
- **Durable pending journal**: Explicit memories are written to both session state and a durable workspace-level pending journal, ensuring no data loss between compactions.
|
||||
- **Safe promotion**: Explicit memories are promoted from pending to workspace memory at:
|
||||
- Next session start (before frozen snapshot)
|
||||
- `session.compacted`
|
||||
- `session.deleted` (before cleanup)
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
system[0] → OpenCode / agent header (stable cached)
|
||||
system[1] → Frozen workspace memory snapshot (stable cached)
|
||||
system[2+] → Hot session state + pending memories (dynamic, not cached)
|
||||
```
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Hot state invalidating cache**: Active files / errors updating every tool call previously caused the entire workspace memory block to be re-hashed, killing cache efficiency.
|
||||
- **Explicit memory loss**: Without compaction, explicit memories could be lost when sessions ended without promotion.
|
||||
- **Mid-session mutation**: Explicit memories no longer mutate the running frozen snapshot; they appear as pending and are promoted safely.
|
||||
|
||||
### Migration
|
||||
|
||||
- One-time migration: `2026-04-27-p0-cleanup` removes stale pending journal entries older than 60 days.
|
||||
|
||||
### Tests
|
||||
|
||||
- **91 tests pass** (24 workspace-memory, 34 extractors, 14 plugin, 19 pending-journal)
|
||||
|
||||
---
|
||||
|
||||
## 1.2.2 (2026-04-27)
|
||||
|
||||
### Safer Multilingual Memory Capture
|
||||
|
||||
This release strengthens explicit memory handling across languages while keeping sensitive credentials out of stored workspace memory.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Always-on credential redaction**: Credentials are redacted both when memory is loaded and when it is saved
|
||||
- **Multilingual memory triggers**: Added Japanese and Korean explicit-memory phrases, plus expanded Chinese coverage
|
||||
- **Expanded snapshot filtering**: Rejects Wave/Sprint/Milestone/Task progress snapshots that should not become durable memory
|
||||
- **Higher memory quality bar**: Extraction now focuses on durable facts that will change future behavior
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Credential leakage risk**: Password/PIN-style values are now redacted with delimiter-preserving patterns, including multilingual labels such as `パスワード`, `비밀번호`, `contraseña`, `mot de passe`, and `Passwort`.
|
||||
- **Missing non-English explicit memory requests**: Japanese (`覚えて`, `メモして`), Korean (`기억해`, `메모해줘`), and additional Chinese triggers are now recognized.
|
||||
- **Progress snapshots polluting memory**: Wave/Sprint/Milestone/Task status updates are filtered from long-term memory unless they contain durable facts.
|
||||
|
||||
### Migration
|
||||
|
||||
- Runs one-time cleanup for legacy snapshot entries: `2026-04-26-p0-cleanup`
|
||||
|
||||
---
|
||||
|
||||
## 1.2.1 (2026-04-26)
|
||||
|
||||
### Compaction Memory Quality — Four-Layer Defense
|
||||
|
||||
This release addresses systemic quality issues in workspace memory: duplicates, stale entries, and silently lost memory candidates. A four-layer defense is now in place:
|
||||
|
||||
```
|
||||
Prompt → Durable-content guidance keeps LLM on factual memories
|
||||
Parser → Accepts bracketless format, filters session snapshots
|
||||
Storage → Entity-key dedup + topic supersession + source priority
|
||||
Staleness → Age-based pruning of obsolete compaction/manual entries
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Self-cleaning memory**: Entity-key deduplication, topic supersession, and age-based staleness pruning automatically maintain memory quality
|
||||
- **Robust parser**: Accepts both bracketless (`- type text`) and bracketed (`- [type] text`) formats — no more silently lost memories
|
||||
- **Durable-content prompt**: Compaction template now guides LLM toward factual, long-lived memories while explicitly discouraging session ephemera
|
||||
- **Smart snapshot filtering**: Automatically rejects project-type snapshots (file counts, test counts, Phase progress) that don't belong in long-term memory
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bracketless format bug**: Parser regex only matched `- [type]` pattern; real LLM output often uses `- type` (no brackets). Both formats now accepted. (P0a)
|
||||
- **Purple/italic text in OpenCode UI**: Replaced XML/HTML comment templates with clean Markdown headings. Further hardened with negative instructions to forbid YAML frontmatter. (P0b β)
|
||||
- **Session snapshots polluting memory**: Project entries like "37 個文件", "26 tests pass", "Phase 2 completed" now rejected by parser filter. (P0c)
|
||||
- **Duplicate entries**: Entities deduped by key (e.g., `opencode-agenthub plugin system`). Topic conflicts resolved via supersession: newer shorter facts beat older verbose ones for decisions/feedback. (P0d)
|
||||
- **Stale entries never cleaned**: Compaction/manual entries with `staleAfterDays` now auto-pruned after 30-day grace period.
|
||||
- **Short reference entries rejected**: Admin PIN (`456123`) and config values (`Scrypt n=32768`) now allowed through config value allowlist despite being under 20 chars.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chooseBetterMemory`**: Now accepts `"entity"` mode (length preferred, for project/reference) and `"supersession"` mode (freshness preferred, for decision/feedback).
|
||||
- **Source priority in sort**: Manual/source priority now included as secondary sort tie-breaker after entry priority.
|
||||
|
||||
### Technical Details
|
||||
|
||||
- **Parser formats**: 4 accepted (plain text label primary, plus Markdown section, legacy section, legacy XML)
|
||||
- **Chinese counter words**: Regex matches `個`/`个` between numbers and nouns (e.g., `37 個文件`)
|
||||
- **Entity keys cautious**: Only known product keys extracted (`opencode-agenthub`); generic config references fall back to canonical text dedup
|
||||
|
||||
### Tests
|
||||
|
||||
- **70/70 tests pass** (24 workspace-memory, 34 extractors, 12 plugin)
|
||||
|
||||
---
|
||||
|
||||
## 1.2.0 (2026-04-26)
|
||||
|
||||
### Memory V2 Architecture
|
||||
@@ -108,4 +510,4 @@ LICENSE
|
||||
- Core Memory blocks (goal/progress/context)
|
||||
- Working Memory with slots and pool
|
||||
- Pressure monitoring with interventions
|
||||
- Smart pruning of tool outputs
|
||||
- Smart pruning of tool outputs
|
||||
|
||||
+76
-31
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Working Memory Plugin implements a **three-layer memory architecture** designed to preserve context across OpenCode session compactions.
|
||||
OpenCode Working Memory implements a **three-layer memory architecture** designed to preserve context across OpenCode session compactions.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
@@ -10,7 +10,7 @@ The Working Memory Plugin implements a **three-layer memory architecture** desig
|
||||
│ • Persistent storage: ~/.local/share/opencode-working-... │
|
||||
│ • Types: feedback | project | decision | reference │
|
||||
│ • Sources: explicit | compaction | manual │
|
||||
│ • Limits: 5200 chars / 28 entries │
|
||||
│ • Render limits: 3600 chars / 28 entries │
|
||||
│ • Survives: session reset, compaction (same workspace) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
@@ -48,8 +48,9 @@ Long-term memory that persists across sessions within the same workspace. Perfec
|
||||
{
|
||||
version: 1,
|
||||
workspace: { root: string, key: string },
|
||||
limits: { maxRenderedChars: 5200, maxEntries: 28 },
|
||||
limits: { maxRenderedChars: 3600, maxEntries: 28 },
|
||||
entries: LongTermMemoryEntry[],
|
||||
lastActivityAt?: string,
|
||||
updatedAt: string
|
||||
}
|
||||
```
|
||||
@@ -73,39 +74,76 @@ Long-term memory that persists across sessions within the same workspace. Perfec
|
||||
|
||||
### Memory Extraction
|
||||
|
||||
During compaction, the plugin scans for `<workspace_memory_candidates>` blocks:
|
||||
During compaction, OpenCode Working Memory scans for `Memory candidates:` sections:
|
||||
|
||||
```
|
||||
<workspace_memory_candidates>
|
||||
Memory candidates:
|
||||
- [decision] Use npm cache for plugin loading
|
||||
- [project] This repo uses TypeScript with strict mode
|
||||
</workspace_memory_candidates>
|
||||
```
|
||||
|
||||
**Quality Gate**: Not all candidates become memories. The plugin rejects:
|
||||
**Legacy Format**: OpenCode Working Memory also accepts `<workspace_memory_candidates>` XML blocks for backward compatibility, but this format is deprecated.
|
||||
|
||||
**Quality Gate**: Not all candidates become memories. OpenCode Working Memory rejects:
|
||||
- Git commit hashes (e.g., `abc1234`)
|
||||
- Raw errors (e.g., `Error: something failed`)
|
||||
- Stack traces
|
||||
- Path-heavy facts (>50% paths)
|
||||
- Very short text (<20 chars)
|
||||
|
||||
### Deduplication
|
||||
### Consolidation, Deduplication, and Retention
|
||||
|
||||
Memories are deduplicated using **canonical text matching**:
|
||||
1. Normalize: lowercase, strip punctuation, collapse whitespace
|
||||
2. Hash the canonical text
|
||||
3. Keep the entry with highest confidence
|
||||
Memories are deduplicated and consolidated with accounting:
|
||||
|
||||
1. Normalize exact text: lowercase, strip punctuation, collapse whitespace.
|
||||
2. Group project/reference entries by identity where possible.
|
||||
3. Keep decision and feedback entries on exact canonical matching to avoid broad semantic merges.
|
||||
4. Keep the best surviving entry by source, confidence, specificity, and freshness tie-breakers.
|
||||
5. Emit accounting events so pending memories can be classified as promoted, absorbed, superseded, or rejected.
|
||||
|
||||
This prevents absorbed or superseded pending memories from retrying forever while still preserving the active surviving memory.
|
||||
|
||||
Retention then decides which active memories are rendered into prompt context. It does not hard-delete old memories by age.
|
||||
|
||||
```typescript
|
||||
strength = initialStrength * 2 ** (-effectiveAgeDays / effectiveHalfLifeDays)
|
||||
```
|
||||
|
||||
Initial strength is based on memory type, source, optional user importance, and safety-critical status. Confidence remains stored for compatibility but is not part of retention scoring.
|
||||
|
||||
Rendered candidates are selected in this order:
|
||||
|
||||
1. Exclude `status: "superseded"` entries.
|
||||
2. Compute current retention strength.
|
||||
3. Sort by strength descending.
|
||||
4. Apply per-type caps, with safety-critical entries exempt from type caps.
|
||||
5. Keep the top 28 rendered entries under the workspace memory character budget.
|
||||
|
||||
Default type caps:
|
||||
|
||||
| Type | Rendered cap |
|
||||
|------|--------------|
|
||||
| `feedback` | 10 |
|
||||
| `decision` | 10 |
|
||||
| `project` | 8 |
|
||||
| `reference` | 6 |
|
||||
|
||||
The type-cap total is 34, intentionally above the global 28-entry cap. These are maximums, not quotas.
|
||||
|
||||
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement.
|
||||
|
||||
### System Prompt Injection
|
||||
|
||||
Workspace memory is injected at the top of every message:
|
||||
|
||||
```
|
||||
<workspace_memory>
|
||||
- [decision] Use npm cache for plugin loading, not npm link
|
||||
- [project] This repo uses opencode-agenthub plugin system
|
||||
- [reference] Storage: ~/.local/share/opencode-working-memory/...
|
||||
</workspace_memory>
|
||||
Workspace memory (cross-session, verify if stale):
|
||||
decision:
|
||||
- Use npm cache for plugin loading, not npm link
|
||||
project:
|
||||
- This repo uses the opencode-agenthub plugin system
|
||||
reference:
|
||||
- Storage: ~/.local/share/opencode-working-memory/...
|
||||
```
|
||||
|
||||
## Layer 2: Hot Session State
|
||||
@@ -180,15 +218,20 @@ Hot session state is injected after workspace memory:
|
||||
|
||||
```
|
||||
---
|
||||
<workspace_memory_candidates>
|
||||
- [project] This repo uses TypeScript with strict mode
|
||||
</workspace_memory_candidates>
|
||||
|
||||
Active Files:
|
||||
Hot session state (current session):
|
||||
|
||||
active_files:
|
||||
- src/plugin.ts (edit, 18x)
|
||||
- tests/plugin.test.ts (edit, 5x)
|
||||
|
||||
Open Errors: (none)
|
||||
open_errors: (none)
|
||||
|
||||
recent_decisions:
|
||||
- Use frozen workspace memory snapshots for cache stability
|
||||
|
||||
pending_memories:
|
||||
- [decision] Parser supports 3 candidate formats
|
||||
```
|
||||
|
||||
## Layer 3: Native OpenCode State
|
||||
@@ -205,7 +248,7 @@ Delegate task tracking to OpenCode's native features.
|
||||
|
||||
## Plugin Hooks
|
||||
|
||||
The plugin hooks into OpenCode lifecycle events:
|
||||
OpenCode Working Memory hooks into OpenCode lifecycle events:
|
||||
|
||||
### `experimental.chat.system.transform`
|
||||
|
||||
@@ -221,13 +264,15 @@ Injects workspace memory and hot session state into system prompt.
|
||||
### `experimental.session.compacting`
|
||||
|
||||
Extracts workspace memory candidates from conversation.
|
||||
Applies quality gate, deduplication, and source priority.
|
||||
Applies quality gate, redaction, migration, consolidation accounting, deduplication, and source priority.
|
||||
|
||||
### `event` (session.compacted, session.deleted)
|
||||
|
||||
- `session.compacted`: Promote session decisions to workspace memory
|
||||
- `session.deleted`: Clean up session state files
|
||||
|
||||
Promotion uses accounting results from workspace memory normalization. Pending memories that are kept are promoted; duplicate memories are absorbed; exact decision replacements can be superseded; over-capacity compaction memories are rejected. Stale-marked memories are not hard-pruned by age; they lose rendered space through retention strength and cap competition.
|
||||
|
||||
## Quality Guarantees
|
||||
|
||||
### No False Positive Errors
|
||||
@@ -304,14 +349,14 @@ const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
|
||||
|
||||
| Layer | Max Chars | Max Entries |
|
||||
|-------|-----------|-------------|
|
||||
| Workspace Memory | 5200 | 28 |
|
||||
| Hot Session State | 1200 | 8 files, 3 errors |
|
||||
| Workspace Memory | 3600 | 28 |
|
||||
| Hot Session State | 700 | 8 files, 3 errors |
|
||||
|
||||
### Injection Overhead
|
||||
|
||||
- Workspace memory: ~200-500 chars per message
|
||||
- Hot session state: ~200-400 chars per message
|
||||
- Total: ~400-900 chars per message (minimal)
|
||||
- Workspace memory: usually under ~2000 chars in observed rendered output
|
||||
- Hot session state: usually under ~500 chars in observed rendered output
|
||||
- Total: typically well below the configured maximums
|
||||
|
||||
### Storage Footprint
|
||||
|
||||
@@ -343,9 +388,9 @@ Modify `src/extractors.ts` to add new extraction patterns.
|
||||
|
||||
### Memory V1 to V2
|
||||
|
||||
The plugin automatically migrates old format files to the new three-layer architecture. No manual intervention needed.
|
||||
OpenCode Working Memory automatically migrates old format files to the new three-layer architecture. No manual intervention needed.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: April 2026
|
||||
**Implementation**: `src/plugin.ts`, `src/extractors.ts`, `src/workspace-memory.ts`, `src/session-state.ts`
|
||||
**Implementation**: `src/plugin.ts`, `src/extractors.ts`, `src/workspace-memory.ts`, `src/session-state.ts`
|
||||
|
||||
+53
-16
@@ -2,14 +2,14 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Working Memory Plugin works out-of-the-box with sensible defaults. Configuration is defined in `src/types.ts` as constants.
|
||||
OpenCode Working Memory works out-of-the-box with sensible defaults. Configuration is defined in `src/types.ts` as constants.
|
||||
|
||||
## Workspace Memory Limits
|
||||
|
||||
```typescript
|
||||
const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 5200, // Maximum characters in system prompt
|
||||
targetRenderedChars: 4200, // Target characters (leave buffer)
|
||||
maxRenderedChars: 3600, // Maximum characters in system prompt
|
||||
targetRenderedChars: 3000, // Target characters (leave buffer)
|
||||
maxEntries: 28, // Maximum number of entries
|
||||
maxEntryTextChars: 260, // Maximum characters per entry text
|
||||
maxRationaleChars: 180, // Maximum characters per entry rationale
|
||||
@@ -18,14 +18,40 @@ const LONG_TERM_LIMITS = {
|
||||
|
||||
**Recommendations**:
|
||||
- Keep `maxRenderedChars` under 5500 to avoid context bloat
|
||||
- Defaults are calibrated from observed rendered usage that was typically under ~2000 characters
|
||||
- `maxEntries` of 28 provides good coverage without overwhelming
|
||||
- Entry text limits ensure entries stay concise
|
||||
|
||||
## Retention Model Defaults
|
||||
|
||||
Workspace memory retention uses strength-based decay. These constants live in `src/workspace-memory.ts`:
|
||||
|
||||
```typescript
|
||||
const BASE_HALF_LIFE_DAYS = 45;
|
||||
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
||||
const REINFORCEMENT_MAX_COUNT = 6;
|
||||
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
||||
const DORMANT_DECAY_MULTIPLIER = 0.25;
|
||||
```
|
||||
|
||||
Initial strength uses type, source, user importance, and safety-critical factors. Confidence is stored for compatibility but is not used for retention scoring.
|
||||
|
||||
Rendered type caps prevent one type from filling all workspace memory slots:
|
||||
|
||||
| Type | Rendered cap |
|
||||
|------|--------------|
|
||||
| `feedback` | 10 |
|
||||
| `decision` | 10 |
|
||||
| `project` | 8 |
|
||||
| `reference` | 6 |
|
||||
|
||||
Safety-critical memories are exempt from type caps but still compete under the global `maxEntries` limit. Old or stale-marked memories are not hard-pruned by age; they lose rendered space through strength and cap competition.
|
||||
|
||||
## Hot Session State Limits
|
||||
|
||||
```typescript
|
||||
const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 1200, // Maximum characters in system prompt
|
||||
maxRenderedChars: 700, // Maximum characters in system prompt
|
||||
maxActiveFilesStored: 20, // Maximum files tracked in state
|
||||
maxActiveFilesRendered: 8, // Maximum files shown in prompt
|
||||
maxOpenErrorsStored: 5, // Maximum errors tracked
|
||||
@@ -36,6 +62,7 @@ const HOT_STATE_LIMITS = {
|
||||
|
||||
**Recommendations**:
|
||||
- Keep `maxRenderedChars` under 1500 for fast prompts
|
||||
- Defaults are calibrated from observed rendered usage around ~500 characters or less
|
||||
- `maxActiveFilesRendered` of 8 provides good context coverage
|
||||
- `maxOpenErrorsRendered` of 3 avoids overwhelming error lists
|
||||
|
||||
@@ -43,12 +70,12 @@ const HOT_STATE_LIMITS = {
|
||||
|
||||
### Long-Term Memory Types
|
||||
|
||||
| Type | Purpose | Stale After (days) |
|
||||
|------|---------|---------------------|
|
||||
| `feedback` | User preferences for workspace | 90 |
|
||||
| `project` | Project-level information | 60 |
|
||||
| `decision` | Important decisions | 45 |
|
||||
| `reference` | Key references | 90 |
|
||||
| Type | Purpose | Rendered cap |
|
||||
|------|---------|--------------|
|
||||
| `feedback` | User preferences for workspace | 10 |
|
||||
| `project` | Project-level information | 8 |
|
||||
| `decision` | Important decisions | 10 |
|
||||
| `reference` | Key references | 6 |
|
||||
|
||||
### Memory Sources
|
||||
|
||||
@@ -114,7 +141,7 @@ To customize limits, edit the constants in `src/types.ts`:
|
||||
```typescript
|
||||
// Example: Increase workspace memory limit
|
||||
export const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 6000, // Increased from 5200
|
||||
maxRenderedChars: 6000, // Increased from 3600
|
||||
maxEntries: 35, // Increased from 28
|
||||
// ...
|
||||
};
|
||||
@@ -144,7 +171,7 @@ const HOT_STATE_LIMITS = {
|
||||
// Preserve more context
|
||||
const LONG_TERM_LIMITS = {
|
||||
maxEntries: 40, // Increased
|
||||
targetRenderedChars: 5000, // Increased
|
||||
targetRenderedChars: 5000, // Increased
|
||||
};
|
||||
```
|
||||
|
||||
@@ -175,6 +202,16 @@ cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json |
|
||||
cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
|
||||
```
|
||||
|
||||
### Inspect Retention Health
|
||||
|
||||
From a source checkout, maintainers can inspect stored vs rendered memory behavior:
|
||||
|
||||
```bash
|
||||
bun scripts/memory-diag.ts health
|
||||
```
|
||||
|
||||
The health output includes stored active memories, rendered candidates, type caps, global cap overflow, dormancy status, retention monitoring alerts, and strength-ranked top/weakest entries.
|
||||
|
||||
### Clear Workspace Memory
|
||||
|
||||
```bash
|
||||
@@ -192,21 +229,21 @@ rm ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json
|
||||
## Best Practices
|
||||
|
||||
1. **Workspace Memory Hygiene**:
|
||||
- Let the plugin extract memories automatically
|
||||
- Let OpenCode Working Memory extract memories automatically
|
||||
- Use explicit "remember this" for important information
|
||||
- Don't manually edit memory files unless testing
|
||||
|
||||
2. **Session State**:
|
||||
- Let the plugin track active files automatically
|
||||
- Let OpenCode Working Memory track active files automatically
|
||||
- Errors are cleared when commands succeed
|
||||
- No manual intervention needed
|
||||
|
||||
3. **Memory Extraction**:
|
||||
- Use `<workspace_memory_candidates>` during compaction
|
||||
- Use `Memory candidates:` during compaction
|
||||
- Follow the pattern: `- [type] text`
|
||||
- Quality gate rejects invalid candidates
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: April 2026
|
||||
**Configuration File**: `src/types.ts`
|
||||
**Configuration File**: `src/types.ts`
|
||||
|
||||
+13
-13
@@ -10,7 +10,7 @@ Add to your `~/.config/opencode/opencode.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Restart OpenCode. The plugin activates automatically — no manual setup needed.
|
||||
Restart OpenCode. OpenCode Working Memory activates automatically — no manual setup needed.
|
||||
|
||||
> **Note**: The correct key is `plugin` (singular), not `plugins`.
|
||||
|
||||
@@ -25,22 +25,22 @@ Restart OpenCode. The plugin activates automatically — no manual setup needed.
|
||||
After restarting OpenCode, memory context appears automatically in system prompts. You'll see:
|
||||
|
||||
```
|
||||
<workspace_memory>
|
||||
- [decision] ... (if any long-term memories exist)
|
||||
</workspace_memory>
|
||||
Workspace memory (cross-session, verify if stale):
|
||||
decision:
|
||||
- ... (if any long-term memories exist)
|
||||
|
||||
---
|
||||
<workspace_memory_candidates>
|
||||
Memory candidates:
|
||||
- [project] ... (candidates for long-term memory)
|
||||
</workspace_memory_candidates>
|
||||
|
||||
Active Files:
|
||||
Hot session state (current session):
|
||||
active_files:
|
||||
- path/to/file.ts (action, count)
|
||||
|
||||
Open Errors: (none, or listed)
|
||||
open_errors: (none, or listed)
|
||||
```
|
||||
|
||||
**No tools to call**. The plugin works automatically via hooks.
|
||||
**No tools to call**. OpenCode Working Memory works automatically via hooks.
|
||||
|
||||
## How Memory Works
|
||||
|
||||
@@ -72,8 +72,8 @@ Tracks current session:
|
||||
|
||||
**Solution**:
|
||||
1. Ensure OpenCode has write permissions in home directory
|
||||
2. Trigger memory operations by working normally (plugin creates files on-demand)
|
||||
3. Check that plugin is listed in config
|
||||
2. Trigger memory operations by working normally (memory files are created on-demand)
|
||||
3. Check that `opencode-working-memory` is listed in config
|
||||
|
||||
### Memory Not Persisting
|
||||
|
||||
@@ -81,7 +81,7 @@ Tracks current session:
|
||||
|
||||
**Solution**:
|
||||
1. Verify you're in the same workspace (different workspace = different memory)
|
||||
2. Ensure `<workspace_memory_candidates>` were captured during compaction
|
||||
2. Ensure `Memory candidates:` were captured during compaction
|
||||
3. Check `workspace-memory.json` exists
|
||||
|
||||
### Type Errors During Development
|
||||
@@ -132,4 +132,4 @@ rm -rf ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: April 2026
|
||||
**Last Updated**: April 2026
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.0",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
@@ -16,7 +16,9 @@
|
||||
"scripts": {
|
||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts"
|
||||
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts",
|
||||
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
|
||||
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"opencode",
|
||||
@@ -37,7 +39,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/sdwolf4103/opencode-working-memory#readme",
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": "^1.2.0"
|
||||
"@opencode-ai/plugin": ">=1.2.0 <2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Safely inspect or quarantine stale test/temp workspace memory stores.
|
||||
*
|
||||
* Default mode is dry-run. Quarantine moves only definite temp/test residues.
|
||||
* Unknown missing roots are reported but skipped unless --include-orphans is set.
|
||||
*/
|
||||
|
||||
import { cleanupWorkspaceResidues } from "../../src/workspace-cleanup.ts";
|
||||
|
||||
type CliOptions = {
|
||||
mode: "dry-run" | "quarantine";
|
||||
dataHome?: string;
|
||||
olderThanDays?: number;
|
||||
includeOrphans: boolean;
|
||||
};
|
||||
|
||||
function usage(): string {
|
||||
return `Usage:
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
npm run cleanup:workspaces -- --quarantine
|
||||
npm run cleanup:workspaces -- --quarantine --older-than-days 1
|
||||
|
||||
Options:
|
||||
--dry-run List candidates without moving anything (default)
|
||||
--quarantine Move definite temp/test residues to quarantine
|
||||
--data-home <path> Override XDG data home for testing/admin work
|
||||
--older-than-days <n> Only consider workspace dirs older than n days
|
||||
--include-orphans Also quarantine missing non-temp roots (off by default)
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = { mode: "dry-run", includeOrphans: false };
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--dry-run":
|
||||
options.mode = "dry-run";
|
||||
break;
|
||||
case "--quarantine":
|
||||
options.mode = "quarantine";
|
||||
break;
|
||||
case "--data-home":
|
||||
options.dataHome = argv[++i];
|
||||
if (!options.dataHome) throw new Error("--data-home requires a path");
|
||||
break;
|
||||
case "--older-than-days": {
|
||||
const value = Number(argv[++i]);
|
||||
if (!Number.isFinite(value) || value < 0) throw new Error("--older-than-days requires a non-negative number");
|
||||
options.olderThanDays = value;
|
||||
break;
|
||||
}
|
||||
case "--include-orphans":
|
||||
options.includeOrphans = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
throw new Error(`Unknown option: ${arg}\n${usage()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const result = await cleanupWorkspaceResidues({
|
||||
dataHome: options.dataHome,
|
||||
mode: options.mode,
|
||||
includeOrphans: options.includeOrphans,
|
||||
minAgeMs: options.olderThanDays === undefined ? undefined : options.olderThanDays * 24 * 60 * 60 * 1_000,
|
||||
});
|
||||
|
||||
console.log(`Mode: ${result.mode}`);
|
||||
console.log(`Scanned: ${result.results.length}`);
|
||||
console.log(`Candidates: ${result.candidates.length}`);
|
||||
|
||||
if (result.candidates.length > 0) {
|
||||
console.log("\nCandidates:");
|
||||
for (const candidate of result.candidates) {
|
||||
console.log(`- ${candidate.workspaceKey} ${candidate.classification} root=${candidate.root ?? "<missing>"}`);
|
||||
console.log(` reasons=${candidate.reasons.join(",")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.quarantined.length > 0) {
|
||||
console.log(`\nQuarantined: ${result.quarantined.length}`);
|
||||
console.log(`Quarantine dir: ${result.quarantineDir}`);
|
||||
}
|
||||
|
||||
const unknownOrphans = result.results.filter(item => item.classification === "orphan_unknown");
|
||||
if (unknownOrphans.length > 0 && !options.includeOrphans) {
|
||||
console.log(`\nUnknown missing-root workspaces skipped: ${unknownOrphans.length}`);
|
||||
console.log("Use --include-orphans only after manually confirming they are safe to quarantine.");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Local helper to trigger migration on workspace roots.
|
||||
*
|
||||
* Usage:
|
||||
* MIGRATION_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/run-migration.ts
|
||||
*
|
||||
* Or create a local file (gitignored):
|
||||
* echo "/path/to/workspace1" > scripts/dev/run-migration-roots.local.txt
|
||||
* echo "/path/to/workspace2" >> scripts/dev/run-migration-roots.local.txt
|
||||
* bun run scripts/dev/run-migration.ts
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { loadWorkspaceMemory } from "../../src/workspace-memory.ts";
|
||||
|
||||
async function getRoots(): Promise<string[]> {
|
||||
// Priority 1: environment variable
|
||||
const envRoots = process.env.MIGRATION_RUN_ROOTS;
|
||||
if (envRoots) {
|
||||
return envRoots.split(":").filter(root => root.length > 0);
|
||||
}
|
||||
|
||||
// Priority 2: local file
|
||||
const localFile = join(import.meta.dirname, "run-migration-roots.local.txt");
|
||||
if (existsSync(localFile)) {
|
||||
const content = await readFile(localFile, "utf8");
|
||||
return content.trim().split("\n").filter(root => root.length > 0);
|
||||
}
|
||||
|
||||
// No roots configured
|
||||
console.log("No workspace roots configured.");
|
||||
console.log("Set MIGRATION_RUN_ROOTS=/path/a:/path/b or create run-migration-roots.local.txt");
|
||||
return [];
|
||||
}
|
||||
|
||||
const roots = await getRoots();
|
||||
|
||||
if (roots.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
console.log(`Loading workspace memory: ${root}`);
|
||||
const store = await loadWorkspaceMemory(root);
|
||||
const active = store.entries.filter(entry => entry.status !== "superseded").length;
|
||||
const superseded = store.entries.filter(entry => entry.status === "superseded").length;
|
||||
console.log(` active=${active} superseded=${superseded} migrations=${(store.migrations ?? []).join(",")}`);
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Maintainer-only offline diagnostics for memory quality calibration.
|
||||
* Does not send telemetry, make API calls, or affect plugin runtime behavior.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { dataHome, extractionRejectionLogPath, migrationLogPath, workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
|
||||
import { assessMemoryQuality, HARD_QUALITY_REASONS } from "../src/memory-quality.ts";
|
||||
import { redactCredentials } from "../src/redaction.ts";
|
||||
import { scanWorkspaceResidues } from "../src/workspace-cleanup.ts";
|
||||
import { calculateRetentionStrength, renderWorkspaceMemory } from "../src/workspace-memory.ts";
|
||||
import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../src/types.ts";
|
||||
import { LONG_TERM_LIMITS, PROMOTION_RETRY_LIMITS } from "../src/types.ts";
|
||||
|
||||
type Command = "health" | "rejections" | "audit";
|
||||
type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown";
|
||||
|
||||
type CliOptions = {
|
||||
raw: boolean;
|
||||
workspace?: string;
|
||||
all?: boolean;
|
||||
softOnly?: boolean;
|
||||
triggerOnly?: boolean;
|
||||
since?: string;
|
||||
migration?: string;
|
||||
};
|
||||
|
||||
type RejectionLogRecord = {
|
||||
timestamp?: string;
|
||||
workspaceKey?: string;
|
||||
workspaceRoot?: string;
|
||||
type?: LongTermType;
|
||||
source?: LongTermSource | string;
|
||||
origin?: string;
|
||||
fromTrigger?: boolean;
|
||||
text?: string;
|
||||
reasons?: string[];
|
||||
};
|
||||
|
||||
type NormalizedRejection = Required<Pick<RejectionLogRecord, "timestamp" | "type" | "text" | "reasons">> & {
|
||||
workspaceKey?: string;
|
||||
workspaceRoot?: string;
|
||||
source?: string;
|
||||
origin: Origin;
|
||||
fromTrigger: boolean;
|
||||
};
|
||||
|
||||
type MigrationLogRecord = {
|
||||
migrationId?: string;
|
||||
timestamp?: string;
|
||||
workspaceKey?: string;
|
||||
workspaceRoot?: string;
|
||||
entryId?: string;
|
||||
type?: LongTermType;
|
||||
source?: LongTermSource | string;
|
||||
text?: string;
|
||||
reasons?: string[];
|
||||
hardReasons?: string[];
|
||||
beforeStatus?: string;
|
||||
afterStatus?: string;
|
||||
};
|
||||
|
||||
const TYPES: LongTermType[] = ["feedback", "decision", "project", "reference"];
|
||||
const TYPE_MAX_FOR_DIAG: Record<LongTermType, number> = {
|
||||
feedback: 10,
|
||||
decision: 10,
|
||||
project: 8,
|
||||
reference: 6,
|
||||
};
|
||||
const WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG = 14;
|
||||
const DORMANT_DECAY_MULTIPLIER_FOR_DIAG = 0.25;
|
||||
const SUSPICIOUS_REASONS = [
|
||||
"progress_snapshot",
|
||||
"active_file_snapshot",
|
||||
"commit_or_ci_snapshot",
|
||||
"temporary_status",
|
||||
"raw_error",
|
||||
"code_or_api_signature",
|
||||
] as const;
|
||||
const ALLOWED_ORIGINS = new Set<Origin>([
|
||||
"explicit_trigger",
|
||||
"compaction_candidate",
|
||||
"manual",
|
||||
"migration_check",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
function usage(): string {
|
||||
return `Usage:
|
||||
bun scripts/memory-diag.ts health [--workspace <path>] [--all] [--raw]
|
||||
bun scripts/memory-diag.ts rejections [--soft-only] [--trigger-only] [--since 14d] [--raw]
|
||||
bun scripts/memory-diag.ts audit [--migration <id>] [--raw]
|
||||
`;
|
||||
}
|
||||
|
||||
function die(message: string): never {
|
||||
console.error(message);
|
||||
console.error(usage());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): { command: Command; options: CliOptions } {
|
||||
const [command, ...rest] = argv;
|
||||
if (!command || command === "--help" || command === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
if (command !== "health" && command !== "rejections" && command !== "audit") {
|
||||
die(`Unknown subcommand: ${command}`);
|
||||
}
|
||||
|
||||
const options: CliOptions = { raw: false };
|
||||
for (let i = 0; i < rest.length; i += 1) {
|
||||
const arg = rest[i];
|
||||
if (arg === "--raw") options.raw = true;
|
||||
else if (arg === "--all") options.all = true;
|
||||
else if (arg === "--soft-only") options.softOnly = true;
|
||||
else if (arg === "--trigger-only") options.triggerOnly = true;
|
||||
else if (arg === "--workspace") {
|
||||
const value = rest[++i];
|
||||
if (!value) die("--workspace requires a path");
|
||||
options.workspace = value;
|
||||
} else if (arg === "--since") {
|
||||
const value = rest[++i];
|
||||
if (!value) die("--since requires a duration or ISO timestamp");
|
||||
options.since = value;
|
||||
} else if (arg === "--migration") {
|
||||
const value = rest[++i];
|
||||
if (!value) die("--migration requires an id");
|
||||
options.migration = value;
|
||||
} else {
|
||||
die(`Unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (command === "health") {
|
||||
if (options.all && options.workspace) die("Use either --all or --workspace, not both");
|
||||
} else {
|
||||
if (options.all || options.workspace) die(`${command} does not accept --all or --workspace`);
|
||||
}
|
||||
if (command !== "rejections" && (options.softOnly || options.triggerOnly || options.since)) {
|
||||
die(`${command} does not accept rejection filters`);
|
||||
}
|
||||
if (command !== "audit" && options.migration) {
|
||||
die(`${command} does not accept --migration`);
|
||||
}
|
||||
|
||||
return { command, options };
|
||||
}
|
||||
|
||||
function countBy<T extends string>(items: T[]): Map<T, number> {
|
||||
const counts = new Map<T, number>();
|
||||
for (const item of items) counts.set(item, (counts.get(item) ?? 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
function sortedCounts<T extends string>(counts: Map<T, number>): Array<[T, number]> {
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
function workspaceRootHash(root: string): string {
|
||||
return createHash("sha256").update(root).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function redactAbsolutePaths(text: string): string {
|
||||
return text.replace(/(?:^|[\s"'`(=:\[])(\/(?:Users|home|private|tmp|var|opt|Volumes|[^\s"'`)\],;:]+)\/[^\s"'`)\],;:]*)/g, (match, path) => match.replace(path, "<path>"));
|
||||
}
|
||||
|
||||
function cleanText(text: string, raw: boolean): string {
|
||||
if (raw) return text;
|
||||
return redactAbsolutePaths(redactCredentials(text));
|
||||
}
|
||||
|
||||
function cleanPath(path: string, raw: boolean): string {
|
||||
return raw ? path : "<path>";
|
||||
}
|
||||
|
||||
function formatWorkspaceIdentity(workspaceKeyValue: string | undefined, workspaceRoot: string | undefined, raw: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (workspaceKeyValue) parts.push(`workspaceKey=${workspaceKeyValue}`);
|
||||
if (workspaceRoot) {
|
||||
parts.push(raw ? `workspaceRoot=${workspaceRoot}` : `workspaceRootHash=${workspaceRootHash(workspaceRoot)}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function truncate(text: string, max = 120): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
async function readJSONFile<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJSONLFile<T>(path: string): Promise<{ records: T[]; invalidLines: number }> {
|
||||
let content = "";
|
||||
try {
|
||||
content = await readFile(path, "utf8");
|
||||
} catch {
|
||||
return { records: [], invalidLines: 0 };
|
||||
}
|
||||
|
||||
const records: T[] = [];
|
||||
let invalidLines = 0;
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
records.push(JSON.parse(trimmed) as T);
|
||||
} catch {
|
||||
invalidLines += 1;
|
||||
}
|
||||
}
|
||||
return { records, invalidLines };
|
||||
}
|
||||
|
||||
function canonicalMemoryText(text: string): string {
|
||||
return text
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[\s\p{P}]+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function ageDays(entry: LongTermMemoryEntry): number | null {
|
||||
const time = new Date(entry.createdAt).getTime();
|
||||
if (Number.isNaN(time)) return null;
|
||||
return Math.floor((Date.now() - time) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatStrength(value: number): string {
|
||||
return Number.isFinite(value) ? value.toFixed(3) : "0.000";
|
||||
}
|
||||
|
||||
function daysSinceIso(value: string | undefined, now = Date.now()): number | null {
|
||||
if (!value) return null;
|
||||
const ms = new Date(value).getTime();
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return Math.max(0, (now - ms) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatPercent(ratio: number): string {
|
||||
return `${(ratio * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
type RetentionDiagItem = {
|
||||
entry: LongTermMemoryEntry;
|
||||
strength: number;
|
||||
};
|
||||
|
||||
function isSafetyCriticalForDiag(entry: LongTermMemoryEntry): boolean {
|
||||
return entry.safetyCritical === true;
|
||||
}
|
||||
|
||||
function retentionCandidatesForDiag(store: WorkspaceMemoryStore): {
|
||||
sorted: RetentionDiagItem[];
|
||||
rendered: RetentionDiagItem[];
|
||||
typeCapped: RetentionDiagItem[];
|
||||
globalCapped: RetentionDiagItem[];
|
||||
} {
|
||||
const now = Date.now();
|
||||
const active = store.entries.filter(entry => entry.status !== "superseded");
|
||||
const sorted = active
|
||||
.map(entry => ({ entry, strength: calculateRetentionStrength(entry, now, store.lastActivityAt) }))
|
||||
.sort((a, b) => b.strength - a.strength || a.entry.id.localeCompare(b.entry.id));
|
||||
|
||||
const rendered: RetentionDiagItem[] = [];
|
||||
const typeCapped: RetentionDiagItem[] = [];
|
||||
const globalCapped: RetentionDiagItem[] = [];
|
||||
const typeCounts: Partial<Record<LongTermType, number>> = {};
|
||||
|
||||
for (const item of sorted) {
|
||||
if (!isSafetyCriticalForDiag(item.entry)) {
|
||||
const count = typeCounts[item.entry.type] ?? 0;
|
||||
const max = TYPE_MAX_FOR_DIAG[item.entry.type] ?? Infinity;
|
||||
if (count >= max) {
|
||||
typeCapped.push(item);
|
||||
continue;
|
||||
}
|
||||
typeCounts[item.entry.type] = count + 1;
|
||||
}
|
||||
|
||||
if (rendered.length < LONG_TERM_LIMITS.maxEntries) {
|
||||
rendered.push(item);
|
||||
} else {
|
||||
globalCapped.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { sorted, rendered, typeCapped, globalCapped };
|
||||
}
|
||||
|
||||
function promotionLimit(source: LongTermSource): number {
|
||||
if (source === "manual") return PROMOTION_RETRY_LIMITS.maxManualAttempts;
|
||||
return PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
|
||||
}
|
||||
|
||||
function emptyStore(root: string, key: string): WorkspaceMemoryStore {
|
||||
return {
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries: [],
|
||||
migrations: [],
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedStore(store: WorkspaceMemoryStore | null, root: string, key: string): WorkspaceMemoryStore {
|
||||
const fallback = emptyStore(root, key);
|
||||
return {
|
||||
...fallback,
|
||||
...(store ?? {}),
|
||||
workspace: store?.workspace ?? fallback.workspace,
|
||||
limits: {
|
||||
maxRenderedChars: store?.limits?.maxRenderedChars ?? fallback.limits.maxRenderedChars,
|
||||
maxEntries: store?.limits?.maxEntries ?? fallback.limits.maxEntries,
|
||||
},
|
||||
entries: Array.isArray(store?.entries) ? store.entries : [],
|
||||
migrations: Array.isArray(store?.migrations) ? store.migrations : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedJournal(journal: PendingMemoryJournalStore | null): PendingMemoryJournalStore {
|
||||
return {
|
||||
version: 1,
|
||||
workspace: journal?.workspace ?? { root: "", key: "" },
|
||||
entries: Array.isArray(journal?.entries) ? journal.entries : [],
|
||||
updatedAt: journal?.updatedAt ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function runHealth(options: CliOptions): Promise<void> {
|
||||
if (options.all) {
|
||||
const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 });
|
||||
console.log("Workspace memory health");
|
||||
console.log("");
|
||||
if (scan.results.length === 0) {
|
||||
console.log("No workspace stores found.");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < scan.results.length; i += 1) {
|
||||
const result = scan.results[i];
|
||||
if (i > 0) console.log("");
|
||||
await printWorkspaceHealth({
|
||||
root: result.root,
|
||||
key: result.workspaceKey,
|
||||
memoryPath: join(result.workspaceDir, "workspace-memory.json"),
|
||||
pendingPath: join(result.workspaceDir, "workspace-pending-journal.json"),
|
||||
raw: options.raw,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const root = options.workspace ?? process.cwd();
|
||||
const key = await workspaceKey(root);
|
||||
await printWorkspaceHealth({
|
||||
root,
|
||||
key,
|
||||
memoryPath: await workspaceMemoryPath(root),
|
||||
pendingPath: await workspacePendingJournalPath(root),
|
||||
raw: options.raw,
|
||||
includeTitle: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function printWorkspaceHealth(input: {
|
||||
root?: string;
|
||||
key: string;
|
||||
memoryPath: string;
|
||||
pendingPath: string;
|
||||
raw: boolean;
|
||||
includeTitle?: boolean;
|
||||
}): Promise<void> {
|
||||
if (input.includeTitle) {
|
||||
console.log("Workspace memory health");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const rawStore = await readJSONFile<WorkspaceMemoryStore>(input.memoryPath);
|
||||
const storeRoot = rawStore?.workspace?.root ?? input.root ?? "";
|
||||
const storeKey = rawStore?.workspace?.key ?? input.key;
|
||||
const store = normalizedStore(rawStore, storeRoot, storeKey);
|
||||
const journal = normalizedJournal(await readJSONFile<PendingMemoryJournalStore>(input.pendingPath));
|
||||
|
||||
const identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw);
|
||||
if (identity) console.log(identity);
|
||||
console.log(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`);
|
||||
console.log(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`);
|
||||
if (!rawStore) console.log("memory store: missing or unreadable (treated as empty)");
|
||||
if (!existsSync(input.pendingPath)) console.log("pending journal: missing (treated as empty)");
|
||||
console.log("");
|
||||
|
||||
const active = store.entries.filter(entry => entry.status !== "superseded");
|
||||
const superseded = store.entries.filter(entry => entry.status === "superseded");
|
||||
const retention = retentionCandidatesForDiag(store);
|
||||
const renderedEntries = retention.rendered.map(item => item.entry);
|
||||
const renderedEstimate = renderWorkspaceMemory(store).length;
|
||||
|
||||
console.log(`Stored active memories: ${active.length}`);
|
||||
console.log(`Superseded memories: ${superseded.length}`);
|
||||
console.log(`Rendered candidates: ${renderedEntries.length}`);
|
||||
console.log(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`);
|
||||
console.log("");
|
||||
|
||||
const pendingEntries = journal.entries;
|
||||
const retryable = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) < promotionLimit(entry.source)).length;
|
||||
const nearRetryLimit = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) >= promotionLimit(entry.source) - 1).length;
|
||||
const pendingBySource = countBy(pendingEntries.map(entry => entry.source));
|
||||
console.log("Pending journal:");
|
||||
console.log(` total: ${pendingEntries.length}`);
|
||||
console.log(` retryable: ${retryable}`);
|
||||
console.log(` near retry limit: ${nearRetryLimit}`);
|
||||
console.log(" by source:");
|
||||
for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) {
|
||||
console.log(` ${source}: ${pendingBySource.get(source) ?? 0}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
console.log("By type:");
|
||||
for (const type of TYPES) {
|
||||
const storedCount = active.filter(entry => entry.type === type).length;
|
||||
const renderedCount = renderedEntries.filter(entry => entry.type === type).length;
|
||||
const supersededCount = superseded.filter(entry => entry.type === type).length;
|
||||
console.log(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${TYPE_MAX_FOR_DIAG[type]} superseded=${supersededCount}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
console.log("Retention caps:");
|
||||
console.log(` type-capped entries: ${retention.typeCapped.length}`);
|
||||
console.log(` global-cap overflow: ${retention.globalCapped.length}`);
|
||||
console.log("");
|
||||
|
||||
const olderThan30 = active.filter(entry => (ageDays(entry) ?? 0) > 30).length;
|
||||
const olderThan90 = active.filter(entry => (ageDays(entry) ?? 0) > 90).length;
|
||||
const staleMarked = active.filter(entry => {
|
||||
const days = ageDays(entry);
|
||||
return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays);
|
||||
}).length;
|
||||
console.log("Age:");
|
||||
console.log(` stale-marked: ${staleMarked}`);
|
||||
console.log(` older than 30d: ${olderThan30}`);
|
||||
console.log(` older than 90d: ${olderThan90}`);
|
||||
console.log("");
|
||||
|
||||
const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt);
|
||||
const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG;
|
||||
const dormantDaysPastGrace = wallDaysSinceActivity === null
|
||||
? 0
|
||||
: Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS_FOR_DIAG);
|
||||
console.log("Dormancy:");
|
||||
console.log(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`);
|
||||
console.log(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`);
|
||||
console.log(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`);
|
||||
console.log(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`);
|
||||
console.log(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER_FOR_DIAG}`);
|
||||
console.log("");
|
||||
|
||||
const highImportanceCount = active.filter(entry => entry.userImportance === "high").length;
|
||||
const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length;
|
||||
const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length;
|
||||
const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length;
|
||||
const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length;
|
||||
const highImportanceAlert = highImportanceRatio > 0.3;
|
||||
const safetyCriticalAlert = safetyCriticalCount > 5;
|
||||
const maxReinforcedAlert = maxReinforcedRatio > 0.1;
|
||||
console.log("Retention monitoring:");
|
||||
console.log(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`);
|
||||
console.log(` safety_critical_count: ${safetyCriticalCount} (alert > 5)${safetyCriticalAlert ? " ALERT" : ""}`);
|
||||
console.log(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`);
|
||||
console.log("");
|
||||
|
||||
const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) }));
|
||||
const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`));
|
||||
const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
|
||||
console.log("Quality warnings:");
|
||||
console.log(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`);
|
||||
console.log(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`);
|
||||
console.log(` duplicate-ish exact canonical text: ${duplicateExtras}`);
|
||||
console.log(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`);
|
||||
console.log("");
|
||||
|
||||
console.log("Suspicious active memories:");
|
||||
for (const reason of SUSPICIOUS_REASONS) {
|
||||
console.log(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`);
|
||||
}
|
||||
|
||||
const failingQuality = qualityByEntry.filter(item => !item.quality.accepted);
|
||||
if (failingQuality.length > 0) {
|
||||
console.log("");
|
||||
console.log("Active memories failing offline quality checks:");
|
||||
for (const item of failingQuality.slice(0, 8)) {
|
||||
console.log(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Top rendered candidates:");
|
||||
const top = retention.rendered.slice(0, 5);
|
||||
if (top.length === 0) {
|
||||
console.log(" (none)");
|
||||
} else {
|
||||
for (const item of top) {
|
||||
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Weakest active memories:");
|
||||
const weakest = retention.sorted.slice(-5).reverse();
|
||||
if (weakest.length === 0) {
|
||||
console.log(" (none)");
|
||||
} else {
|
||||
for (const item of weakest) {
|
||||
console.log(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inferOrigin(record: RejectionLogRecord): Origin {
|
||||
if (record.origin && ALLOWED_ORIGINS.has(record.origin as Origin)) return record.origin as Origin;
|
||||
if (record.source === "compaction") return "compaction_candidate";
|
||||
if (record.source === "explicit") return "explicit_trigger";
|
||||
if (record.source === "manual") return "manual";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function normalizeRejection(record: RejectionLogRecord): NormalizedRejection | null {
|
||||
if (!record.text || !Array.isArray(record.reasons)) return null;
|
||||
const origin = inferOrigin(record);
|
||||
return {
|
||||
timestamp: record.timestamp ?? "",
|
||||
workspaceKey: record.workspaceKey,
|
||||
workspaceRoot: record.workspaceRoot,
|
||||
type: record.type ?? "project",
|
||||
source: record.source,
|
||||
origin,
|
||||
fromTrigger: typeof record.fromTrigger === "boolean" ? record.fromTrigger : origin === "explicit_trigger",
|
||||
text: record.text,
|
||||
reasons: record.reasons,
|
||||
};
|
||||
}
|
||||
|
||||
function sinceCutoff(rawSince: string | undefined): number | null {
|
||||
if (!rawSince) return null;
|
||||
const relative = rawSince.match(/^(\d+)([dhm])$/i);
|
||||
if (relative) {
|
||||
const amount = Number(relative[1]);
|
||||
const unit = relative[2].toLowerCase();
|
||||
const multiplier = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : 60_000;
|
||||
return Date.now() - amount * multiplier;
|
||||
}
|
||||
const timestamp = new Date(rawSince).getTime();
|
||||
if (Number.isNaN(timestamp)) die(`Invalid --since value: ${rawSince}`);
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function hasSoftReason(record: NormalizedRejection): boolean {
|
||||
return record.reasons.some(reason => !HARD_QUALITY_REASONS.has(reason));
|
||||
}
|
||||
|
||||
async function runRejections(options: CliOptions): Promise<void> {
|
||||
const path = extractionRejectionLogPath();
|
||||
const { records, invalidLines } = await readJSONLFile<RejectionLogRecord>(path);
|
||||
const cutoff = sinceCutoff(options.since);
|
||||
let normalized = records.map(normalizeRejection).filter((record): record is NormalizedRejection => record !== null);
|
||||
if (cutoff !== null) {
|
||||
normalized = normalized.filter(record => {
|
||||
const timestamp = new Date(record.timestamp).getTime();
|
||||
return !Number.isNaN(timestamp) && timestamp >= cutoff;
|
||||
});
|
||||
}
|
||||
if (options.softOnly) normalized = normalized.filter(hasSoftReason);
|
||||
if (options.triggerOnly) normalized = normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger");
|
||||
|
||||
console.log("Extraction rejection summary");
|
||||
console.log("");
|
||||
console.log(`logPath=${cleanPath(path, options.raw)}`);
|
||||
if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`);
|
||||
console.log("");
|
||||
console.log(`Total rejected: ${normalized.length}`);
|
||||
console.log("");
|
||||
|
||||
console.log("By reason:");
|
||||
const byReason = sortedCounts(countBy(normalized.flatMap(record => record.reasons)));
|
||||
if (byReason.length === 0) console.log(" (none)");
|
||||
else for (const [reason, count] of byReason) console.log(` ${reason.padEnd(24)} ${count}`);
|
||||
console.log("");
|
||||
|
||||
console.log("By origin:");
|
||||
const byOrigin = sortedCounts(countBy(normalized.map(record => record.origin)));
|
||||
if (byOrigin.length === 0) console.log(" (none)");
|
||||
else for (const [origin, count] of byOrigin) console.log(` ${origin.padEnd(24)} ${count}`);
|
||||
console.log("");
|
||||
|
||||
console.log("Trigger-origin rejections (high priority for v1.5):");
|
||||
const triggerReasons = sortedCounts(countBy(normalized.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons)));
|
||||
if (triggerReasons.length === 0) console.log(" (none)");
|
||||
else for (const [reason, count] of triggerReasons) console.log(` ${reason.padEnd(24)} ${count}`);
|
||||
console.log("");
|
||||
|
||||
console.log("Recent suspicious soft rejects:");
|
||||
const suspicious = normalized
|
||||
.filter(hasSoftReason)
|
||||
.sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0))
|
||||
.slice(0, 8);
|
||||
if (suspicious.length === 0) {
|
||||
console.log(" (none)");
|
||||
} else {
|
||||
for (const record of suspicious) {
|
||||
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
|
||||
console.log(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, options.raw)))}`);
|
||||
console.log(` reasons: ${record.reasons.join(",")}`);
|
||||
console.log(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrationLogsRoot(): string {
|
||||
return join(dataHome(), "opencode-working-memory", "migration-logs");
|
||||
}
|
||||
|
||||
async function migrationLogPaths(options: CliOptions): Promise<string[]> {
|
||||
if (options.migration) return [migrationLogPath(options.migration)];
|
||||
const root = migrationLogsRoot();
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = await readdir(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries.filter(entry => entry.endsWith(".jsonl")).sort().map(entry => join(root, entry));
|
||||
}
|
||||
|
||||
function migrationIdFromPath(path: string): string {
|
||||
return path.split("/").pop()?.replace(/\.jsonl$/, "") ?? "unknown";
|
||||
}
|
||||
|
||||
function riskySupersedeReasons(record: MigrationLogRecord): string[] {
|
||||
const reasons: string[] = [];
|
||||
const hardReasonsMissing = !Array.isArray(record.hardReasons);
|
||||
const hardReasons = Array.isArray(record.hardReasons) ? record.hardReasons : [];
|
||||
const qualityReasons = Array.isArray(record.reasons) ? record.reasons : [];
|
||||
const text = record.text ?? "";
|
||||
|
||||
if (hardReasonsMissing || hardReasons.length === 0) reasons.push("missing_or_empty_hardReasons");
|
||||
if (qualityReasons.length > 0 && hardReasons.length === 0) reasons.push("soft_reasons_without_hardReasons");
|
||||
if (/\b(?:User|user|prefers|requires|wants|insists)\b|用戶|使用者|偏好|要求|不要|不刪除/u.test(text)) reasons.push("user_preference_marker");
|
||||
if (/\b(?:must|should|do not|never|is|are|follows)\b|必須|應該|採用|維持|需支援/iu.test(text)) reasons.push("durable_rule_marker");
|
||||
if ((record.type === "feedback" || record.type === "decision") && hardReasons.length === 1 && hardReasons[0] === "path_heavy") {
|
||||
reasons.push("feedback_or_decision_path_heavy_only");
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
async function runAudit(options: CliOptions): Promise<void> {
|
||||
const paths = await migrationLogPaths(options);
|
||||
console.log("Migration audit report");
|
||||
console.log("");
|
||||
if (paths.length === 0) {
|
||||
console.log("No migration logs found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < paths.length; i += 1) {
|
||||
const path = paths[i];
|
||||
const migrationId = options.migration ?? migrationIdFromPath(path);
|
||||
const { records, invalidLines } = await readJSONLFile<MigrationLogRecord>(path);
|
||||
const superseded = records.filter(record => !record.afterStatus || record.afterStatus === "superseded");
|
||||
const hardReasons = superseded.flatMap(record => {
|
||||
if (Array.isArray(record.hardReasons)) return record.hardReasons;
|
||||
return Array.isArray(record.reasons) ? record.reasons.filter(reason => HARD_QUALITY_REASONS.has(reason)) : [];
|
||||
});
|
||||
const risky = superseded
|
||||
.map(record => ({ record, reasons: riskySupersedeReasons(record) }))
|
||||
.filter(item => item.reasons.length > 0);
|
||||
|
||||
if (i > 0) console.log("");
|
||||
console.log(`Migration: ${migrationId}`);
|
||||
console.log(`logPath=${cleanPath(path, options.raw)}`);
|
||||
if (invalidLines > 0) console.log(`Invalid JSONL lines skipped: ${invalidLines}`);
|
||||
console.log(`Superseded entries: ${superseded.length}`);
|
||||
console.log("");
|
||||
|
||||
console.log("By hard reason:");
|
||||
const byHardReason = sortedCounts(countBy(hardReasons));
|
||||
if (byHardReason.length === 0) console.log(" (none)");
|
||||
else for (const [reason, count] of byHardReason) console.log(` ${reason.padEnd(24)} ${count}`);
|
||||
console.log("");
|
||||
|
||||
console.log("Potentially risky supersedes:");
|
||||
console.log(` ${risky.length}`);
|
||||
for (const item of risky.slice(0, 10)) {
|
||||
const record = item.record;
|
||||
const hard = Array.isArray(record.hardReasons) ? record.hardReasons : [];
|
||||
const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, options.raw);
|
||||
console.log(` - [${record.type ?? "unknown"}] hardReasons=${JSON.stringify(hard)} risk=${item.reasons.join(",")} ${JSON.stringify(truncate(cleanText(record.text ?? "", options.raw)))}`);
|
||||
if (identity) console.log(` ${identity}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { command, options } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (command === "health") await runHealth(options);
|
||||
else if (command === "rejections") await runRejections(options);
|
||||
else await runAudit(options);
|
||||
+145
-40
@@ -1,6 +1,11 @@
|
||||
import { createHash } from "crypto";
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import { extractionRejectionLogPath } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
|
||||
function id(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -27,6 +32,16 @@ function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Japanese negative
|
||||
if (/(?:覚えないで|記憶しないで|メモしないで)\s*$/u.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Korean negative
|
||||
if (/(?:기억하지\s*마|기억하지마|메모하지\s*마|메모하지마)\s*$/u.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -35,9 +50,13 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
|
||||
const patterns = [
|
||||
// 中文:請/幫我 + 記住 + 可選後綴
|
||||
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/gim,
|
||||
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/gim,
|
||||
// 日文(長詞優先):覚えておいて must come before 覚えて
|
||||
/(?:^|\n)\s*(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/gim,
|
||||
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
|
||||
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
|
||||
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
|
||||
/(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim,
|
||||
/(?:^|\n)\s*(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/gim,
|
||||
// save/add to memory
|
||||
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/gim,
|
||||
// commit to memory
|
||||
@@ -49,7 +68,8 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
/(?:^|\n)\s*(?:my preference is|i prefer)[::,,]?\s*(.+)$/gim,
|
||||
];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -82,6 +102,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
retentionClock: nowMs,
|
||||
staleAfterDays: staleAfterDaysFor(type),
|
||||
});
|
||||
}
|
||||
@@ -179,74 +200,158 @@ export function classifyCommand(command: string): OpenError["category"] | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCandidateBody(body: string): { text: string; hadTrigger: boolean } | null {
|
||||
const text = body.trim();
|
||||
const triggerPatterns = [
|
||||
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im,
|
||||
/(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im,
|
||||
/(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im,
|
||||
];
|
||||
|
||||
for (const pattern of triggerPatterns) {
|
||||
const match = pattern.exec(text);
|
||||
if (!match) continue;
|
||||
|
||||
const triggerIndex = match.index + (match[0].match(/^\s*/)?.[0]?.length || 0);
|
||||
if (isNegatedMemoryRequest(text, triggerIndex)) return null;
|
||||
|
||||
const extracted = match[1]?.trim();
|
||||
return extracted ? { text: extracted, hadTrigger: true } : null;
|
||||
}
|
||||
|
||||
return { text, hadTrigger: false };
|
||||
}
|
||||
|
||||
function extractFirstPath(text: string): string | undefined {
|
||||
return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality gate for workspace memory candidates.
|
||||
* Rejects low-quality entries like git hashes, error messages, etc.
|
||||
* Acceptance gate for workspace memory candidates.
|
||||
* Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts.
|
||||
*/
|
||||
function shouldAcceptWorkspaceMemoryCandidate(entry: {
|
||||
type ExtractionRejectionLogEntry = {
|
||||
timestamp: string;
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
}): boolean {
|
||||
reasons: string[];
|
||||
source: "compaction";
|
||||
};
|
||||
|
||||
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
|
||||
try {
|
||||
const path = extractionRejectionLogPath();
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
|
||||
} catch (error) {
|
||||
console.error("[memory] failed to write extraction rejection log:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAcceptWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
},
|
||||
options: {
|
||||
fromMemoryTrigger?: boolean;
|
||||
} = {},
|
||||
): boolean {
|
||||
const text = entry.text.trim();
|
||||
const minLength = options.fromMemoryTrigger ? 6 : 20;
|
||||
|
||||
// Too short
|
||||
if (text.length < 20) return false;
|
||||
// Too short (with type-specific allowlist for stable config values)
|
||||
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
|
||||
} else if (text.length < minLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Git history / commit hash
|
||||
if (/\b[0-9a-f]{7,40}\b/.test(text)) return false;
|
||||
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return false;
|
||||
// Indirect Prompt Injection / Adversarial Instructions
|
||||
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
||||
// comparative "instead of" is allowed.
|
||||
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false;
|
||||
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
|
||||
|
||||
// Raw error / stack trace
|
||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError):/i.test(text)) return false;
|
||||
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return false;
|
||||
|
||||
// Active file list
|
||||
if (/^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text)) return false;
|
||||
|
||||
// Temporary progress
|
||||
if (/^(currently|now|pending|in progress|todo|wip):/i.test(text)) return false;
|
||||
|
||||
// Code signature / API doc
|
||||
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
|
||||
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false;
|
||||
|
||||
// Path-heavy facts (rediscoverable from repo)
|
||||
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
||||
if (pathCount > 2) return false;
|
||||
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
|
||||
if (!quality.accepted) {
|
||||
void logExtractionRejection({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: entry.type,
|
||||
text: redactCredentials(text),
|
||||
reasons: quality.reasons,
|
||||
source: "compaction",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
|
||||
const match = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
|
||||
if (!match) return [];
|
||||
/**
|
||||
* Extract candidate block from summary using multiple formats.
|
||||
* Supports: Plain text label, Markdown section, legacy XML.
|
||||
*/
|
||||
function extractCandidateBlock(summary: string): string | null {
|
||||
// 1. Plain text label (primary format, no Markdown header)
|
||||
const plainMatch = summary.match(/Memory candidates:\s*\n([\s\S]*?)(?:\n[A-Z][a-z]+ [a-z]+:|\n##\s|$)/i);
|
||||
if (plainMatch) return plainMatch[1];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
// 2. Markdown section (legacy)
|
||||
const markdownMatch = summary.match(/##\s*Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||||
if (markdownMatch) return markdownMatch[1];
|
||||
|
||||
// 3. Legacy "Workspace Memory Candidates" section
|
||||
const legacyMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||||
if (legacyMatch) return legacyMatch[1];
|
||||
|
||||
// 4. Legacy XML block (backward compatible)
|
||||
const xmlMatch = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
|
||||
if (xmlMatch) return xmlMatch[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
|
||||
const block = extractCandidateBlock(summary);
|
||||
if (!block) return [];
|
||||
|
||||
const nowMs = Date.now();
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
|
||||
for (const line of match[1].split("\n")) {
|
||||
const item = line.trim().match(/^-\s*\[(feedback|project|decision|reference)\]\s*(.+)$/i);
|
||||
for (const line of block.split("\n")) {
|
||||
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
||||
const item = line.trim().match(
|
||||
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
|
||||
);
|
||||
if (!item) continue;
|
||||
const type = item[1].toLowerCase() as LongTermType;
|
||||
const body = item[2].trim();
|
||||
if (body.length < 12) continue;
|
||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||
const normalizedBody = normalizeCandidateBody(item[3]);
|
||||
if (!normalizedBody) continue;
|
||||
|
||||
const minLength = normalizedBody.hadTrigger ? 6 : 12;
|
||||
if (normalizedBody.text.length < minLength) continue;
|
||||
|
||||
// Apply quality gate
|
||||
if (!shouldAcceptWorkspaceMemoryCandidate({ type, text: body })) continue;
|
||||
if (!shouldAcceptWorkspaceMemoryCandidate(
|
||||
{ type, text: normalizedBody.text },
|
||||
{ fromMemoryTrigger: normalizedBody.hadTrigger },
|
||||
)) continue;
|
||||
|
||||
entries.push({
|
||||
id: id("mem"),
|
||||
type,
|
||||
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
retentionClock: nowMs,
|
||||
staleAfterDays: staleAfterDaysFor(type),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { LongTermMemoryEntry, LongTermSource } from "./types.ts";
|
||||
|
||||
export type MemoryQualityInput = Pick<LongTermMemoryEntry, "type" | "text"> & {
|
||||
source?: LongTermSource;
|
||||
};
|
||||
|
||||
export type MemoryQualityResult = {
|
||||
accepted: boolean;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||
"empty",
|
||||
"progress_snapshot",
|
||||
"raw_error",
|
||||
"commit_or_ci_snapshot",
|
||||
"temporary_status",
|
||||
"active_file_snapshot",
|
||||
"code_or_api_signature",
|
||||
"path_heavy",
|
||||
]);
|
||||
|
||||
export function isHardQualityReason(reason: string): boolean {
|
||||
return HARD_QUALITY_REASONS.has(reason);
|
||||
}
|
||||
|
||||
export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult {
|
||||
const reasons: string[] = [];
|
||||
const text = entry.text.trim();
|
||||
|
||||
if (text.length === 0) reasons.push("empty");
|
||||
if (isProgressSnapshotViolation(text)) reasons.push("progress_snapshot");
|
||||
if (isRawErrorViolation(text)) reasons.push("raw_error");
|
||||
if (isCommitOrCiViolation(text)) reasons.push("commit_or_ci_snapshot");
|
||||
if (isPathHeavyViolation(text)) reasons.push("path_heavy");
|
||||
if (isTemporaryStatusViolation(text)) reasons.push("temporary_status");
|
||||
if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot");
|
||||
if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature");
|
||||
if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback");
|
||||
if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision");
|
||||
|
||||
return { accepted: reasons.length === 0, reasons };
|
||||
}
|
||||
|
||||
export function isProgressSnapshotViolation(text: string): boolean {
|
||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||
|
||||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||||
if (hasSnapshotContext && !hasLimitContext) return true;
|
||||
}
|
||||
|
||||
if (/\b(?:completed|done|finished|implemented|added|updated|fixed|reviewed|passed|modified)\b/i.test(text)) {
|
||||
if (/\b(?:wave|phase|task|plan|pr|commit|ci|test|suite|implementation|session|change|fix|review|file)\b/i.test(text)) return true;
|
||||
}
|
||||
if (/(?:已完成|完成|修復|实现|實作).{0,40}(?:wave|phase|task|plan|PR|測試|测试|實作|实现|修復)/iu.test(text)) return true;
|
||||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||||
if (/completed|done|finished|完成|已完成/i.test(text)) return true;
|
||||
}
|
||||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||||
if (/\b(?:currently|right now|latest change|previous session|last wave|next step)\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isFeedbackQualityViolation(text: string): boolean {
|
||||
const stablePreference = /\b(?:user|the user)\s+(?:prefers|wants|asked|expects|requires|likes|dislikes)\b/i.test(text)
|
||||
|| /\b(?:prefer|preference|going forward|from now on|always|never)\b/i.test(text)
|
||||
|| /(?:使用者|用戶|用户).{0,12}(?:偏好|希望|要求|想要)/u.test(text)
|
||||
|| /(?:以後|以后|請|请).{0,20}(?:使用|回答|保持|避免)/u.test(text);
|
||||
|
||||
if (stablePreference) return false;
|
||||
|
||||
const internalNote = /\b(?:implemented|updated|fixed|reviewed|added|changed|modified|created|writes|wrote)\b/i.test(text);
|
||||
if (internalNote) return true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isDecisionQualityViolation(text: string): boolean {
|
||||
const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|
||||
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
|
||||
if (!futureRule) return true;
|
||||
if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true;
|
||||
if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isRawErrorViolation(text: string): boolean {
|
||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
|
||||
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isCommitOrCiViolation(text: string): boolean {
|
||||
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return true;
|
||||
if (/\b[0-9a-f]{7,40}\b/.test(text)) return true;
|
||||
if (/\bCI\b.*\b(?:passed|failed|run|compatibility|flaky)\b/i.test(text)) return true;
|
||||
if (/\b(?:passed|failed|run|compatibility|flaky)\b.*\bCI\b/i.test(text)) return true;
|
||||
if (/\bcompatibility\s+run\s+\d+/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPathHeavyViolation(text: string): boolean {
|
||||
const pathCount = (text.match(/\/[\w.-]+(?:\/[\w.-]+)+/g) || []).length;
|
||||
return pathCount > 2;
|
||||
}
|
||||
|
||||
function isTemporaryStatusViolation(text: string): boolean {
|
||||
if (/^(currently|now|pending|in progress|todo|wip)\b/i.test(text)) return true;
|
||||
if (/\b(?:run npm test|tests? are running|next reply|before continuing)\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isActiveFileSnapshotViolation(text: string): boolean {
|
||||
return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text);
|
||||
}
|
||||
|
||||
function isCodeOrApiSignatureViolation(text: string): boolean {
|
||||
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return true;
|
||||
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -20,7 +20,19 @@ export async function workspaceMemoryPath(root: string): Promise<string> {
|
||||
return join(await memoryRoot(root), "workspace-memory.json");
|
||||
}
|
||||
|
||||
export async function workspacePendingJournalPath(root: string): Promise<string> {
|
||||
return join(await memoryRoot(root), "workspace-pending-journal.json");
|
||||
}
|
||||
|
||||
export async function sessionStatePath(root: string, sessionID: string): Promise<string> {
|
||||
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
|
||||
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
|
||||
}
|
||||
|
||||
export function migrationLogPath(migrationId: string): string {
|
||||
return join(dataHome(), "opencode-working-memory", "migration-logs", `${migrationId}.jsonl`);
|
||||
}
|
||||
|
||||
export function extractionRejectionLogPath(): string {
|
||||
return join(dataHome(), "opencode-working-memory", "extraction-rejections.jsonl");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts";
|
||||
import { PROMOTION_RETRY_LIMITS } from "./types.ts";
|
||||
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
|
||||
/**
|
||||
* Retention limits for the pending memory journal.
|
||||
*
|
||||
* The journal is a scratchpad for memories that haven't been promoted to
|
||||
* workspace memory yet. It should not grow unboundedly:
|
||||
* - maxEntries: Hard cap on number of pending entries
|
||||
* - maxAgeDays: Prune entries older than this (compaction candidates that
|
||||
* were never promoted)
|
||||
*/
|
||||
export const PENDING_JOURNAL_LIMITS = {
|
||||
maxEntries: 50,
|
||||
maxAgeDays: 30,
|
||||
} as const;
|
||||
|
||||
function normalizeMemoryText(text: string): string {
|
||||
return text
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[\s\p{P}]+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function memoryKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
|
||||
return `${entry.type}:${normalizeMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
export async function emptyPendingJournal(root: string): Promise<PendingMemoryJournalStore> {
|
||||
return {
|
||||
version: 1,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
entries: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const result: LongTermMemoryEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = `${memoryKey(entry)}\u0000${entry.pendingOwnerSessionID ?? ""}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(entry);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective timestamp for an entry, preferring updatedAt over createdAt.
|
||||
* Returns 0 if both are invalid/missing.
|
||||
*/
|
||||
function entryTime(entry: LongTermMemoryEntry): number {
|
||||
const updatedAt = entry.updatedAt ? new Date(entry.updatedAt).getTime() : NaN;
|
||||
if (!Number.isNaN(updatedAt)) return updatedAt;
|
||||
|
||||
const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : NaN;
|
||||
if (!Number.isNaN(createdAt)) return createdAt;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isStaleEntry(entry: LongTermMemoryEntry, maxAgeDays: number): boolean {
|
||||
const time = entryTime(entry);
|
||||
|
||||
// Invalid timestamps are corruption safety and apply to every source.
|
||||
if (time === 0) return true;
|
||||
|
||||
// TTL policy applies only to compaction candidates. Explicit/manual entries
|
||||
// represent user intent and should survive age while under the hard cap.
|
||||
if (entry.source !== "compaction") return false;
|
||||
|
||||
return Date.now() - time > maxAgeDays * 86_400_000;
|
||||
}
|
||||
|
||||
function applyRetention(
|
||||
entries: LongTermMemoryEntry[],
|
||||
maxEntries: number,
|
||||
maxAgeDays: number,
|
||||
): LongTermMemoryEntry[] {
|
||||
const deduped = dedupeByText(entries);
|
||||
const freshEntries = deduped.filter(entry => !isStaleEntry(entry, maxAgeDays));
|
||||
const sorted = [...freshEntries].sort((a, b) => {
|
||||
const timeDiff = entryTime(b) - entryTime(a);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const capped = sorted.slice(0, maxEntries);
|
||||
return capped.sort((a, b) => {
|
||||
const timeDiff = entryTime(a) - entryTime(b);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeJournal(
|
||||
root: string,
|
||||
store: PendingMemoryJournalStore,
|
||||
): Promise<PendingMemoryJournalStore> {
|
||||
return workspaceKey(root).then(key => ({
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
entries: applyRetention(
|
||||
Array.isArray(store.entries) ? store.entries : [],
|
||||
PENDING_JOURNAL_LIMITS.maxEntries,
|
||||
PENDING_JOURNAL_LIMITS.maxAgeDays,
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function loadPendingJournal(root: string): Promise<PendingMemoryJournalStore> {
|
||||
const path = await workspacePendingJournalPath(root);
|
||||
const fallback = await emptyPendingJournal(root);
|
||||
const loaded = await readJSON(path, () => fallback) as Partial<PendingMemoryJournalStore>;
|
||||
return normalizeJournal(root, {
|
||||
version: loaded.version ?? 1,
|
||||
workspace: loaded.workspace ?? fallback.workspace,
|
||||
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
|
||||
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function savePendingJournal(root: string, store: PendingMemoryJournalStore): Promise<void> {
|
||||
await atomicWriteJSON(await workspacePendingJournalPath(root), await normalizeJournal(root, store));
|
||||
}
|
||||
|
||||
export async function updatePendingJournal(
|
||||
root: string,
|
||||
updater: (store: PendingMemoryJournalStore) => PendingMemoryJournalStore | Promise<PendingMemoryJournalStore>,
|
||||
): Promise<PendingMemoryJournalStore> {
|
||||
const path = await workspacePendingJournalPath(root);
|
||||
const fallback = await emptyPendingJournal(root);
|
||||
return updateJSON(path, () => fallback, async current => {
|
||||
const normalized = await normalizeJournal(root, current);
|
||||
return normalizeJournal(root, await updater(normalized));
|
||||
});
|
||||
}
|
||||
|
||||
export async function appendPendingMemories(root: string, memories: LongTermMemoryEntry[]): Promise<void> {
|
||||
if (memories.length === 0) return;
|
||||
await updatePendingJournal(root, store => {
|
||||
store.entries.push(...memories);
|
||||
return store;
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasPendingJournalEntries(root: string): Promise<boolean> {
|
||||
const journal = await loadPendingJournal(root);
|
||||
return journal.entries.length > 0;
|
||||
}
|
||||
|
||||
export async function clearPendingMemories(
|
||||
root: string,
|
||||
keys?: Set<string>,
|
||||
options: { ownerSessionID?: string; clearUnowned?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await updatePendingJournal(root, store => {
|
||||
if (!keys || keys.size === 0) {
|
||||
store.entries = [];
|
||||
return store;
|
||||
}
|
||||
|
||||
store.entries = store.entries.filter(entry => {
|
||||
if (!keys.has(memoryKey(entry))) return true;
|
||||
|
||||
if (options.ownerSessionID) {
|
||||
if (entry.pendingOwnerSessionID === options.ownerSessionID) return false;
|
||||
if (options.clearUnowned && !entry.pendingOwnerSessionID) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.clearUnowned) {
|
||||
return Boolean(entry.pendingOwnerSessionID);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
return store;
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordPromotionRejections(
|
||||
root: string,
|
||||
keys: Set<string>,
|
||||
reason: string,
|
||||
options: { ownerSessionID?: string; includeUnownedOnly?: boolean } = {},
|
||||
): Promise<Set<string>> {
|
||||
const exhausted = new Set<string>();
|
||||
if (keys.size === 0) return exhausted;
|
||||
|
||||
await updatePendingJournal(root, store => {
|
||||
const nowIso = new Date().toISOString();
|
||||
const exhaustedEntries = new Set<string>();
|
||||
|
||||
store.entries = store.entries.map(entry => {
|
||||
const key = memoryKey(entry);
|
||||
if (!keys.has(key)) return entry;
|
||||
if (options.ownerSessionID && entry.pendingOwnerSessionID !== options.ownerSessionID) return entry;
|
||||
if (!options.ownerSessionID && options.includeUnownedOnly && entry.pendingOwnerSessionID) return entry;
|
||||
|
||||
const promotionAttempts = (entry.promotionAttempts ?? 0) + 1;
|
||||
const max = entry.source === "manual"
|
||||
? PROMOTION_RETRY_LIMITS.maxManualAttempts
|
||||
: PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
|
||||
|
||||
if (promotionAttempts >= max) {
|
||||
exhausted.add(key);
|
||||
exhaustedEntries.add(`${key}\u0000${entry.pendingOwnerSessionID ?? ""}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
promotionAttempts,
|
||||
lastPromotionAttemptAt: nowIso,
|
||||
lastPromotionFailureReason: reason,
|
||||
};
|
||||
});
|
||||
|
||||
store.entries = store.entries.filter(entry => (
|
||||
!exhaustedEntries.has(`${memoryKey(entry)}\u0000${entry.pendingOwnerSessionID ?? ""}`)
|
||||
));
|
||||
return store;
|
||||
});
|
||||
|
||||
return exhausted;
|
||||
}
|
||||
+374
-85
@@ -2,10 +2,18 @@
|
||||
* Memory V2 Plugin for OpenCode
|
||||
*
|
||||
* Architecture:
|
||||
* - Layer 1: Stable Workspace Memory (frozen per session, refreshed at compaction)
|
||||
* - Layer 2: Hot Session State (active files, open errors, recent decisions)
|
||||
* - Layer 1: Stable Workspace Memory (frozen per session cache epoch, refreshed at compaction)
|
||||
* - Layer 2: Hot Session State (active files, open errors, recent decisions, pending memories)
|
||||
* - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction)
|
||||
*
|
||||
* Cache Epoch Model:
|
||||
* - Each session creates a frozen workspace memory snapshot on first transform.
|
||||
* - Normal turns reuse the exact rendered string (system[1] remains stable).
|
||||
* - Compaction starts a new cache epoch: pending memories are promoted, the cache is cleared,
|
||||
* and the next transform re-renders workspace memory.
|
||||
* - Explicit memory ("remember X") goes to SessionState.pendingMemories + durable journal,
|
||||
* visible in ephemeral system[2+] for the current epoch, promoted to system[1] after compaction.
|
||||
*
|
||||
* This plugin:
|
||||
* - Caches frozen workspace memory per sessionID
|
||||
* - Processes explicit memory from latest user text once per message id
|
||||
@@ -26,8 +34,18 @@ import {
|
||||
import {
|
||||
loadWorkspaceMemory,
|
||||
updateWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
renderWorkspaceMemory,
|
||||
reinforceMemory,
|
||||
} from "./workspace-memory.ts";
|
||||
import {
|
||||
appendPendingMemories,
|
||||
clearPendingMemories,
|
||||
hasPendingJournalEntries,
|
||||
loadPendingJournal,
|
||||
memoryKey,
|
||||
recordPromotionRejections,
|
||||
} from "./pending-journal.ts";
|
||||
import {
|
||||
loadSessionState,
|
||||
updateSessionState,
|
||||
@@ -44,40 +62,103 @@ import {
|
||||
latestCompactionSummary,
|
||||
pendingTodos,
|
||||
} from "./opencode.ts";
|
||||
import { accountPendingPromotions } from "./promotion-accounting.ts";
|
||||
import { WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Generate the memory candidate instruction to include in compaction context.
|
||||
* Build the complete compaction prompt.
|
||||
*
|
||||
* Replaces OpenCode's default template (which uses --- separators that trigger
|
||||
* YAML frontmatter comment scope in markdown rendering, producing purple italic text).
|
||||
* Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter,
|
||||
* horizontal rules, and delimiter lines.
|
||||
*
|
||||
* @param privateContext - Background context (workspace memory, hot session state,
|
||||
* pending todos) from our plugin and any other plugins. Shown to the model to
|
||||
* inform the summary but not copied verbatim.
|
||||
*/
|
||||
function memoryCandidateInstruction(): string {
|
||||
return `
|
||||
At the end of the compaction summary, include:
|
||||
|
||||
<workspace_memory_candidates>
|
||||
- [feedback] ...
|
||||
- [project] ...
|
||||
- [decision] ...
|
||||
- [reference] ...
|
||||
</workspace_memory_candidates>
|
||||
|
||||
Only include durable information useful across future sessions in this exact workspace.
|
||||
Do NOT include active file lists, raw errors, temporary progress, stack traces, code signatures, API docs, git history, or facts easily rediscovered from the repository.
|
||||
For decisions, include rationale in one sentence.
|
||||
If nothing qualifies, output an empty block.
|
||||
`.trim();
|
||||
function buildCompactionPrompt(privateContext: string): string {
|
||||
return [
|
||||
"Provide a detailed summary for continuing our conversation above.",
|
||||
"Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.",
|
||||
"",
|
||||
"Do not call any tools. Respond only with the summary text.",
|
||||
"Respond in the same language as the user's messages in the conversation.",
|
||||
"",
|
||||
"Formatting rules:",
|
||||
"- Start the response with \"## Goal\".",
|
||||
"- Use Markdown headings only.",
|
||||
"- Do not output YAML frontmatter.",
|
||||
"- Do not output horizontal rules.",
|
||||
"- Do not wrap the summary in delimiter lines such as ---.",
|
||||
"- Do not use code fences around the summary.",
|
||||
"",
|
||||
"Use this structure:",
|
||||
"",
|
||||
"## Goal",
|
||||
"",
|
||||
"## Instructions",
|
||||
"",
|
||||
"## Progress",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"",
|
||||
"## Discoveries",
|
||||
"",
|
||||
"## Next Steps",
|
||||
"",
|
||||
"## Relevant Files",
|
||||
"",
|
||||
"At the end of the summary, include a Memory candidates section only if there are durable facts that will change future behavior.",
|
||||
"",
|
||||
"CRITICAL MEMORY RULES:",
|
||||
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
|
||||
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
|
||||
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
|
||||
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
|
||||
"- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.",
|
||||
"- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.",
|
||||
"- If unsure, skip it.",
|
||||
"",
|
||||
"Good memory examples:",
|
||||
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
|
||||
"- [decision] Do not add semantic merge to memory dedupe.",
|
||||
"- [project] This repository is an OpenCode plugin using local JSON stores.",
|
||||
"- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.",
|
||||
"",
|
||||
"Bad memory examples to skip:",
|
||||
"- Wave 2 completed successfully.",
|
||||
"- 180 tests passed and CI is green.",
|
||||
"- Implemented owner-aware cleanup in plugin.ts.",
|
||||
"- The assistant reviewed code reviewer feedback and updated the plan.",
|
||||
"- Commit a762e86 contains the owner scope fix.",
|
||||
"",
|
||||
"Format when there ARE durable memories:",
|
||||
"Memory candidates:",
|
||||
"- [feedback|decision|project|reference] future-facing durable fact",
|
||||
"",
|
||||
"Format when there are NO durable memories:",
|
||||
"Memory candidates:",
|
||||
"(none)",
|
||||
"",
|
||||
"Background context, use this to inform the summary above.",
|
||||
"Do not output this context verbatim:",
|
||||
"",
|
||||
privateContext,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render todos for compaction context.
|
||||
* Render todos for compaction context (plain text format, no Markdown headers).
|
||||
*/
|
||||
function renderTodos(todos: Array<{ content: string; status: string; priority?: string }>): string {
|
||||
function renderTodosForCompaction(todos: Array<{ content: string; status: string; priority?: string }>): string {
|
||||
if (todos.length === 0) return "";
|
||||
|
||||
const lines = ["<pending_todos>"];
|
||||
const lines = ["Pending todos:"];
|
||||
for (const todo of todos) {
|
||||
const priority = todo.priority ? ` [${todo.priority}]` : "";
|
||||
lines.push(`- ${todo.content}${priority}`);
|
||||
const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○";
|
||||
lines.push(`- ${status} ${todo.content}${priority}`);
|
||||
}
|
||||
lines.push("</pending_todos>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -109,6 +190,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
string,
|
||||
{
|
||||
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
|
||||
renderedPrompt: string;
|
||||
loadedAt: number;
|
||||
}
|
||||
>();
|
||||
@@ -116,27 +198,75 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// Cache for processed user message IDs (to avoid duplicate processing)
|
||||
const processedUserMessages = new Map<string, Set<string>>();
|
||||
|
||||
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
while (frozenWorkspaceMemoryCache.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
|
||||
const oldest = [...frozenWorkspaceMemoryCache.entries()]
|
||||
.sort((a, b) => a[1].loadedAt - b[1].loadedAt)[0]?.[0];
|
||||
if (!oldest) break;
|
||||
frozenWorkspaceMemoryCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneProcessedUserMessagesCache(): void {
|
||||
for (const [sessionID, messages] of processedUserMessages) {
|
||||
while (messages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
|
||||
const oldest = messages.values().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
messages.delete(oldest);
|
||||
}
|
||||
|
||||
if (messages.size === 0) {
|
||||
processedUserMessages.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
while (processedUserMessages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedSessionIDs) {
|
||||
const oldestSessionID = processedUserMessages.keys().next().value as string | undefined;
|
||||
if (!oldestSessionID) break;
|
||||
processedUserMessages.delete(oldestSessionID);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberProcessedUserMessage(sessionID: string, messageID: string, processedForSession: Set<string>): void {
|
||||
processedForSession.add(messageID);
|
||||
while (processedForSession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
|
||||
const oldest = processedForSession.values().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
processedForSession.delete(oldest);
|
||||
}
|
||||
|
||||
if (processedUserMessages.has(sessionID)) {
|
||||
processedUserMessages.delete(sessionID);
|
||||
}
|
||||
processedUserMessages.set(sessionID, processedForSession);
|
||||
pruneProcessedUserMessagesCache();
|
||||
}
|
||||
|
||||
async function processLatestUserMessage(sessionID: string): Promise<void> {
|
||||
const processedForSession = processedUserMessages.get(sessionID) ?? new Set<string>();
|
||||
const latestMessage = await latestUserText(client, sessionID);
|
||||
|
||||
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
|
||||
|
||||
const memories = extractExplicitMemories(latestMessage.text);
|
||||
const memories = extractExplicitMemories(latestMessage.text).map(memory => ({
|
||||
...memory,
|
||||
pendingOwnerSessionID: sessionID,
|
||||
pendingMessageID: latestMessage.id,
|
||||
}));
|
||||
const decisions = memories.filter(memory => memory.type === "decision");
|
||||
let workspaceMemory: Awaited<ReturnType<typeof loadWorkspaceMemory>> | undefined;
|
||||
|
||||
if (memories.length > 0) {
|
||||
workspaceMemory = await updateWorkspaceMemory(directory, store => {
|
||||
store.entries.push(...memories);
|
||||
return store;
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.pendingMemories.push(...memories);
|
||||
return state;
|
||||
});
|
||||
|
||||
// Update frozen cache
|
||||
const cached = frozenWorkspaceMemoryCache.get(sessionID);
|
||||
if (cached) {
|
||||
cached.store = workspaceMemory;
|
||||
}
|
||||
await appendPendingMemories(directory, memories);
|
||||
}
|
||||
|
||||
if (decisions.length > 0) {
|
||||
@@ -152,8 +282,115 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
});
|
||||
}
|
||||
|
||||
processedForSession.add(latestMessage.id);
|
||||
processedUserMessages.set(sessionID, processedForSession);
|
||||
rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession);
|
||||
}
|
||||
|
||||
async function promotePendingMemories(
|
||||
sessionID?: string,
|
||||
options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const includeUnownedJournal = options.includeUnownedJournal ?? !sessionID;
|
||||
const includeOwnedJournal = options.includeOwnedJournal ?? Boolean(sessionID);
|
||||
const [journal, sessionState] = await Promise.all([
|
||||
loadPendingJournal(directory),
|
||||
sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const journalPending = journal.entries.filter(memory => {
|
||||
if (sessionID && includeOwnedJournal && memory.pendingOwnerSessionID === sessionID) return true;
|
||||
if (includeUnownedJournal && !memory.pendingOwnerSessionID) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const pending = [
|
||||
...(sessionState?.pendingMemories ?? []),
|
||||
...journalPending,
|
||||
];
|
||||
if (pending.length === 0) return;
|
||||
|
||||
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
|
||||
|
||||
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
|
||||
beforeEntries = [...workspaceMemory.entries];
|
||||
const existingByKey = new Map<string, { memory: typeof workspaceMemory.entries[number]; index: number }>();
|
||||
workspaceMemory.entries.forEach((memory, index) => {
|
||||
if (memory.status === "superseded") return;
|
||||
existingByKey.set(memoryKey(memory), { memory, index });
|
||||
});
|
||||
|
||||
const promotedAt = Date.now();
|
||||
for (const memory of pending) {
|
||||
const key = memoryKey(memory);
|
||||
const existing = existingByKey.get(key);
|
||||
if (existing) {
|
||||
const reinforced = reinforceMemory(
|
||||
existing.memory,
|
||||
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
|
||||
promotedAt,
|
||||
);
|
||||
if (reinforced !== existing.memory) {
|
||||
workspaceMemory.entries[existing.index] = reinforced;
|
||||
existingByKey.set(key, { memory: reinforced, index: existing.index });
|
||||
}
|
||||
} else {
|
||||
workspaceMemory.entries.push({
|
||||
...memory,
|
||||
retentionClock: memory.retentionClock ?? promotedAt,
|
||||
});
|
||||
existingByKey.set(key, {
|
||||
memory: workspaceMemory.entries[workspaceMemory.entries.length - 1],
|
||||
index: workspaceMemory.entries.length - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return workspaceMemory;
|
||||
});
|
||||
|
||||
const accounting = accountPendingPromotions({
|
||||
pending,
|
||||
before: beforeEntries,
|
||||
after: updateResult.store.entries,
|
||||
events: updateResult.events,
|
||||
});
|
||||
|
||||
const exhaustedRejectedKeys = await recordPromotionRejections(
|
||||
directory,
|
||||
accounting.retryableRejectedKeys,
|
||||
"rejected_capacity",
|
||||
{
|
||||
ownerSessionID: sessionID,
|
||||
includeUnownedOnly: !sessionID,
|
||||
},
|
||||
);
|
||||
|
||||
const sessionRemovalKeys = new Set([
|
||||
...accounting.clearableKeys,
|
||||
...exhaustedRejectedKeys,
|
||||
]);
|
||||
|
||||
if (sessionID) {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.pendingMemories = state.pendingMemories.filter(memory => {
|
||||
const key = memoryKey(memory);
|
||||
if (!sessionRemovalKeys.has(key)) return true;
|
||||
|
||||
if (accounting.clearableKeys.has(key)) return false;
|
||||
if (exhaustedRejectedKeys.has(key)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
return state;
|
||||
});
|
||||
clearFrozenWorkspaceMemoryCache(sessionID);
|
||||
}
|
||||
|
||||
if (accounting.clearableKeys.size > 0) {
|
||||
await clearPendingMemories(directory, accounting.clearableKeys, {
|
||||
ownerSessionID: sessionID,
|
||||
clearUnowned: !sessionID || includeUnownedJournal === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bashExitCode(hookOutput: unknown): number | undefined {
|
||||
@@ -179,24 +416,32 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get frozen workspace memory for a session.
|
||||
* Loads from disk once per session, then caches in memory.
|
||||
* Get frozen workspace memory snapshot for a session.
|
||||
* Loads and renders from disk once per session, then reuses the exact rendered string.
|
||||
*/
|
||||
async function getFrozenWorkspaceMemory(
|
||||
async function getFrozenWorkspaceMemorySnapshot(
|
||||
root: string,
|
||||
sessionID: string
|
||||
): Promise<Awaited<ReturnType<typeof loadWorkspaceMemory>>> {
|
||||
): Promise<{
|
||||
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
|
||||
renderedPrompt: string;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
const cached = frozenWorkspaceMemoryCache.get(sessionID);
|
||||
|
||||
// Cache is valid for the session lifetime
|
||||
// Cache is valid for the current session cache epoch.
|
||||
// It is intentionally invalidated after compaction so promoted memories
|
||||
// become visible in the next compacted context (new epoch starts).
|
||||
if (cached) {
|
||||
return cached.store;
|
||||
return { store: cached.store, renderedPrompt: cached.renderedPrompt };
|
||||
}
|
||||
|
||||
const store = await loadWorkspaceMemory(root);
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, loadedAt: now });
|
||||
return store;
|
||||
const renderedPrompt = renderWorkspaceMemory(store);
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
return { store, renderedPrompt };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,28 +451,43 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
}
|
||||
|
||||
function sessionIDFromEventProperties(properties: unknown): string | undefined {
|
||||
const props = properties as { sessionID?: string; info?: { id?: string } } | undefined;
|
||||
return props?.sessionID ?? props?.info?.id;
|
||||
}
|
||||
|
||||
return {
|
||||
// Inject workspace memory and hot session state into system prompt
|
||||
"experimental.chat.system.transform": async (hookInput, output) => {
|
||||
const { sessionID } = hookInput;
|
||||
if (!sessionID) return;
|
||||
|
||||
pruneFrozenWorkspaceMemoryCache();
|
||||
pruneProcessedUserMessagesCache();
|
||||
|
||||
// Sub-agents are short-lived - skip memory system
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Process explicit user memory even on no-tool turns.
|
||||
// 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.
|
||||
await processLatestUserMessage(sessionID);
|
||||
|
||||
// Get frozen workspace memory (loaded once per session)
|
||||
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
|
||||
// Before first snapshot in this session, promote durable unowned backlog from
|
||||
// prior sessions. Current-turn owned explicit memory remains pending and only
|
||||
// appears in hot state for this transform.
|
||||
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
||||
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
|
||||
}
|
||||
|
||||
// Get frozen workspace memory snapshot (loaded and rendered once per session)
|
||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
|
||||
// Get current hot session state
|
||||
const sessionState = await loadSessionState(directory, sessionID);
|
||||
|
||||
// Render and inject workspace memory
|
||||
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
|
||||
if (workspacePrompt) {
|
||||
output.system.push(workspacePrompt);
|
||||
// Inject frozen workspace memory snapshot
|
||||
if (workspaceSnapshot.renderedPrompt) {
|
||||
output.system.push(workspaceSnapshot.renderedPrompt);
|
||||
}
|
||||
|
||||
// Render and inject hot session state
|
||||
@@ -292,7 +552,17 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
await processLatestUserMessage(sessionID);
|
||||
},
|
||||
|
||||
// Add compaction context before summarization
|
||||
/**
|
||||
* Replace the default compaction prompt with a ---free template.
|
||||
*
|
||||
* OpenCode's default template wraps sections in --- separators. When the
|
||||
* model follows the template (which our structured context encourages),
|
||||
* the TUI renders --- at position 0 as YAML frontmatter, applying the
|
||||
* "comment" syntax scope (purple italic in palenight theme).
|
||||
*
|
||||
* We set output.prompt to replace the entire prompt, removing all ---
|
||||
* and explicitly forbidding YAML frontmatter / horizontal rules.
|
||||
*/
|
||||
"experimental.session.compacting": async (hookInput, output) => {
|
||||
const { sessionID } = hookInput;
|
||||
if (!sessionID) return;
|
||||
@@ -300,14 +570,18 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// Sub-agents don't need compaction support
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Add compaction context with memory, hot state, todos, and instruction
|
||||
// Preserve context injected by other plugins that ran before us.
|
||||
// Setting output.prompt bypasses the default prompt + context join,
|
||||
// so we must explicitly carry forward any existing output.context.
|
||||
const otherContext = output.context.filter(Boolean).join("\n\n");
|
||||
|
||||
// Build our private context (workspace memory, hot state, todos)
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. Frozen workspace memory
|
||||
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
|
||||
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
|
||||
if (workspacePrompt) {
|
||||
contextParts.push(workspacePrompt);
|
||||
// 1. Frozen workspace memory snapshot
|
||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
if (workspaceSnapshot.renderedPrompt) {
|
||||
contextParts.push(workspaceSnapshot.renderedPrompt);
|
||||
}
|
||||
|
||||
// 2. Hot session state
|
||||
@@ -319,53 +593,68 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
|
||||
// 3. Pending todos from OpenCode
|
||||
const todos = await pendingTodos(client, sessionID);
|
||||
const todosPrompt = renderTodos(todos);
|
||||
const todosPrompt = renderTodosForCompaction(todos);
|
||||
if (todosPrompt) {
|
||||
contextParts.push(todosPrompt);
|
||||
}
|
||||
|
||||
// 4. Memory candidate instruction
|
||||
contextParts.push(memoryCandidateInstruction());
|
||||
// Combine: other plugins' context first, then our private context
|
||||
const privateContext = [otherContext, ...contextParts]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
// Add to compaction context (output.context is an array)
|
||||
for (const part of contextParts) {
|
||||
output.context.push(part);
|
||||
}
|
||||
// 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
|
||||
event: async ({ event }) => {
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (event.properties as { sessionID?: string; info?: { id?: string } })?.sessionID
|
||||
?? (event.properties as { info?: { id?: string } })?.info?.id;
|
||||
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
|
||||
// Parse latest compaction summary for memory candidates, stage them into
|
||||
// durable pending journal, then promote pending memories.
|
||||
const summary = await latestCompactionSummary(client, sessionID);
|
||||
if (summary) {
|
||||
const candidates = parseWorkspaceMemoryCandidates(summary);
|
||||
if (candidates.length > 0) {
|
||||
await updateWorkspaceMemory(directory, workspaceMemory => {
|
||||
workspaceMemory.entries.push(...candidates);
|
||||
return workspaceMemory;
|
||||
});
|
||||
const candidates = summary ? parseWorkspaceMemoryCandidates(summary) : [];
|
||||
if (candidates.length > 0) {
|
||||
await appendPendingMemories(directory, candidates);
|
||||
}
|
||||
|
||||
// Clear frozen cache so next session reloads with new memories
|
||||
clearFrozenWorkspaceMemoryCache(sessionID);
|
||||
}
|
||||
try {
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
} catch {
|
||||
// Keep pending memories in session/journal for retry on next event/session.
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = (event.properties as { info?: { id?: string } })?.info?.id;
|
||||
const sessionID = sessionIDFromEventProperties(event.properties);
|
||||
if (sessionID) {
|
||||
// Clean up caches
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
processedUserMessages.delete(sessionID);
|
||||
sessionParentCache.delete(sessionID);
|
||||
// Promote pending memories before deleting per-session state.
|
||||
// If promotion fails, leave session state and journal intact.
|
||||
let promoted = false;
|
||||
try {
|
||||
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
|
||||
promoted = true;
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
if (promoted) {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
processedUserMessages.delete(sessionID);
|
||||
sessionParentCache.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
await rm(await sessionStatePath(directory, sessionID), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { LongTermMemoryEntry } from "./types.ts";
|
||||
import { memoryKey } from "./pending-journal.ts";
|
||||
import type { MemoryConsolidationEvent } from "./workspace-memory.ts";
|
||||
import { workspaceMemoryIdentityKey } from "./workspace-memory.ts";
|
||||
|
||||
export type PendingPromotionAccounting = {
|
||||
promotedKeys: Set<string>;
|
||||
absorbedKeys: Set<string>;
|
||||
supersededKeys: Set<string>;
|
||||
rejectedKeys: Set<string>;
|
||||
retryableRejectedKeys: Set<string>;
|
||||
clearableKeys: Set<string>;
|
||||
};
|
||||
|
||||
export function accountPendingPromotions(input: {
|
||||
pending: LongTermMemoryEntry[];
|
||||
before: LongTermMemoryEntry[];
|
||||
after: LongTermMemoryEntry[];
|
||||
events?: MemoryConsolidationEvent[];
|
||||
}): PendingPromotionAccounting {
|
||||
const beforeActive = input.before.filter(entry => entry.status !== "superseded");
|
||||
const afterActive = input.after.filter(entry => entry.status !== "superseded");
|
||||
const beforeExactKeys = new Set(beforeActive.map(entry => memoryKey(entry)));
|
||||
const afterExactKeys = new Set(afterActive.map(entry => memoryKey(entry)));
|
||||
const afterIdentityKeys = new Set(afterActive.map(entry => workspaceMemoryIdentityKey(entry)));
|
||||
const terminalEventByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event]));
|
||||
|
||||
const promotedKeys = new Set<string>();
|
||||
const absorbedKeys = new Set<string>();
|
||||
const supersededKeys = new Set<string>();
|
||||
const rejectedKeys = new Set<string>();
|
||||
|
||||
for (const memory of input.pending) {
|
||||
const key = memoryKey(memory);
|
||||
const identityKey = workspaceMemoryIdentityKey(memory);
|
||||
|
||||
if (beforeExactKeys.has(key)) {
|
||||
absorbedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (afterExactKeys.has(key)) {
|
||||
promotedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
const terminal = terminalEventByKey.get(key);
|
||||
if (terminal) {
|
||||
if (
|
||||
terminal.reason === "absorbed_exact" ||
|
||||
terminal.reason === "absorbed_identity"
|
||||
) {
|
||||
absorbedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (terminal.reason === "superseded_existing") {
|
||||
supersededKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (terminal.reason === "rejected_capacity") {
|
||||
rejectedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (afterIdentityKeys.has(identityKey)) {
|
||||
absorbedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
rejectedKeys.add(key);
|
||||
}
|
||||
|
||||
const clearableKeys = new Set([
|
||||
...promotedKeys,
|
||||
...absorbedKeys,
|
||||
...supersededKeys,
|
||||
...input.pending
|
||||
.filter(memory => {
|
||||
const terminal = terminalEventByKey.get(memoryKey(memory));
|
||||
return memory.source === "compaction" && terminal?.reason === "rejected_capacity";
|
||||
})
|
||||
.map(memory => memoryKey(memory)),
|
||||
]);
|
||||
|
||||
const retryableRejectedKeys = new Set(
|
||||
input.pending
|
||||
.filter(memory => {
|
||||
const key = memoryKey(memory);
|
||||
return rejectedKeys.has(key) &&
|
||||
!clearableKeys.has(key) &&
|
||||
(memory.source === "explicit" || memory.source === "manual");
|
||||
})
|
||||
.map(memory => memoryKey(memory)),
|
||||
);
|
||||
|
||||
return {
|
||||
promotedKeys,
|
||||
absorbedKeys,
|
||||
supersededKeys,
|
||||
rejectedKeys,
|
||||
retryableRejectedKeys,
|
||||
clearableKeys,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shared redaction utilities for sensitive credential patterns.
|
||||
* Used by both workspace memory normalization and extraction rejection logging.
|
||||
*/
|
||||
|
||||
// Password labels in multiple languages
|
||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||
|
||||
// Username labels in multiple languages
|
||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||
|
||||
// Sensitive key labels
|
||||
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
|
||||
|
||||
// Secret value pattern (excludes common delimiters and brackets)
|
||||
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
||||
|
||||
// Prefix patterns for different credential types
|
||||
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
|
||||
const BEARER_PREFIX = String.raw`(Bearer\s+)`;
|
||||
|
||||
/**
|
||||
* Redacts sensitive credentials from text.
|
||||
* Handles:
|
||||
* - PINs in multiple formats
|
||||
* - Username/password pairs
|
||||
* - Standalone passwords
|
||||
* - Bearer tokens
|
||||
* - API keys, secrets, credentials, auth tokens, private keys
|
||||
*
|
||||
* Supports multiple languages and delimiters (ASCII and CJK).
|
||||
*/
|
||||
export function redactCredentials(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// 1. PIN
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 2. Username+password pair
|
||||
result = result.replace(
|
||||
new RegExp(
|
||||
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
|
||||
"gi",
|
||||
),
|
||||
"$1[REDACTED]$3$4[REDACTED]",
|
||||
);
|
||||
|
||||
// 3. Standalone password
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 4. Bearer tokens (but not "bearer token:" labels)
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 5. Sensitive keys/tokens
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
+30
-4
@@ -1,8 +1,9 @@
|
||||
import { relative } from "path";
|
||||
import { sessionStatePath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import type { ActiveFile, OpenError, SessionDecision, SessionState } from "./types.ts";
|
||||
import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts";
|
||||
import { HOT_STATE_LIMITS } from "./types.ts";
|
||||
import { memoryKey } from "./pending-journal.ts";
|
||||
|
||||
const ACTION_WEIGHT: Record<ActiveFile["action"], number> = {
|
||||
edit: 50,
|
||||
@@ -20,6 +21,7 @@ export function createEmptySessionState(sessionID: string): SessionState {
|
||||
activeFiles: [],
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +32,7 @@ export async function loadSessionState(root: string, sessionID: string): Promise
|
||||
loaded.activeFiles = Array.isArray(loaded.activeFiles) ? loaded.activeFiles : [];
|
||||
loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : [];
|
||||
loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : [];
|
||||
loaded.pendingMemories = Array.isArray(loaded.pendingMemories) ? loaded.pendingMemories : [];
|
||||
return loaded;
|
||||
}
|
||||
|
||||
@@ -48,6 +51,7 @@ export async function updateSessionState(
|
||||
current.activeFiles = Array.isArray(current.activeFiles) ? current.activeFiles : [];
|
||||
current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : [];
|
||||
current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : [];
|
||||
current.pendingMemories = Array.isArray(current.pendingMemories) ? current.pendingMemories : [];
|
||||
return normalizeSessionState(await updater(current));
|
||||
});
|
||||
}
|
||||
@@ -57,9 +61,23 @@ function normalizeSessionState(state: SessionState): SessionState {
|
||||
state.activeFiles = state.activeFiles.slice(0, HOT_STATE_LIMITS.maxActiveFilesStored);
|
||||
state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored);
|
||||
state.recentDecisions = state.recentDecisions.slice(0, HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
||||
state.pendingMemories = dedupePendingMemories(Array.isArray(state.pendingMemories) ? state.pendingMemories : [])
|
||||
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesStored);
|
||||
return state;
|
||||
}
|
||||
|
||||
function dedupePendingMemories(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;
|
||||
}
|
||||
|
||||
export function touchActiveFile(state: SessionState, filePath: string, action: ActiveFile["action"]): void {
|
||||
const now = Date.now();
|
||||
const existing = state.activeFiles.find(item => item.path === filePath);
|
||||
@@ -177,10 +195,12 @@ export function renderHotSessionState(state: SessionState, workspaceRoot: string
|
||||
.sort((a, b) => b.lastSeen - a.lastSeen)
|
||||
.slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered);
|
||||
const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
||||
const pendingMemories = dedupePendingMemories(state.pendingMemories)
|
||||
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesRendered);
|
||||
|
||||
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0) return "";
|
||||
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0 && pendingMemories.length === 0) return "";
|
||||
|
||||
const lines: string[] = ["<hot_session_state>"];
|
||||
const lines: string[] = ["Hot session state (current session):"];
|
||||
|
||||
if (activeFiles.length > 0) {
|
||||
lines.push("active_files:");
|
||||
@@ -204,7 +224,13 @@ export function renderHotSessionState(state: SessionState, workspaceRoot: string
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("</hot_session_state>");
|
||||
if (pendingMemories.length > 0) {
|
||||
lines.push("pending_memories:");
|
||||
for (const memory of pendingMemories) {
|
||||
lines.push(`- [${memory.type}] ${memory.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
|
||||
}
|
||||
|
||||
|
||||
+87
-5
@@ -1,9 +1,13 @@
|
||||
import { existsSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
||||
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
||||
import type { FileHandle } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
|
||||
const fileLocks = new Map<string, Promise<unknown>>();
|
||||
const LOCK_WAIT_TIMEOUT_MS = 5000;
|
||||
const LOCK_STALE_MS = 30_000;
|
||||
const LOCK_HEARTBEAT_MS = 1_000;
|
||||
|
||||
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
||||
if (!existsSync(path)) return fallback();
|
||||
@@ -14,6 +18,82 @@ export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async function readJSONStrict<T>(path: string, fallback: () => T): Promise<T> {
|
||||
if (!existsSync(path)) return fallback();
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON in ${path}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function isLockStale(lockPath: string, now = Date.now()): Promise<boolean> {
|
||||
try {
|
||||
const stats = await stat(lockPath);
|
||||
|
||||
if (now - stats.mtimeMs > LOCK_STALE_MS) return true;
|
||||
|
||||
const content = await readFile(lockPath, "utf8");
|
||||
const [, createdText] = content.split("\n");
|
||||
const createdAt = Number(createdText);
|
||||
|
||||
return Number.isFinite(createdAt) && now - createdAt > LOCK_STALE_MS;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
||||
}
|
||||
}
|
||||
|
||||
async function writeLockInfo(handle: FileHandle): Promise<void> {
|
||||
const content = `${process.pid}\n${Date.now()}\n`;
|
||||
await handle.truncate(0);
|
||||
await handle.write(content, 0, "utf8");
|
||||
}
|
||||
|
||||
async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
const lockPath = `${path}.lock`;
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const started = Date.now();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const handle = await open(lockPath, "wx", 0o600);
|
||||
let heartbeat: NodeJS.Timeout | undefined;
|
||||
let heartbeatWrite: Promise<void> = Promise.resolve();
|
||||
const queueHeartbeat = (): void => {
|
||||
heartbeatWrite = heartbeatWrite
|
||||
.catch(() => undefined)
|
||||
.then(() => writeLockInfo(handle))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
try {
|
||||
await writeLockInfo(handle);
|
||||
heartbeat = setInterval(queueHeartbeat, LOCK_HEARTBEAT_MS);
|
||||
return await fn();
|
||||
} finally {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
await heartbeatWrite.catch(() => undefined);
|
||||
await handle.close();
|
||||
await rm(lockPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "EEXIST") throw error;
|
||||
|
||||
if (await isLockStale(lockPath)) {
|
||||
await rm(lockPath, { force: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) {
|
||||
throw new Error(`Timed out waiting for lock ${lockPath}`);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||
@@ -36,10 +116,12 @@ export async function updateJSON<T>(
|
||||
|
||||
try {
|
||||
await previous.catch(() => undefined);
|
||||
const current = await readJSON(path, fallback);
|
||||
const updated = await updater(current);
|
||||
await atomicWriteJSON(path, updated);
|
||||
return updated;
|
||||
return await withFileLock(path, async () => {
|
||||
const current = await readJSONStrict(path, fallback);
|
||||
const updated = await updater(current);
|
||||
await atomicWriteJSON(path, updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
if (fileLocks.get(path) === queued) {
|
||||
|
||||
+41
-3
@@ -15,6 +15,17 @@ export type LongTermMemoryEntry = {
|
||||
staleAfterDays?: number;
|
||||
supersedes?: string[];
|
||||
tags?: string[];
|
||||
pendingOwnerSessionID?: string;
|
||||
pendingMessageID?: string;
|
||||
promotionAttempts?: number;
|
||||
lastPromotionAttemptAt?: string;
|
||||
lastPromotionFailureReason?: string;
|
||||
retentionClock?: number; // Unix timestamp when retention started
|
||||
reinforcementCount?: number; // Number of times this memory was reinforced
|
||||
lastReinforcedAt?: number; // Unix timestamp of last reinforcement
|
||||
lastReinforcedSessionID?: string;
|
||||
userImportance?: "low" | "normal" | "high";
|
||||
safetyCritical?: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryStore = {
|
||||
@@ -28,6 +39,18 @@ export type WorkspaceMemoryStore = {
|
||||
maxEntries: number;
|
||||
};
|
||||
entries: LongTermMemoryEntry[];
|
||||
migrations?: string[];
|
||||
updatedAt: string;
|
||||
lastActivityAt?: string;
|
||||
};
|
||||
|
||||
export type PendingMemoryJournalStore = {
|
||||
version: 1;
|
||||
workspace: {
|
||||
root: string;
|
||||
key: string;
|
||||
};
|
||||
entries: LongTermMemoryEntry[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -68,21 +91,36 @@ export type SessionState = {
|
||||
activeFiles: ActiveFile[];
|
||||
openErrors: OpenError[];
|
||||
recentDecisions: SessionDecision[];
|
||||
pendingMemories: LongTermMemoryEntry[];
|
||||
};
|
||||
|
||||
export const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 5200,
|
||||
targetRenderedChars: 4200,
|
||||
maxRenderedChars: 3600,
|
||||
targetRenderedChars: 3000,
|
||||
maxEntries: 28,
|
||||
maxEntryTextChars: 260,
|
||||
maxRationaleChars: 180,
|
||||
} as const;
|
||||
|
||||
export const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 1200,
|
||||
maxRenderedChars: 700,
|
||||
maxActiveFilesStored: 20,
|
||||
maxActiveFilesRendered: 8,
|
||||
maxOpenErrorsStored: 5,
|
||||
maxOpenErrorsRendered: 3,
|
||||
maxRecentDecisionsStored: 8,
|
||||
maxPendingMemoriesStored: 12,
|
||||
maxPendingMemoriesRendered: 6,
|
||||
} as const;
|
||||
|
||||
export const PROMOTION_RETRY_LIMITS = {
|
||||
maxExplicitAttempts: 3,
|
||||
maxManualAttempts: 3,
|
||||
} as const;
|
||||
|
||||
export const WORKSPACE_MEMORY_CACHE_LIMITS = {
|
||||
maxFrozenSessions: 50,
|
||||
maxProcessedSessionIDs: 200,
|
||||
maxProcessedMessagesPerSession: 50,
|
||||
frozenTtlMs: 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { appendFile, mkdir, readFile, readdir, rename, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dataHome as defaultDataHome } from "./paths.ts";
|
||||
|
||||
export type WorkspaceCleanupClassification =
|
||||
| "test_temp_definite"
|
||||
| "orphan_unknown"
|
||||
| "live_or_existing"
|
||||
| "invalid_or_unreadable";
|
||||
|
||||
export type WorkspaceCleanupResult = {
|
||||
workspaceKey: string;
|
||||
workspaceDir: string;
|
||||
root?: string;
|
||||
rootExists: boolean;
|
||||
classification: WorkspaceCleanupClassification;
|
||||
reasons: string[];
|
||||
entryCount?: number;
|
||||
migrations?: string[];
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupScanOptions = {
|
||||
dataHome?: string;
|
||||
nowMs?: number;
|
||||
minAgeMs?: number;
|
||||
includeOrphans?: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupScan = {
|
||||
results: WorkspaceCleanupResult[];
|
||||
candidates: WorkspaceCleanupResult[];
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupMode = "dry-run" | "quarantine";
|
||||
|
||||
export type WorkspaceCleanupOptions = WorkspaceCleanupScanOptions & {
|
||||
mode?: WorkspaceCleanupMode;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupQuarantineEvent = WorkspaceCleanupResult & {
|
||||
from: string;
|
||||
to: string;
|
||||
quarantinedAt: string;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupRunResult = WorkspaceCleanupScan & {
|
||||
mode: WorkspaceCleanupMode;
|
||||
quarantined: WorkspaceCleanupQuarantineEvent[];
|
||||
quarantineDir?: string;
|
||||
};
|
||||
|
||||
type WorkspaceMemoryShape = {
|
||||
workspace?: {
|
||||
root?: unknown;
|
||||
key?: unknown;
|
||||
};
|
||||
entries?: unknown[];
|
||||
migrations?: unknown[];
|
||||
updatedAt?: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_MIN_AGE_MS = 10 * 60 * 1_000;
|
||||
|
||||
const KNOWN_TEST_ROOT_PREFIXES = [
|
||||
"memory-plugin-test-",
|
||||
"memory-plugin-prompt-",
|
||||
"wm-",
|
||||
"wm-quality-",
|
||||
"wm-accounting-",
|
||||
"wm-redact-",
|
||||
"wm-normalized-",
|
||||
"wm-ordering-",
|
||||
"wm-extraction-",
|
||||
];
|
||||
|
||||
function normalizePathForComparison(path: string): string {
|
||||
return resolve(path).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isInsidePath(path: string, parent: string): boolean {
|
||||
const normalizedPath = normalizePathForComparison(path);
|
||||
const normalizedParent = normalizePathForComparison(parent);
|
||||
return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`);
|
||||
}
|
||||
|
||||
export function isTempRoot(root: string, osTmpdir = tmpdir()): boolean {
|
||||
const normalized = normalizePathForComparison(root);
|
||||
const normalizedTmp = normalizePathForComparison(osTmpdir);
|
||||
|
||||
if (isInsidePath(normalized, normalizedTmp)) return true;
|
||||
if (isInsidePath(normalized, "/tmp")) return true;
|
||||
if (isInsidePath(normalized, "/private/tmp")) return true;
|
||||
|
||||
return /^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/.test(normalized);
|
||||
}
|
||||
|
||||
export function isKnownTestWorkspaceRoot(root: string): boolean {
|
||||
const name = basename(root);
|
||||
return KNOWN_TEST_ROOT_PREFIXES.some(prefix => name.startsWith(prefix));
|
||||
}
|
||||
|
||||
function classifyCandidate(result: WorkspaceCleanupResult, includeOrphans: boolean): boolean {
|
||||
if (result.reasons.includes("recent_workspace_dir")) return false;
|
||||
if (result.reasons.includes("lock_present")) return false;
|
||||
if (result.classification === "test_temp_definite") return true;
|
||||
return includeOrphans && result.classification === "orphan_unknown";
|
||||
}
|
||||
|
||||
export async function classifyWorkspaceDir(
|
||||
workspaceDir: string,
|
||||
options: { nowMs?: number; minAgeMs?: number } = {},
|
||||
): Promise<WorkspaceCleanupResult> {
|
||||
const workspaceKey = basename(workspaceDir);
|
||||
const reasons: string[] = [];
|
||||
const memoryPath = join(workspaceDir, "workspace-memory.json");
|
||||
|
||||
if (existsSync(`${memoryPath}.lock`)) {
|
||||
reasons.push("lock_present");
|
||||
}
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(workspaceDir);
|
||||
} catch {
|
||||
return {
|
||||
workspaceKey,
|
||||
workspaceDir,
|
||||
rootExists: false,
|
||||
classification: "invalid_or_unreadable",
|
||||
reasons: ["workspace_dir_unreadable"],
|
||||
};
|
||||
}
|
||||
|
||||
const minAgeMs = options.minAgeMs ?? DEFAULT_MIN_AGE_MS;
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
if (minAgeMs > 0 && nowMs - stats.mtimeMs < minAgeMs) {
|
||||
reasons.push("recent_workspace_dir");
|
||||
}
|
||||
|
||||
let store: WorkspaceMemoryShape;
|
||||
try {
|
||||
store = JSON.parse(await readFile(memoryPath, "utf8")) as WorkspaceMemoryShape;
|
||||
} catch {
|
||||
return {
|
||||
workspaceKey,
|
||||
workspaceDir,
|
||||
rootExists: false,
|
||||
classification: "invalid_or_unreadable",
|
||||
reasons: [...reasons, "invalid_json"],
|
||||
};
|
||||
}
|
||||
|
||||
const root = typeof store.workspace?.root === "string" ? store.workspace.root : undefined;
|
||||
const key = typeof store.workspace?.key === "string" ? store.workspace.key : workspaceKey;
|
||||
const entryCount = Array.isArray(store.entries) ? store.entries.length : undefined;
|
||||
const migrations = Array.isArray(store.migrations) ? store.migrations.filter((item): item is string => typeof item === "string") : undefined;
|
||||
const updatedAt = typeof store.updatedAt === "string" ? store.updatedAt : undefined;
|
||||
|
||||
if (!root) {
|
||||
return {
|
||||
workspaceKey: key,
|
||||
workspaceDir,
|
||||
rootExists: false,
|
||||
classification: "invalid_or_unreadable",
|
||||
reasons: [...reasons, "missing_workspace_root"],
|
||||
entryCount,
|
||||
migrations,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const rootExists = existsSync(root);
|
||||
if (rootExists) {
|
||||
return {
|
||||
workspaceKey: key,
|
||||
workspaceDir,
|
||||
root,
|
||||
rootExists,
|
||||
classification: "live_or_existing",
|
||||
reasons: [...reasons, "root_exists"],
|
||||
entryCount,
|
||||
migrations,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
reasons.push("root_missing");
|
||||
const tempRoot = isTempRoot(root);
|
||||
const testRoot = isKnownTestWorkspaceRoot(root);
|
||||
if (tempRoot) reasons.push("root_under_temp");
|
||||
if (testRoot) reasons.push(`test_prefix_${KNOWN_TEST_ROOT_PREFIXES.find(prefix => basename(root).startsWith(prefix))?.replace(/-$/, "") ?? basename(root)}`);
|
||||
|
||||
return {
|
||||
workspaceKey: key,
|
||||
workspaceDir,
|
||||
root,
|
||||
rootExists,
|
||||
classification: tempRoot || testRoot ? "test_temp_definite" : "orphan_unknown",
|
||||
reasons,
|
||||
entryCount,
|
||||
migrations,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function workspacesDir(dataHome: string): string {
|
||||
return join(dataHome, "opencode-working-memory", "workspaces");
|
||||
}
|
||||
|
||||
export async function scanWorkspaceResidues(options: WorkspaceCleanupScanOptions = {}): Promise<WorkspaceCleanupScan> {
|
||||
const root = workspacesDir(options.dataHome ?? defaultDataHome());
|
||||
const results: WorkspaceCleanupResult[] = [];
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(root);
|
||||
} catch {
|
||||
return { results, candidates: [] };
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const workspaceDir = join(root, entry);
|
||||
const stats = await stat(workspaceDir).catch(() => undefined);
|
||||
if (!stats?.isDirectory()) continue;
|
||||
|
||||
results.push(await classifyWorkspaceDir(workspaceDir, {
|
||||
nowMs: options.nowMs,
|
||||
minAgeMs: options.minAgeMs,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
candidates: results.filter(result => classifyCandidate(result, options.includeOrphans ?? false)),
|
||||
};
|
||||
}
|
||||
|
||||
function quarantineName(now: Date): string {
|
||||
return `workspace-cleanup-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`;
|
||||
}
|
||||
|
||||
export async function cleanupWorkspaceResidues(options: WorkspaceCleanupOptions = {}): Promise<WorkspaceCleanupRunResult> {
|
||||
const mode = options.mode ?? "dry-run";
|
||||
const now = options.now ?? new Date();
|
||||
const scan = await scanWorkspaceResidues({
|
||||
dataHome: options.dataHome,
|
||||
nowMs: options.nowMs,
|
||||
minAgeMs: options.minAgeMs,
|
||||
includeOrphans: options.includeOrphans,
|
||||
});
|
||||
|
||||
if (mode === "dry-run" || scan.candidates.length === 0) {
|
||||
return { ...scan, mode, quarantined: [] };
|
||||
}
|
||||
|
||||
const dataHome = options.dataHome ?? defaultDataHome();
|
||||
const quarantineDir = join(dataHome, "opencode-working-memory", "quarantine", quarantineName(now));
|
||||
const quarantined: WorkspaceCleanupQuarantineEvent[] = [];
|
||||
|
||||
for (const candidate of scan.candidates) {
|
||||
const destination = join(quarantineDir, "workspaces", candidate.workspaceKey);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await rename(candidate.workspaceDir, destination);
|
||||
|
||||
const event: WorkspaceCleanupQuarantineEvent = {
|
||||
...candidate,
|
||||
from: candidate.workspaceDir,
|
||||
to: destination,
|
||||
quarantinedAt: now.toISOString(),
|
||||
};
|
||||
quarantined.push(event);
|
||||
|
||||
await mkdir(quarantineDir, { recursive: true });
|
||||
await appendFile(join(quarantineDir, "manifest.jsonl"), JSON.stringify(event) + "\n", "utf8");
|
||||
}
|
||||
|
||||
return { ...scan, mode, quarantined, quarantineDir };
|
||||
}
|
||||
+713
-56
@@ -1,12 +1,199 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
const MIN_ENVELOPE_LENGTH = 80;
|
||||
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
||||
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
|
||||
|
||||
// Retention decay model constants (v1.5)
|
||||
const BASE_HALF_LIFE_DAYS = 45;
|
||||
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
||||
const REINFORCEMENT_MAX_COUNT = 6;
|
||||
const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
||||
const DORMANT_DECAY_MULTIPLIER = 0.25;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const TYPE_FACTOR = {
|
||||
reference: 1.0,
|
||||
project: 1.25,
|
||||
feedback: 2.25,
|
||||
decision: 2.5,
|
||||
} as const;
|
||||
|
||||
const SOURCE_FACTOR = {
|
||||
compaction: 1.0,
|
||||
manual: 1.4,
|
||||
explicit: 2.0,
|
||||
} as const;
|
||||
|
||||
const USER_IMPORTANCE_FACTOR = {
|
||||
low: 0.7,
|
||||
normal: 1.0,
|
||||
high: 1.5,
|
||||
} as const;
|
||||
|
||||
const SAFETY_CRITICAL_FACTOR = 6.0;
|
||||
|
||||
const TYPE_MAX = {
|
||||
feedback: 10,
|
||||
decision: 10,
|
||||
project: 8,
|
||||
reference: 6,
|
||||
} as const;
|
||||
|
||||
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
|
||||
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
|
||||
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
|
||||
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
|
||||
const safetyFactor = memory.safetyCritical ? SAFETY_CRITICAL_FACTOR : 1.0;
|
||||
|
||||
return typeFactor * sourceFactor * importanceFactor * safetyFactor;
|
||||
}
|
||||
|
||||
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
|
||||
const reinforcementCount = Math.min(
|
||||
memory.reinforcementCount ?? 0,
|
||||
REINFORCEMENT_MAX_COUNT,
|
||||
);
|
||||
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
|
||||
return BASE_HALF_LIFE_DAYS / factor;
|
||||
}
|
||||
|
||||
function timestampMs(value: unknown, fallback: number): number {
|
||||
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
|
||||
return Number.isFinite(ms) ? ms : fallback;
|
||||
}
|
||||
|
||||
export function calculateRetentionStrength(
|
||||
memory: LongTermMemoryEntry,
|
||||
now: number,
|
||||
lastActivityAt?: string,
|
||||
): number {
|
||||
const initialStrength = calculateInitialStrength(memory);
|
||||
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
|
||||
|
||||
// Use retentionClock if available, fallback to updatedAt.
|
||||
const retentionStart = Number.isFinite(memory.retentionClock)
|
||||
? memory.retentionClock
|
||||
: memory.updatedAt ?? memory.createdAt;
|
||||
const createdAtMs = timestampMs(retentionStart, now);
|
||||
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
|
||||
|
||||
// Calculate strength using exponential decay.
|
||||
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
|
||||
|
||||
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
|
||||
}
|
||||
|
||||
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
|
||||
const lastActivity = store.lastActivityAt
|
||||
? new Date(store.lastActivityAt).getTime()
|
||||
: now;
|
||||
if (!Number.isFinite(lastActivity)) return 0;
|
||||
|
||||
const daysSinceActivity = (now - lastActivity) / DAY_MS;
|
||||
return Math.max(0, daysSinceActivity);
|
||||
}
|
||||
|
||||
export function calculateEffectiveAgeDays(
|
||||
entryStartMs: number,
|
||||
now: number,
|
||||
lastActivityAt?: string,
|
||||
): number {
|
||||
const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS);
|
||||
|
||||
if (!lastActivityAt) return wallAgeDays;
|
||||
|
||||
const lastActivityMs = new Date(lastActivityAt).getTime();
|
||||
if (!Number.isFinite(lastActivityMs)) return wallAgeDays;
|
||||
|
||||
const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS;
|
||||
const overlapStartMs = Math.max(entryStartMs, dormantStartMs);
|
||||
const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS);
|
||||
const activeDays = wallAgeDays - dormantOverlapDays;
|
||||
|
||||
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
|
||||
}
|
||||
|
||||
export function reinforceMemory(
|
||||
memory: LongTermMemoryEntry,
|
||||
sessionId: string,
|
||||
now: number,
|
||||
): LongTermMemoryEntry {
|
||||
if (memory.lastReinforcedSessionID === sessionId) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
return {
|
||||
...memory,
|
||||
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
|
||||
lastReinforcedAt: now,
|
||||
lastReinforcedSessionID: sessionId,
|
||||
retentionClock: now,
|
||||
};
|
||||
}
|
||||
|
||||
export type MemoryConsolidationReason =
|
||||
| "promoted"
|
||||
| "absorbed_exact"
|
||||
| "absorbed_identity"
|
||||
| "superseded_existing"
|
||||
| "rejected_capacity";
|
||||
|
||||
export type MemoryConsolidationEvent = {
|
||||
memoryKey: string;
|
||||
identityKey: string;
|
||||
memory: LongTermMemoryEntry;
|
||||
reason: MemoryConsolidationReason;
|
||||
retainedId?: string;
|
||||
supersededId?: string;
|
||||
};
|
||||
|
||||
export type LongTermLimitResult = {
|
||||
kept: LongTermMemoryEntry[];
|
||||
dropped: MemoryConsolidationEvent[];
|
||||
absorbed: MemoryConsolidationEvent[];
|
||||
superseded: MemoryConsolidationEvent[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||||
store: WorkspaceMemoryStore;
|
||||
events: MemoryConsolidationEvent[];
|
||||
};
|
||||
|
||||
export type QualityCleanupMigrationLogEntry = {
|
||||
migrationId: string;
|
||||
timestamp: string;
|
||||
workspaceKey: string;
|
||||
workspaceRoot: string;
|
||||
entryId: string;
|
||||
type: LongTermMemoryEntry["type"];
|
||||
source: LongTermMemoryEntry["source"];
|
||||
text: string;
|
||||
reasons: string[];
|
||||
hardReasons: string[];
|
||||
beforeStatus: "active";
|
||||
afterStatus: "superseded";
|
||||
};
|
||||
|
||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
const nowIso = new Date().toISOString();
|
||||
return {
|
||||
version: 1,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
@@ -15,20 +202,59 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
|
||||
maxEntries: LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
migrations: [],
|
||||
updatedAt: nowIso,
|
||||
lastActivityAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const fallback = await emptyWorkspaceMemory(root);
|
||||
const loaded = await readJSON(await workspaceMemoryPath(root), () => fallback);
|
||||
loaded.workspace = { root, key: await workspaceKey(root) };
|
||||
loaded.limits = {
|
||||
maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
const loaded = await readJSON(path, () => fallback) as Partial<WorkspaceMemoryStore>;
|
||||
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: loaded.version ?? 1,
|
||||
workspace: loaded.workspace ?? { root, key: await workspaceKey(root) },
|
||||
limits: {
|
||||
maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
|
||||
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
|
||||
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
|
||||
lastActivityAt: loaded.lastActivityAt ?? loaded.updatedAt ?? fallback.lastActivityAt,
|
||||
};
|
||||
loaded.entries = Array.isArray(loaded.entries) ? loaded.entries : [];
|
||||
return loaded;
|
||||
|
||||
// Always normalize on load so redaction/migrations are always-on.
|
||||
const normalized = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||||
|
||||
// Persist security/correctness mutations, but avoid read-time maintenance
|
||||
// writes for ordering/capacity/timestamp-only normalization.
|
||||
if (hasSecurityOrMigrationChange(store, normalized.store)) {
|
||||
await atomicWriteJSON(path, normalized.store);
|
||||
}
|
||||
|
||||
return normalized.store;
|
||||
}
|
||||
|
||||
function hasSecurityOrMigrationChange(
|
||||
before: WorkspaceMemoryStore,
|
||||
after: WorkspaceMemoryStore,
|
||||
): boolean {
|
||||
const beforeById = new Map((before.entries ?? []).map(entry => [entry.id, entry]));
|
||||
for (const afterEntry of after.entries ?? []) {
|
||||
const beforeEntry = beforeById.get(afterEntry.id);
|
||||
if (!beforeEntry) continue;
|
||||
if (beforeEntry.text !== afterEntry.text) return true;
|
||||
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
|
||||
if (beforeEntry.status !== afterEntry.status) return true;
|
||||
}
|
||||
|
||||
const beforeMigrations = JSON.stringify(before.migrations ?? []);
|
||||
const afterMigrations = JSON.stringify(after.migrations ?? []);
|
||||
if ((before.lastActivityAt ?? "") !== (after.lastActivityAt ?? "")) return true;
|
||||
return beforeMigrations !== afterMigrations;
|
||||
}
|
||||
|
||||
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
|
||||
@@ -40,26 +266,217 @@ export async function updateWorkspaceMemory(
|
||||
root: string,
|
||||
updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise<WorkspaceMemoryStore>,
|
||||
): Promise<WorkspaceMemoryStore> {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const fallback = await emptyWorkspaceMemory(root);
|
||||
return updateJSON(path, () => fallback, async current => {
|
||||
const normalized = await normalizeWorkspaceMemory(root, current);
|
||||
return normalizeWorkspaceMemory(root, await updater(normalized));
|
||||
});
|
||||
return (await updateWorkspaceMemoryWithAccounting(root, updater)).store;
|
||||
}
|
||||
|
||||
async function normalizeWorkspaceMemory(
|
||||
export async function updateWorkspaceMemoryWithAccounting(
|
||||
root: string,
|
||||
updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise<WorkspaceMemoryStore>,
|
||||
): Promise<WorkspaceMemoryNormalizationResult> {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const fallback = await emptyWorkspaceMemory(root);
|
||||
let finalResult: WorkspaceMemoryNormalizationResult | undefined;
|
||||
const store = await updateJSON(path, () => fallback, async current => {
|
||||
const normalized = await normalizeWorkspaceMemory(root, current);
|
||||
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized));
|
||||
return finalResult.store;
|
||||
});
|
||||
|
||||
return finalResult ?? {
|
||||
store,
|
||||
kept: store.entries.filter(entry => entry.status !== "superseded"),
|
||||
dropped: [],
|
||||
absorbed: [],
|
||||
superseded: [],
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function normalizeWorkspaceMemory(
|
||||
root: string,
|
||||
store: WorkspaceMemoryStore,
|
||||
): Promise<WorkspaceMemoryStore> {
|
||||
store.workspace = { root, key: await workspaceKey(root) };
|
||||
store.limits = {
|
||||
maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
return (await normalizeWorkspaceMemoryWithAccounting(root, store)).store;
|
||||
}
|
||||
|
||||
export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
root: string,
|
||||
store: WorkspaceMemoryStore,
|
||||
): Promise<WorkspaceMemoryNormalizationResult> {
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
let result: WorkspaceMemoryStore = {
|
||||
...store,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
limits: {
|
||||
maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: Array.isArray(store.entries) ? store.entries : [],
|
||||
migrations: Array.isArray(store.migrations) ? store.migrations : [],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Always-on credential redaction
|
||||
result.entries = result.entries.map(entry => {
|
||||
const text = redactCredentials(entry.text);
|
||||
const rationale = entry.rationale ? redactCredentials(entry.rationale) : undefined;
|
||||
|
||||
if (text === entry.text && rationale === entry.rationale) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
text,
|
||||
rationale,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
});
|
||||
|
||||
// One-time migrations for legacy/low-quality snapshot violations.
|
||||
// Run quality cleanup first so hard violations receive quality audit tags
|
||||
// before the older P0 project-only cleanup marks progress snapshots.
|
||||
const beforeQualityCleanup = result;
|
||||
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
|
||||
result = qualityCleanup.store;
|
||||
let skipRemainingMigrations = false;
|
||||
if (qualityCleanup.events.length > 0) {
|
||||
try {
|
||||
await appendQualityCleanupMigrationLog(qualityCleanup.events);
|
||||
} catch (error) {
|
||||
console.error("[memory] failed to write quality cleanup migration log:", error);
|
||||
console.error("[memory] aborting migration to maintain audit trail integrity");
|
||||
result = beforeQualityCleanup;
|
||||
skipRemainingMigrations = true;
|
||||
}
|
||||
}
|
||||
if (!skipRemainingMigrations) {
|
||||
result = runMigrationP0Cleanup(result, nowIso);
|
||||
}
|
||||
|
||||
// P0 accounting only considers active entries. Entries that were already
|
||||
// superseded before this normalization are preserved in storage; entries that
|
||||
// lose during this enforcement are reported via accounting events but are not
|
||||
// archived as superseded records in this wave.
|
||||
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
|
||||
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
|
||||
const accounting = enforceLongTermLimitsWithAccounting(activeEntries, result);
|
||||
|
||||
const normalizedStore = {
|
||||
...result,
|
||||
entries: [...accounting.kept, ...supersededEntries],
|
||||
updatedAt: nowIso,
|
||||
lastActivityAt: nowIso,
|
||||
};
|
||||
|
||||
return {
|
||||
store: normalizedStore,
|
||||
kept: accounting.kept,
|
||||
dropped: accounting.dropped,
|
||||
absorbed: accounting.absorbed,
|
||||
superseded: accounting.superseded,
|
||||
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
||||
};
|
||||
}
|
||||
|
||||
export function runMigrationP0Cleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
): WorkspaceMemoryStore {
|
||||
if (store.migrations?.includes(MIGRATION_ID)) {
|
||||
return store;
|
||||
}
|
||||
|
||||
const entries = store.entries.map(entry => {
|
||||
if (entry.source !== "compaction") return entry;
|
||||
if (entry.type !== "project") return entry;
|
||||
|
||||
if (isProgressSnapshotViolation(entry.text)) {
|
||||
return {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
return {
|
||||
...store,
|
||||
entries,
|
||||
migrations: [...(store.migrations || []), MIGRATION_ID],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationLogEntry[]): Promise<void> {
|
||||
if (events.length === 0) return;
|
||||
const path = migrationLogPath(QUALITY_CLEANUP_MIGRATION_ID);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n", "utf8");
|
||||
}
|
||||
|
||||
export function runMigrationQualityCleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
|
||||
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
|
||||
return { store, events: [] };
|
||||
}
|
||||
|
||||
const events: QualityCleanupMigrationLogEntry[] = [];
|
||||
let changed = false;
|
||||
const entries = store.entries.map(entry => {
|
||||
if (entry.source !== "compaction") return entry;
|
||||
if (entry.status === "superseded") return entry;
|
||||
|
||||
const quality = assessMemoryQuality(entry);
|
||||
if (quality.accepted) return entry;
|
||||
|
||||
const hardReasons = quality.reasons.filter(isHardQualityReason);
|
||||
if (hardReasons.length === 0) return entry;
|
||||
|
||||
changed = true;
|
||||
events.push({
|
||||
migrationId: QUALITY_CLEANUP_MIGRATION_ID,
|
||||
timestamp: nowIso,
|
||||
workspaceKey: store.workspace.key,
|
||||
workspaceRoot: store.workspace.root,
|
||||
entryId: entry.id,
|
||||
type: entry.type,
|
||||
source: entry.source,
|
||||
text: entry.text,
|
||||
reasons: quality.reasons,
|
||||
hardReasons,
|
||||
beforeStatus: "active",
|
||||
afterStatus: "superseded",
|
||||
});
|
||||
|
||||
const tags = new Set([
|
||||
...(entry.tags ?? []),
|
||||
"quality_cleanup",
|
||||
...hardReasons.map(reason => `quality:${reason}`),
|
||||
]);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
updatedAt: nowIso,
|
||||
tags: [...tags],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
store: {
|
||||
...store,
|
||||
entries,
|
||||
migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID],
|
||||
updatedAt: changed ? nowIso : store.updatedAt,
|
||||
},
|
||||
events,
|
||||
};
|
||||
store.entries = enforceLongTermLimits(store.entries);
|
||||
store.updatedAt = new Date().toISOString();
|
||||
return store;
|
||||
}
|
||||
|
||||
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
|
||||
@@ -76,43 +493,286 @@ function canonicalMemoryText(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
|
||||
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
function normalizeUrlIdentity(raw: string): string | null {
|
||||
const cleaned = raw.replace(/[),.;:!?]+$/g, "");
|
||||
try {
|
||||
const url = new URL(cleaned);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
url.protocol = url.protocol.toLowerCase();
|
||||
url.hostname = url.hostname.toLowerCase();
|
||||
url.hash = "";
|
||||
if (url.pathname.length > 1) {
|
||||
url.pathname = url.pathname.replace(/\/+$/g, "");
|
||||
}
|
||||
return `url:${url.toString()}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePathIdentity(raw: string): string | null {
|
||||
const unwrapped = raw
|
||||
.trim()
|
||||
.replace(/^[`"']+|[`"']+$/g, "")
|
||||
.replace(/[),.;:!?]+$/g, "")
|
||||
.replace(/\\+/g, "/");
|
||||
|
||||
if (!unwrapped) return null;
|
||||
const collapsed = unwrapped.startsWith("/")
|
||||
? `/${unwrapped.slice(1).replace(/\/+$/g, "/").replace(/\/+/g, "/")}`
|
||||
: unwrapped.replace(/\/+/g, "/");
|
||||
const withoutTrailingSlash = collapsed.length > 1 ? collapsed.replace(/\/+$/g, "") : collapsed;
|
||||
return `path:${withoutTrailingSlash}`;
|
||||
}
|
||||
|
||||
function isConcretePathIdentity(pathIdentity: string): boolean {
|
||||
const path = pathIdentity.slice("path:".length);
|
||||
if (!path || path === "." || path === "..") return false;
|
||||
|
||||
if (path.startsWith("/")) return true;
|
||||
if (/^\.\.?\//.test(path)) return true;
|
||||
if (/^\.[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||
if (/^[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||
return /\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config)$/i.test(path);
|
||||
}
|
||||
|
||||
function normalizeConcretePathIdentity(raw: string): string | null {
|
||||
const pathIdentity = normalizePathIdentity(raw);
|
||||
if (!pathIdentity) return null;
|
||||
return isConcretePathIdentity(pathIdentity) ? pathIdentity : null;
|
||||
}
|
||||
|
||||
function extractConcreteIdentityKey(text: string): string | null {
|
||||
const urlMatch = text.match(/https?:\/\/[^\s`"'<>]+/i);
|
||||
if (urlMatch) {
|
||||
const urlIdentity = normalizeUrlIdentity(urlMatch[0]);
|
||||
if (urlIdentity) return urlIdentity;
|
||||
}
|
||||
|
||||
const wrappedPathPattern = /[`"']([^`"']+)[`"']/g;
|
||||
for (const match of text.matchAll(wrappedPathPattern)) {
|
||||
const pathIdentity = normalizeConcretePathIdentity(match[1]);
|
||||
if (pathIdentity) return pathIdentity;
|
||||
}
|
||||
|
||||
const pathMatch = text.match(/(?:\/[^ | ||||