Compare commits

...

29 Commits

Author SHA1 Message Date
Ralph Chang fe6ce36e09 docs: prepare v1.3.0 release notes 2026-04-27 17:06:43 +08:00
Ralph Chang 3cc6dff7ae feat: add consolidation accounting for workspace memory promotion
P0 implementation with four waves:

Wave 1: Dedup with accounting
- Add dedupeLongTermEntriesWithAccounting()
- Classify exact duplicate, identity duplicate, topic duplicate

Wave 2: Normalization with accounting
- Add normalizeWorkspaceMemoryWithAccounting()
- Chain redaction → migration → enforceLongTermLimitsWithAccounting

Wave 3: Promotion accounting integration
- Update accountPendingPromotions() to use new accounting API
- Add supersededKeys to classification
- Distinguish promoted / absorbed / superseded / rejected

Wave 4: Integration tests
- End-to-end tests covering full pipeline

Bug fixes:
- Fix active vs superseded boundary (superseded entries no longer block promotion)
- Remove unused rejected_duplicate_lower_quality type
- Defer pending journal safety cap (TODO added)

Tests: 135 passing (up from 115)
2026-04-27 16:45:55 +08:00
Ralph Chang 1c748f3ee2 chore: ignore superpowers plans and update architecture doc
- Add docs/superpowers/plans/ to .gitignore
- Remove tracked plan files from git
- Update docs/architecture.md:
  - Change primary extraction format from XML to 'Memory candidates:'
  - Mark XML format as legacy/deprecated
  - Fix hot session state injection example
2026-04-27 14:53:07 +08:00
Ralph Chang ca68b7f55c feat: sharpen compaction memory extraction prompt
Wave 3 of memory quality optimization plan.

- Add good memory examples in buildCompactionPrompt()
- Add bad memory examples to skip (test counts, commit hashes, etc.)
- Add prompt assertions in tests to prevent regression
- Emphasize 'useful if a new agent opens this workspace next week'
2026-04-27 14:40:32 +08:00
Ralph Chang 023589a905 test: add memory quality eval fixtures
Wave 2 of memory quality optimization plan.

- 5 accepted cases: durable facts that should be kept
- 7 rejected cases: noise that should be filtered
- Parser-level regression guard (zero API call)
- All cases pass against current extractors.ts
2026-04-27 14:34:53 +08:00
Ralph Chang 24f807fed0 fix: account for absorbed pending memories
- Add workspaceMemoryIdentityKey() to unify dedup/supersession identity semantics
- Add accountPendingPromotions() to distinguish promoted/absorbed/rejected
- Wire promotion accounting into promotePendingMemories()
- Add clearableKeys.size > 0 guard to prevent journal wipe
- Add regression tests for absorbed duplicate, cap-rejected, all-rejected edge cases

Wave 1 of memory quality optimization plan.
2026-04-27 14:27:43 +08:00
Ralph Chang 097235e43b docs: add memory quality optimization implementation plan
P0 implementation plan with 3 waves:
- Wave 1: Promotion accounting (fix absorbed duplicate data loss)
- Wave 2: Memory quality eval (fixture-based regression guard)
- Wave 3: Compaction prompt negative examples

Addresses architecture review feedback:
- Distinguish promoted/absorbed/rejected pending memories
- Add clearableKeys.size > 0 guard to prevent journal wipe
- Add regression tests for all edge cases
2026-04-27 14:16:46 +08:00
sdwolf4103 14bbb76cf1 Fix formatting in README.md 2026-04-27 13:00:26 +08:00
Ralph Chang fd8d730e3b docs: streamline README and document ongoing work 2026-04-27 12:38:11 +08:00
Ralph Chang 4309cb855f fix: promotion accounting, sessionID extraction, and strengthened regression tests
Architecture review fixes:

- Promotion accounting: only clear pending memories that survived
  workspace memory normalization/cap limits. Use retainedKeys from
  the returned normalized store instead of attemptedKeys.

- Shared sessionID extraction: add sessionIDFromEventProperties()
  helper and use it in both session.compacted and session.deleted,
  fixing the previous gap where session.deleted only read info.id.

- Strengthen compaction refresh test: seed workspace memory before
  first transform so firstSystem1 is non-empty, then assert
  refreshed system[1] preserves existing entries AND contains
  promoted memories.
2026-04-27 10:02:18 +08:00
Ralph Chang 2437a9dc71 fix: clarify cache epoch semantics and add regression tests
- Update plugin.ts comments to describe 'session cache epoch' instead
  of misleading 'session lifetime' wording
- Add regression test: same-session explicit memory does not mutate
  frozen system[1]; pending memory goes to ephemeral system[2+]
- Add regression test: session.compacted intentionally refreshes
  system[1] as a new cache epoch boundary (promotes pending memories,
  clears frozen cache, next transform re-renders workspace memory)
- Both tests use one plugin instance with mutable mock client to
  preserve in-memory frozen cache across turns
2026-04-27 09:55:03 +08:00
Ralph Chang 3560868f52 release: v1.2.3 2026-04-27 02:24:48 +08:00
Ralph Chang e7c7a5cfb2 feat: add durable pending memory journal 2026-04-27 02:20:26 +08:00
Ralph Chang 026c75a5e4 feat: freeze rendered workspace memory snapshot 2026-04-27 01:57:41 +08:00
Ralph Chang eb74a9f03e docs: add workspace memory cache optimization plan 2026-04-27 01:55:48 +08:00
Ralph Chang f6f35e87c1 feat: release v1.2.2 with multilingual memory hardening 2026-04-27 00:21:18 +08:00
Ralph Chang 6603fe869d docs: add v1.2.1 release notes 2026-04-26 16:58:14 +08:00
Ralph Chang 3d44269228 fix: resolve remaining architect issues - split feedback keys, remove generic config key, supersession mode
- Split feedbackTopicKey: server-error now separate from port-occupied-environment
- Remove generic plugin.*config entity key (too broad), fall back to canonical dedup
- Feedback topic conflicts now use supersession mode (newer beats longer)
- Add 3 regression tests: English port/split, unrelated configs, feedback supersession

70/70 tests pass.
2026-04-26 16:54:24 +08:00
Ralph Chang a154139b27 fix: P0c/P0d architect review corrections
P0c fixes:
- Chinese file count regex now accepts 個/个 between number and 文件
- Admin PIN short reference (<20 chars) passes via config value allowlist
- Phase snapshot uses semantic window (.{0,20}) instead of absolute position

P0d fixes:
- Feedback key split: 500 error and port issue remain separate entries
- extractEntityKey avoids over-merging unrelated plugin configs
- chooseBetterMemory supports supersession mode (newer beats longer)
- Sort comparator now includes source priority as secondary tie-breaker

New regression tests (11 total):
- Real Admin PIN short reference passes
- Real Chinese 37 個文件 snapshot rejected
- Real pathology Phase 1-4 snapshot rejected
- Feedback 500 vs port entries not collapsed
- Unrelated plugin configs not collapsed
- Supersession prefers newer shorter over older longer

67/67 tests pass.
2026-04-26 16:50:58 +08:00
Ralph Chang 7527765207 feat: storage-time dedupe, stale pruning, and supersession (P0d)
- Project/reference entries dedupe by entity key (bilingual aware)
- Decision entries supersede by topic key (parser formats, template, etc)
- Feedback entries supersede by topic key (same issue, newer fix wins)
- Stale compaction/manual entries pruned after staleAfterDays + 30
- Explicit and feedback entries never age-pruned
- Freshness used as tie-breaker in priority-based trimming
- Adds 10 new tests covering dedup, supersession, staleness, and freshness
2026-04-26 16:37:18 +08:00
Ralph Chang f9acfd6136 fix: parser accepts bracketless format, rejects project snapshots, adds durable-content prompt
P0a: Parser now accepts both - [type] text and - type text formats
P0b: Prompt adds durable-content guidance to avoid session-specific snapshots
P0c: Parser quality gate rejects exact test counts, file counts, phase progress
- Only rejects phase progress when it appears early in the string (snapshot)
- Stable config values with numbers (Admin PIN, Scrypt) still pass
- Adds 7 new tests covering bracketless parsing and snapshot rejection
2026-04-26 16:28:55 +08:00
Ralph Chang ca71c20a8f docs: add memory dedup & staleness architecture analysis 2026-04-26 16:20:29 +08:00
Ralph Chang 5e9ada6859 fix: replace default compaction template to prevent purple italic rendering
Root cause: OpenCode's default compaction template uses --- separators.
When our plugin adds structured context (Memory candidates: format), the
model strictly follows the template, outputting --- at position 0. The
markdown textmate grammar treats this as YAML frontmatter, applying the
'comment' syntax scope (purple + italic in themes like palenight).

Fix: Set output.prompt in the compacting hook to replace the entire
template with a ---free version. Uses only ## Markdown headings and
explicitly forbids YAML frontmatter, horizontal rules, and delimiter
lines. Preserves context from other plugins by merging output.context.

- Replace compactionContextHeader() with buildCompactionPrompt()
- Set output.prompt instead of pushing to output.context
- Merge existing output.context from other plugins before clearing
- Add 'Instructions' section to the template (per architect review)
- Update tests: verify output.prompt, ---free format, context merging
2026-04-26 15:46:41 +08:00
Ralph Chang 721544e7a8 fix: use plain text labels instead of Markdown headers
- Changed '## Memory Candidates' to 'Memory candidates:' in compaction context
- Changed '## Pending Todos' to 'Pending todos:' in todo rendering
- Updated extractCandidateBlock() to parse plain text format (primary)
- Removed stripXmlTags() function (no longer needed)
- All 42 tests pass

Root cause: Markdown headings (##) render as purple in OpenCode UI,
same issue as XML tags and HTML comments. Plain text labels avoid
all special markup rendering.
2026-04-26 15:13:58 +08:00
Ralph Chang 32fa2bd454 chore: keep version at 1.2.1 2026-04-26 14:50:19 +08:00
Ralph Chang af539a42f3 chore: bump version to 1.2.2 for HTML comment output format 2026-04-26 14:49:46 +08:00
Ralph Chang eff0d3784c fix: change compaction output to HTML comment, prevent Markdown rendering issues
Root cause: Model was instructed to output <workspace_memory_candidates> XML
tags in the user-visible compaction summary, causing purple/italic rendering
when combined with --- delimiters in Markdown.

Fixes:
- compactionContextHeader(): Now instructs model to use HTML comment format
  <!-- workspace_memory_candidates ... --> which is hidden from users
- extractCandidateBlock(): New function supports 3 formats:
  1. HTML comment (preferred, hidden from user)
  2. Markdown section (visible but clean)
  3. Legacy XML (backward compatible)
- Added "DO NOT use XML tags" and "DO NOT start with ---" instructions

Tests:
- Verify compaction context header uses HTML comment format
- Test parser accepts all 3 formats (HTML comment, Markdown, legacy XML)
2026-04-26 14:49:38 +08:00
Ralph Chang 2354b62350 chore: bump version to 1.2.1 for compaction context fix 2026-04-26 14:35:18 +08:00
Ralph Chang 92e90124de fix: prevent XML tags in compaction context from causing Markdown rendering issues
- Add stripXmlTags() to convert <workspace_memory>, <hot_session_state>, <pending_todos> to Markdown headers for compaction context
- Add [PRIVATE COMPACTION CONTEXT - DO NOT OUTPUT] wrapper to prevent model from copying input context to output
- Rename renderTodos to renderTodosForCompaction for clarity
- Add test to verify compaction context contains no XML tags

This fixes the issue where compaction summary would render with purple italic text
due to --- delimiters interacting with XML-like tags in Markdown.
2026-04-26 14:34:55 +08:00
23 changed files with 3926 additions and 1409 deletions
+3
View File
@@ -48,3 +48,6 @@ pnpm-lock.yaml
.opencode/
.opencode-agenthub/
.opencode-agenthub.user.json
# Superpowers local planning artifacts
docs/superpowers/plans/
+3 -3
View File
@@ -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
@@ -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)
+75
View File
@@ -0,0 +1,75 @@
# 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.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.
+152 -159
View File
@@ -1,31 +1,40 @@
# OpenCode Working Memory Plugin
# OpenCode Working Memory
[![npm version](https://img.shields.io/npm/v/opencode-working-memory.svg)](https://www.npmjs.com/package/opencode-working-memory)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
OpenCode Working Memory helps your agent keep useful context across compactions and sessions: project decisions, preferences, important references, active files, and unresolved errors.
## What You Get
It works automatically, without manual memory 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 OpenCodes 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.
## Installation
Add to your `~/.config/opencode/opencode.json`:
Add OpenCode Working Memory to your OpenCode config:
```json
{
@@ -33,181 +42,165 @@ 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>
## Explicit Memory Triggers
You can explicitly ask the agent to remember durable facts.
Examples:
```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.
```
**Memory types:**
- `feedback` - User preferences for this workspace
- `project` - Project-level information
- `decision` - Important decisions made
- `reference` - Key references (paths, patterns)
Supported trigger languages include:
**Sources:**
- `explicit` - User explicitly said "remember this" (confidence: 1.0)
- `compaction` - Extracted during compaction (confidence: 0.75)
- `manual` - Added programmatically (confidence: varies)
| Language | Examples |
|---|---|
| English | `remember this`, `save to memory`, `from now on`, `my preference` |
| Chinese | `記住`, `记住`, `記得`, `请帮我记住` |
| Japanese | `覚えて`, `覚えておいて`, `メモして` |
| Korean | `기억해`, `기억해줘`, `메모해줘` |
### Hot Session State (Short-term)
Negative requests are respected too:
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
Don't remember this.
不要記住這個。
覚えないで。
기억하지 마.
```
## Quality Guarantees
Avoid saving:
The plugin includes several quality guards:
- Secrets, passwords, tokens, or credentials
- Temporary progress updates
- Raw command output
- Short-lived session details
- **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
## Quality Guards
## No Tools Required
OpenCode Working Memory tries to keep memory useful and low-noise.
Unlike other memory plugins, **this plugin has no manual tools**. Everything is automatic:
It includes guards for:
- 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
- Credential redaction
- Duplicate memory cleanup
- Superseding older decisions with newer ones
- Consolidation accounting so promoted, absorbed, superseded, and rejected memories are handled differently
- Filtering stack traces, git hashes, raw errors, and noisy path-heavy facts
- Rejecting temporary project progress snapshots
Just install and let it run. The plugin hooks into OpenCode's lifecycle events and does the right thing.
The goal is to remember durable facts, not every detail.
## 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: 5200 characters
- Workspace memory limit: 28 entries
- Hot session state budget: 1200 characters
- 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:
## Ongoing Work
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.
- Improve memory recording quality so only durable, useful facts are kept.
- Strengthen deduplication and supersession so stale memories do not pile up.
- Add better forgetting behavior for obsolete decisions, preferences, and project facts.
## 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
@@ -226,13 +219,13 @@ npm run typecheck
## 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.
+155 -1
View File
@@ -1,5 +1,159 @@
# Release Notes
## 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 +262,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
+39 -24
View File
@@ -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.
```
┌─────────────────────────────────────────────────────────────┐
@@ -73,39 +73,47 @@ 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 and Deduplication
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. Group decisions and feedback by topic where possible.
4. Keep the best surviving entry by source, confidence, type, and freshness rules.
5. Emit accounting events so pending memories can be classified as promoted, absorbed, superseded, or rejected.
This prevents absorbed or superseded pending memories from retrying forever while still preserving the active surviving memory.
### 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 +188,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 +218,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 +234,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; obsolete same-topic memories are superseded; stale or over-capacity compaction memories are rejected.
## Quality Guarantees
### No False Positive Errors
@@ -343,9 +358,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`
+5 -5
View File
@@ -2,7 +2,7 @@
## 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
@@ -192,21 +192,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
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-working-memory",
"version": "1.2.0",
"version": "1.3.0",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
+124 -16
View File
@@ -27,6 +27,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,7 +45,11 @@ 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,
// save/add to memory
@@ -179,6 +193,31 @@ 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];
}
@@ -187,14 +226,24 @@ function extractFirstPath(text: string): string | undefined {
* Quality gate for workspace memory candidates.
* Rejects low-quality entries like git hashes, error messages, etc.
*/
function shouldAcceptWorkspaceMemoryCandidate(entry: {
type: LongTermType;
text: string;
}): boolean {
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;
@@ -218,30 +267,89 @@ function shouldAcceptWorkspaceMemoryCandidate(entry: {
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
if (pathCount > 2) return false;
// Session-specific progress snapshots for project type
if (entry.type === "project") {
if (isProjectSnapshotViolation(text)) return false;
}
return true;
}
function isProjectSnapshotViolation(text: string): boolean {
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// File counts with snapshot/process context only, not static limits
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
if (hasSnapshotContext && !hasLimitContext) return true;
}
// Phase/Wave/Sprint/Milestone/Task progress
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
return false;
}
/**
* Extract candidate block from summary using multiple formats.
* Supports: Plain text label, Markdown section, legacy XML.
*/
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];
// 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 match = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
if (!match) return [];
const block = extractCandidateBlock(summary);
if (!block) return [];
const now = new Date().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",
+4
View File
@@ -20,6 +20,10 @@ 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`);
+106
View File
@@ -0,0 +1,106 @@
import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts";
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
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);
if (seen.has(key)) continue;
seen.add(key);
result.push(entry);
}
return result;
}
function normalizeJournal(
root: string,
store: PendingMemoryJournalStore,
): Promise<PendingMemoryJournalStore> {
return workspaceKey(root).then(key => ({
version: 1,
workspace: { root, key },
// TODO(memory-consolidation follow-up): add the deferred pending journal
// safety cap (max entries and old compaction pruning). P0 currently relies
// on promotion accounting to clear terminal compaction candidates without
// changing journal capacity behavior.
entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []),
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>): Promise<void> {
await updatePendingJournal(root, store => {
if (!keys || keys.size === 0) {
store.entries = [];
return store;
}
store.entries = store.entries.filter(entry => !keys.has(memoryKey(entry)));
return store;
});
}
+253 -77
View File
@@ -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,16 @@ import {
import {
loadWorkspaceMemory,
updateWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
renderWorkspaceMemory,
} from "./workspace-memory.ts";
import {
appendPendingMemories,
clearPendingMemories,
hasPendingJournalEntries,
loadPendingJournal,
memoryKey,
} from "./pending-journal.ts";
import {
loadSessionState,
updateSessionState,
@@ -44,40 +60,110 @@ import {
latestCompactionSummary,
pendingTodos,
} from "./opencode.ts";
import { accountPendingPromotions } from "./promotion-accounting.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, extract durable memory entries for future sessions.",
"",
"Memory quality bar:",
"Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.",
"",
"Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.",
"",
"When unsure, skip it. Fewer high-signal memories are better than many low-value ones.",
"",
"Good memory examples:",
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
"- [decision] Use frozen workspace memory snapshots plus ephemeral hot state for cache stability.",
"- [project] The plugin should piggyback memory extraction on OpenCode compaction and avoid extra LLM calls.",
"- [reference] Workspace memory appears in frozen system[1]; pending memories appear in hot session state until compaction.",
"",
"Bad memory examples to skip:",
"- 42 tests passed.",
"- Wave 2 completed successfully.",
"- Modified 5 files.",
"- commit 4309cb8 contains the latest fix.",
"- TypeError: Cannot read properties of undefined.",
"- Currently running npm test.",
"",
"A memory should still be useful if a new agent opens this workspace next week.",
"",
"Only extract facts that are likely to stay true across sessions.",
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
"For progress, extract the stable goal or durable milestone, not the current number.",
"For references, extract configuration values that do not usually change between sessions.",
"For feedback, extract unresolved issues or user preferences that future sessions need to know.",
"Use exactly this candidate format, including square brackets around the type:",
"",
"Memory candidates:",
"- [feedback] content",
"- [project] content",
"- [decision] content",
"- [reference] content",
"",
"Do not write '- project content'; write '- [project] content'.",
"",
"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 +195,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
string,
{
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
renderedPrompt: string;
loadedAt: number;
}
>();
@@ -124,19 +211,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const memories = extractExplicitMemories(latestMessage.text);
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) {
@@ -156,6 +237,59 @@ export const MemoryV2Plugin: Plugin = async (input) => {
processedUserMessages.set(sessionID, processedForSession);
}
async function promotePendingMemories(sessionID?: string): Promise<void> {
const [journal, sessionState] = await Promise.all([
loadPendingJournal(directory),
sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined),
]);
const pending = [
...(sessionState?.pendingMemories ?? []),
...journal.entries,
];
if (pending.length === 0) return;
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
beforeEntries = [...workspaceMemory.entries];
const existingKeys = new Set(
workspaceMemory.entries
.filter(memory => memory.status !== "superseded")
.map(memory => memoryKey(memory)),
);
for (const memory of pending) {
const key = memoryKey(memory);
if (!existingKeys.has(key)) {
workspaceMemory.entries.push(memory);
existingKeys.add(key);
}
}
return workspaceMemory;
});
const accounting = accountPendingPromotions({
pending,
before: beforeEntries,
after: updateResult.store.entries,
events: updateResult.events,
});
if (sessionID) {
await updateSessionState(directory, sessionID, state => {
state.pendingMemories = state.pendingMemories.filter(memory => !accounting.clearableKeys.has(memoryKey(memory)));
return state;
});
clearFrozenWorkspaceMemoryCache(sessionID);
}
if (accounting.clearableKeys.size > 0) {
await clearPendingMemories(directory, accounting.clearableKeys);
}
}
function bashExitCode(hookOutput: unknown): number | undefined {
const output = hookOutput as {
exitCode?: unknown;
@@ -179,24 +313,30 @@ 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();
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 });
return { store, renderedPrompt };
}
/**
@@ -206,6 +346,11 @@ 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) => {
@@ -215,19 +360,25 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Sub-agents are short-lived - skip memory system
if (await isSubAgent(sessionID)) return;
// Before first snapshot in this session, promote durable pending memories from
// prior sessions. Keep this before processing latest user text so current-turn
// explicit memory remains pending (not immediately frozen into system[1]).
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
await promotePendingMemories();
}
// Process explicit user memory even on no-tool turns.
await processLatestUserMessage(sessionID);
// Get frozen workspace memory (loaded once per session)
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
// 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 +443,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 +461,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,49 +484,60 @@ 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);
} 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) {
// Promote pending memories before deleting per-session state.
// If promotion fails, leave session state and journal intact.
try {
await promotePendingMemories(sessionID);
} catch {
return;
}
// Clean up caches
frozenWorkspaceMemoryCache.delete(sessionID);
processedUserMessages.delete(sessionID);
+95
View File
@@ -0,0 +1,95 @@
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>;
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" || terminal.reason === "rejected_stale") {
rejectedKeys.add(key);
continue;
}
}
if (afterIdentityKeys.has(identityKey)) {
absorbedKeys.add(key);
continue;
}
rejectedKeys.add(key);
}
return {
promotedKeys,
absorbedKeys,
supersededKeys,
rejectedKeys,
clearableKeys: new Set([
...promotedKeys,
...absorbedKeys,
...supersededKeys,
...input.pending
.filter(memory => {
const terminal = terminalEventByKey.get(memoryKey(memory));
return memory.source === "compaction" && (
terminal?.reason === "rejected_capacity" ||
terminal?.reason === "rejected_stale"
);
})
.map(memory => memoryKey(memory)),
]),
};
}
+30 -4
View File
@@ -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);
}
+14
View File
@@ -28,6 +28,17 @@ export type WorkspaceMemoryStore = {
maxEntries: number;
};
entries: LongTermMemoryEntry[];
migrations?: string[];
updatedAt: string;
};
export type PendingMemoryJournalStore = {
version: 1;
workspace: {
root: string;
key: string;
};
entries: LongTermMemoryEntry[];
updatedAt: string;
};
@@ -68,6 +79,7 @@ export type SessionState = {
activeFiles: ActiveFile[];
openErrors: OpenError[];
recentDecisions: SessionDecision[];
pendingMemories: LongTermMemoryEntry[];
};
export const LONG_TERM_LIMITS = {
@@ -85,4 +97,6 @@ export const HOT_STATE_LIMITS = {
maxOpenErrorsStored: 5,
maxOpenErrorsRendered: 3,
maxRecentDecisionsStored: 8,
maxPendingMemoriesStored: 12,
maxPendingMemoriesRendered: 6,
} as const;
+499 -43
View File
@@ -5,6 +5,45 @@ import { atomicWriteJSON, readJSON, updateJSON } from "./storage.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 SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,\s\[]+`;
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
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+(?![是=:])))`;
export type MemoryConsolidationReason =
| "promoted"
| "absorbed_exact"
| "absorbed_identity"
| "superseded_existing"
| "rejected_capacity"
| "rejected_stale";
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 async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
return {
@@ -15,20 +54,53 @@ export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemor
maxEntries: LONG_TERM_LIMITS.maxEntries,
},
entries: [],
migrations: [],
updatedAt: new Date().toISOString(),
};
}
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,
};
loaded.entries = Array.isArray(loaded.entries) ? loaded.entries : [];
return loaded;
// Always normalize on load so redaction/migrations are always-on.
const normalized = await normalizeWorkspaceMemory(root, store);
// Persist only when meaningful content changed (ignore timestamps).
if (didStoreMeaningfullyChange(store, normalized)) {
await atomicWriteJSON(path, normalized);
}
return normalized;
}
function didStoreMeaningfullyChange(
before: WorkspaceMemoryStore,
after: WorkspaceMemoryStore,
): boolean {
const sanitize = (store: WorkspaceMemoryStore) => ({
...store,
updatedAt: "",
entries: store.entries.map(entry => ({
...entry,
updatedAt: "",
})),
});
return JSON.stringify(sanitize(before)) !== JSON.stringify(sanitize(after));
}
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
@@ -40,26 +112,179 @@ 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 migration for legacy snapshot violations
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);
const normalizedStore = {
...result,
entries: [...accounting.kept, ...supersededEntries],
updatedAt: 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 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]",
);
return result;
}
export function isProjectSnapshotViolation(text: string): boolean {
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// File counts with snapshot context, excluding limit statements
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
if (hasSnapshotContext && !hasLimitContext) return true;
}
// Phase/Wave/Sprint/Milestone/Task progress
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
return false;
}
export function runMigrationP0Cleanup(
store: WorkspaceMemoryStore,
nowIso: string,
): WorkspaceMemoryStore {
if (store.migrations?.includes(MIGRATION_ID)) {
return store;
}
const entries = store.entries.map(entry => {
if (entry.source === "explicit") return entry;
if (entry.type !== "project") return entry;
if (isProjectSnapshotViolation(entry.text)) {
return {
...entry,
status: "superseded" as const,
updatedAt: nowIso,
};
}
return entry;
});
return {
...store,
entries,
migrations: [...(store.migrations || []), MIGRATION_ID],
updatedAt: nowIso,
};
store.entries = enforceLongTermLimits(store.entries);
store.updatedAt = new Date().toISOString();
return store;
}
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
@@ -76,31 +301,260 @@ function canonicalMemoryText(text: string): string {
.trim();
}
export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
}
/** Extract entity/destination keys for project and reference dedup */
function extractEntityKey(text: string): string | null {
const normalized = canonicalMemoryText(text);
// Check known key phrases (bilingual-friendly)
// opencode + agenthub plugin system
if (/opencode.*agenthub/i.test(normalized)) {
return "opencode-agenthub plugin system";
}
// For generic config references, fall back to canonical text dedup — no entity key
return null;
}
/** Extract decision topic key for supersession detection */
function decisionTopicKey(text: string): string | null {
const normalized = text.toLowerCase();
// Parser format versions
if (/parser.*formats?|supports?\s*\d+\s*format/i.test(normalized)) {
return "parser-supported-formats";
}
// Compaction template replacement
if (/compaction.*template|output\.prompt|template.*replace/i.test(normalized)) {
return "compaction-template-replacement";
}
// Plugin loading
if (/plugin.*load|npm.*cache|plugin.*config/i.test(normalized)) {
return "plugin-loading-config";
}
// Output format changes (purple/italic, YAML frontmatter, etc)
if (/purple.*italic|markup|markdown.*render|frontmatter/i.test(normalized)) {
return "output-format-rendering";
}
return null;
}
/** Extract feedback topic key for supersession detection */
function feedbackTopicKey(text: string): string | null {
const normalized = text.toLowerCase();
// Purple/italic rendering issue
if (/purple.*italic/i.test(normalized)) {
return "purple-italic-rendering";
}
// Browser login/server errors (500 internal_error)
if (/login.*500|500.*internal|internal_error|server.*error/i.test(normalized)) {
return "server-error";
}
// Port occupied / environment issues
if (/port.*occup|9473|端口|舊進程|旧进程/i.test(normalized)) {
return "port-occupied-environment";
}
// Theme preferences
if (/theme|dark.*light|prefer.*theme/i.test(normalized)) {
return "theme-preference";
}
return null;
}
export function workspaceMemoryIdentityKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
if (entry.type === "project" || entry.type === "reference") {
return `${entry.type}:${extractEntityKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
}
if (entry.type === "feedback") {
return `${entry.type}:${feedbackTopicKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
}
return `decision:${decisionTopicKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
}
function consolidationEvent(
memory: LongTermMemoryEntry,
reason: MemoryConsolidationReason,
retained?: LongTermMemoryEntry,
): MemoryConsolidationEvent {
return {
memoryKey: workspaceMemoryExactKey(memory),
identityKey: workspaceMemoryIdentityKey(memory),
memory,
reason,
retainedId: retained?.id,
supersededId: reason === "superseded_existing" ? memory.id : undefined,
};
}
/** Check if entry should be pruned by age (for compaction/manual entries only) */
function isPrunableByAge(entry: LongTermMemoryEntry, now: number): boolean {
// Never prune feedback or explicit entries
if (entry.type === "feedback") return false;
if (entry.source === "explicit") return false;
if (!entry.staleAfterDays) return false;
const createdAt = new Date(entry.createdAt).getTime();
const ageDays = (now - createdAt) / 86400000;
const grace = 30; // 30-day grace period
return ageDays > entry.staleAfterDays + grace;
}
/** Choose better memory when identity/topic keys conflict */
function chooseBetterMemory(
a: LongTermMemoryEntry,
b: LongTermMemoryEntry,
mode: "entity" | "supersession" = "entity",
): LongTermMemoryEntry {
// Source priority: explicit > manual > compaction
if (sourcePriority(a.source) !== sourcePriority(b.source)) {
return sourcePriority(a.source) > sourcePriority(b.source) ? a : b;
}
// Higher confidence wins
if (a.confidence !== b.confidence) {
return a.confidence > b.confidence ? a : b;
}
// For entity dedup: longer (more specific) beats shorter
// For supersession: newer beats older (and thus longer is not preferred)
if (mode === "supersession") {
// Newer wins for same-topic supersession
if (new Date(a.createdAt).getTime() !== new Date(b.createdAt).getTime()) {
return new Date(a.createdAt) > new Date(b.createdAt) ? a : b;
}
return a.text.length > b.text.length ? a : b;
}
// Entity mode: longer text means more specific
if (Math.abs(a.text.length - b.text.length) > 10) {
return a.text.length > b.text.length ? a : b;
}
// Freshness tie-breaker
return new Date(a.createdAt) > new Date(b.createdAt) ? a : b;
}
export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
const byKey = new Map<string, LongTermMemoryEntry>();
return enforceLongTermLimitsWithAccounting(entries).kept;
}
for (const entry of entries.filter(entry => entry.status === "active")) {
const text = entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars);
const key = `${entry.type}:${canonicalMemoryText(text)}`;
export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
const now = Date.now();
const staleDropped: MemoryConsolidationEvent[] = [];
const existing = byKey.get(key);
// Phase 1: filter active, prune by age
const phase1: LongTermMemoryEntry[] = [];
for (const entry of entries) {
if (entry.status === "superseded") continue;
if (isPrunableByAge(entry, now)) {
staleDropped.push(consolidationEvent(entry, "rejected_stale"));
continue;
}
phase1.push({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) });
}
// Source priority: explicit > manual > compaction
// Same source: higher confidence wins
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
const sorted = [...dedupeResult.kept].sort(compareLongTermMemoryForRetention);
const kept = sorted.slice(0, LONG_TERM_LIMITS.maxEntries);
const keptIds = new Set(kept.map(entry => entry.id));
const capacityDropped = sorted
.filter(entry => !keptIds.has(entry.id))
.map(entry => consolidationEvent(entry, "rejected_capacity"));
return {
kept,
dropped: [...staleDropped, ...dedupeResult.dropped, ...capacityDropped],
absorbed: dedupeResult.absorbed,
superseded: dedupeResult.superseded,
};
}
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
const absorbed: MemoryConsolidationEvent[] = [];
const superseded: MemoryConsolidationEvent[] = [];
// For project/reference/feedback: detect entity keys FIRST, then dedupe by entity OR canonical
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
// Build entity key dedup for project/reference/feedback
const entityDeduped = new Map<string, LongTermMemoryEntry>();
for (const entry of projectRefEntries) {
const key = workspaceMemoryIdentityKey(entry);
const hasTopicIdentity = key !== workspaceMemoryExactKey(entry);
const existing = entityDeduped.get(key);
if (!existing) {
byKey.set(key, { ...entry, text });
} else if (sourcePriority(entry.source) > sourcePriority(existing.source)) {
byKey.set(key, { ...entry, text });
} else if (sourcePriority(entry.source) === sourcePriority(existing.source)) {
if (entry.confidence > existing.confidence) {
byKey.set(key, { ...entry, text });
entityDeduped.set(key, entry);
} else {
// Feedback topic conflicts use supersession mode (newer beats longer)
const mode = entry.type === "feedback" && hasTopicIdentity ? "supersession" as const : "entity" as const;
const retained = chooseBetterMemory(entry, existing, mode);
const dropped = retained === entry ? existing : entry;
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: mode === "supersession"
? "superseded_existing" as const
: "absorbed_identity" as const;
if (reason === "superseded_existing") {
superseded.push(consolidationEvent(dropped, reason, retained));
} else {
absorbed.push(consolidationEvent(dropped, reason, retained));
}
if (retained === entry) {
entityDeduped.set(key, entry);
}
}
}
return [...byKey.values()]
.sort((a, b) => priority(b) - priority(a))
.slice(0, LONG_TERM_LIMITS.maxEntries);
// For decisions: detect topic keys for supersession, or use canonical
const decisionEntries = entries.filter(e => e.type === "decision");
const decisionDeduped = new Map<string, LongTermMemoryEntry>();
for (const entry of decisionEntries) {
const key = workspaceMemoryIdentityKey(entry);
const existing = decisionDeduped.get(key);
if (!existing) {
decisionDeduped.set(key, entry);
} else {
const retained = chooseBetterMemory(entry, existing, "supersession");
const dropped = retained === entry ? existing : entry;
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
? "absorbed_exact" as const
: "superseded_existing" as const;
if (reason === "superseded_existing") {
superseded.push(consolidationEvent(dropped, reason, retained));
} else {
absorbed.push(consolidationEvent(dropped, reason, retained));
}
if (retained === entry) {
decisionDeduped.set(key, entry);
}
}
}
// Merge deduped entries
const phaseFinal = new Map<string, LongTermMemoryEntry>();
for (const entry of [...entityDeduped.values(), ...decisionDeduped.values()]) {
phaseFinal.set(entry.id, entry);
}
return {
kept: [...phaseFinal.values()],
dropped: [],
absorbed,
superseded,
};
}
function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMemoryEntry): number {
const pA = priorityWithFreshness(a);
const pB = priorityWithFreshness(b);
if (pB !== pA) return pB - pA;
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
if (sourceDiff !== 0) return sourceDiff;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
function priority(entry: LongTermMemoryEntry): number {
@@ -115,6 +569,11 @@ function priority(entry: LongTermMemoryEntry): number {
return sourceWeight + typeWeight + entry.confidence * 10;
}
/** Extended priority including freshness for tie-breaking */
function priorityWithFreshness(entry: LongTermMemoryEntry): number {
return priority(entry);
}
function wouldFit(
lines: string[],
nextLine: string,
@@ -136,10 +595,8 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
// If maxChars smaller than minimum envelope, return empty string
if (maxChars < MIN_ENVELOPE_LENGTH) return "";
const closing = "</workspace_memory>";
const lines: string[] = [
"<workspace_memory>",
"Persistent workspace memory. Use as background; verify stale or code-related claims.",
"Workspace memory (cross-session, verify if stale):",
];
for (const type of ["feedback", "project", "decision", "reference"] as const) {
@@ -150,17 +607,16 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
for (const item of items) {
const line = `- ${renderEntry(item)}`;
if (wouldFit([...lines, ...sectionLines], line, closing, maxChars)) {
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
sectionLines.push(line);
}
}
if (sectionLines.length > 1 && wouldFit(lines, sectionLines[0], closing, maxChars)) {
if (sectionLines.length > 1) {
lines.push(...sectionLines);
}
}
lines.push(closing);
return lines.join("\n");
}
+239 -17
View File
@@ -133,9 +133,8 @@ import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
test("parseWorkspaceMemoryCandidates rejects short text", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [decision] short text
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -143,9 +142,8 @@ test("parseWorkspaceMemoryCandidates rejects short text", () => {
test("parseWorkspaceMemoryCandidates rejects git commit hash", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [project] abc123def456 is the commit hash
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -153,9 +151,8 @@ test("parseWorkspaceMemoryCandidates rejects git commit hash", () => {
test("parseWorkspaceMemoryCandidates rejects raw error", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [feedback] TypeError: Cannot read property 'x' of undefined
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -163,9 +160,8 @@ test("parseWorkspaceMemoryCandidates rejects raw error", () => {
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [reference] at foo (bar.ts:10:5)
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -173,9 +169,8 @@ test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
test("parseWorkspaceMemoryCandidates rejects commit prefix", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [project] fix: add new feature
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -183,9 +178,8 @@ test("parseWorkspaceMemoryCandidates rejects commit prefix", () => {
test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [project] files at /src/a.ts /src/b.ts /src/c.ts are important
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
@@ -193,9 +187,8 @@ test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [decision] Use pnpm instead of npm for package management
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1);
@@ -205,11 +198,240 @@ test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
test("parseWorkspaceMemoryCandidates accepts valid project info", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [project] This project uses TypeScript for all source files
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1);
assert.equal(items[0].type, "project");
});
});
test("parseWorkspaceMemoryCandidates accepts plain text label format (no Markdown)", () => {
const summary = `
Memory candidates:
- [decision] Use plain text labels to avoid purple Markdown headers
- [project] This repo uses pnpm for package management
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 2);
assert.equal(items[0].type, "decision");
assert.equal(items[1].type, "project");
});
test("parseWorkspaceMemoryCandidates accepts bracketless candidate format", () => {
const summary = `
Memory candidates:
- project Backend health improvements organized into phased milestones
- reference Scrypt N=16384, r=8, p=1
- feedback 9473
- decision Use output.prompt to replace the default compaction template
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 4, "Should parse all 4 bracketless candidates");
assert.deepEqual(items.map(i => i.type), [
"project",
"reference",
"feedback",
"decision",
]);
});
test("parseWorkspaceMemoryCandidates rejects unknown bracketless candidate type", () => {
const summary = `
Memory candidates:
- note this should not be parsed as memory
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
});
test("parseWorkspaceMemoryCandidates rejects bracketless very short body", () => {
const summary = `
Memory candidates:
- project short
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
});
test("parseWorkspaceMemoryCandidates does not match bracketless type as substring", () => {
// "projectile" should NOT match "project"
const summary = `
Memory candidates:
- projectile launcher should not be parsed as a project memory
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
});
test("parseWorkspaceMemoryCandidates rejects exact test count snapshots", () => {
const summary = `
Memory candidates:
- project 1237 tests pass, 226 suites
- project 500 tests pass today
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Exact test counts are session snapshots, not durable memory");
});
test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => {
const summary = `
Memory candidates:
- project USB 37
- project 42 files synced
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Exact file counts are session snapshots");
});
test("parseWorkspaceMemoryCandidates rejects phase progress snapshots", () => {
const summary = `
Memory candidates:
- project Phase 1-4
- project Phase 3 completed
- project Completed phase 1
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Phase progress is session snapshot, not durable milestone");
});
test("parseWorkspaceMemoryCandidates rejects wave/sprint/milestone/task progress snapshots", () => {
const summary = `
Memory candidates:
- project Waves 1-5 Wave 6 deferred
- project Sprint 3 completed
- project Milestone 2 done
- project Task 8 finished
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Wave/Sprint/Milestone/Task progress should be rejected as snapshots");
});
test("parseWorkspaceMemoryCandidates keeps file limits but rejects file sync snapshots", () => {
const summary = `
Memory candidates:
- project Upload limit is 10 files per request
- project USB uploaded 37 files for sync verification
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1, "Should keep static file-limit facts and reject processed file-count snapshots");
assert.match(items[0].text, /Upload limit is 10 files/);
});
test("parseWorkspaceMemoryCandidates accepts durable project facts", () => {
const summary = `
Memory candidates:
- project Backend health improvements organized into phased milestones
- project USB sync covers bundles, server, frontend, tests, and docs
- project Test suite expected to pass before handoff
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 3, "Durable project facts should pass");
});
test("parseWorkspaceMemoryCandidates accepts short Admin PIN reference entry", () => {
// Real Admin PIN is <20 chars — should pass via config value allowlist
const summary = `
Memory candidates:
- reference Admin PIN 456123
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1, "Short config reference should pass via allowlist");
assert.equal(items[0].type, "reference");
});
test("parseWorkspaceMemoryCandidates accepts Scrypt config reference", () => {
// Scrypt parameters with numbers should pass
const summary = `
Memory candidates:
- reference Scrypt N=16384, r=8, p=1
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1, "Scrypt config values should pass");
assert.equal(items[0].type, "reference");
});
test("parseWorkspaceMemoryCandidates rejects Chinese file count snapshot", () => {
// Real Chinese file count with counter word 個
const summary = `
Memory candidates:
- project USB 37 bundles, server, frontend, tests, docs
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Chinese file count with 個 should be rejected");
});
test("parseWorkspaceMemoryCandidates rejects real phase snapshot mid-description", () => {
// Real phase snapshot where Phase appears deep in the string
const summary = `
Memory candidates:
- project pathology-playground Phase 1-4
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Phase snapshot mid-description should still be rejected");
});
test("parseWorkspaceMemoryCandidates extracts Japanese triggers", () => {
const summary = `
Memory candidates:
- project 覚えて: このプロジェクトは pnpm 使
- project 覚えておいて: 日本語でメモ
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 2);
assert.match(items[0].text, /pnpm/);
});
test("parseWorkspaceMemoryCandidates extracts Korean triggers", () => {
const summary = `
Memory candidates:
- project 기억해: pnpm을
- project 메모해줘: 한국어
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 2);
});
test("parseWorkspaceMemoryCandidates rejects negated Japanese triggers", () => {
const summary = `
Memory candidates:
- project 覚えて: 一時的なメモ
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Negated Japanese trigger should be rejected");
});
test("parseWorkspaceMemoryCandidates rejects negated Korean triggers", () => {
const summary = `
Memory candidates:
- project 기억해: 일시적인
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0, "Negated Korean trigger should be rejected");
});
test("parseWorkspaceMemoryCandidates body extraction excludes trigger suffix", () => {
const summary = `
Memory candidates:
- project 覚えておいて: このプロジェクトは pnpm 使
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items[0].text, "このプロジェクトは pnpm を使う");
assert.equal(items[0].text.includes("おいて"), false);
});
+93
View File
@@ -0,0 +1,93 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
const acceptedCases = [
{
name: "durable user language preference",
line: "- [feedback] User prefers architecture reviews in Traditional Chinese",
expectedType: "feedback",
expectedText: /Traditional Chinese/,
},
{
name: "stable cache architecture decision",
line: "- [decision] Use frozen workspace memory snapshots plus ephemeral hot state for cache stability",
expectedType: "decision",
expectedText: /frozen workspace memory/,
},
{
name: "stable zero API call constraint",
line: "- [project] The plugin piggybacks memory extraction on OpenCode compaction and should not add extra LLM calls",
expectedType: "project",
expectedText: /extra LLM calls/,
},
{
name: "hard to rediscover reference",
line: "- [reference] Workspace memory uses a frozen system[1] snapshot and pending memories remain in hot session state until compaction",
expectedType: "reference",
expectedText: /system\[1\]/,
},
{
name: "short stable config reference",
line: "- [reference] Config parser supports bracketless format",
expectedType: "reference",
expectedText: /bracketless/,
},
] as const;
const rejectedCases = [
{
name: "test count snapshot",
line: "- [project] 42 tests passed after the latest implementation",
},
{
name: "suite count snapshot",
line: "- [project] 3 suites pass and 0 suites fail right now",
},
{
name: "phase progress snapshot",
line: "- [project] Wave 2 completed successfully",
},
{
name: "commit hash",
line: "- [reference] Commit 4309cb8 contains the promotion accounting fix",
},
{
name: "raw transient error",
line: "- [feedback] TypeError: Cannot read properties of undefined",
},
{
name: "path heavy rediscoverable fact",
line: "- [project] Important files are /src/plugin.ts /src/workspace-memory.ts /src/session-state.ts",
},
{
name: "temporary pending task",
line: "- [decision] currently: run npm test before the next reply",
},
] as const;
for (const item of acceptedCases) {
test(`memory quality accepts ${item.name}`, () => {
const summary = `
Memory candidates:
${item.line}
`;
const entries = parseWorkspaceMemoryCandidates(summary);
assert.equal(entries.length, 1);
assert.equal(entries[0].type, item.expectedType);
assert.match(entries[0].text, item.expectedText);
});
}
for (const item of rejectedCases) {
test(`memory quality rejects ${item.name}`, () => {
const summary = `
Memory candidates:
${item.line}
`;
const entries = parseWorkspaceMemoryCandidates(summary);
assert.equal(entries.length, 0);
});
}
+1101 -3
View File
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { LongTermMemoryEntry } from "../src/types.ts";
import { accountPendingPromotions } from "../src/promotion-accounting.ts";
import { memoryKey } from "../src/pending-journal.ts";
import type { MemoryConsolidationEvent } from "../src/workspace-memory.ts";
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
function mem(
id: string,
text: string,
opts: Partial<LongTermMemoryEntry> = {},
): LongTermMemoryEntry {
const now = opts.createdAt ?? new Date().toISOString();
return {
id,
type: opts.type ?? "decision",
text,
source: opts.source ?? "compaction",
confidence: opts.confidence ?? 0.75,
status: opts.status ?? "active",
createdAt: now,
updatedAt: opts.updatedAt ?? now,
staleAfterDays: opts.staleAfterDays,
rationale: opts.rationale,
supersedes: opts.supersedes,
tags: opts.tags,
};
}
function event(
memory: LongTermMemoryEntry,
reason: MemoryConsolidationEvent["reason"],
): MemoryConsolidationEvent {
return {
memoryKey: workspaceMemoryExactKey(memory),
identityKey: workspaceMemoryIdentityKey(memory),
memory,
reason,
};
}
test("accountPendingPromotions marks exact retained pending memory as promoted", () => {
const pending = [mem("pending", "Use frozen rendered snapshots for cache stability.")];
const before: LongTermMemoryEntry[] = [];
const after = [pending[0]];
const result = accountPendingPromotions({ pending, before, after });
assert.deepEqual([...result.promotedKeys], [memoryKey(pending[0])]);
assert.equal(result.absorbedKeys.size, 0);
assert.equal(result.rejectedKeys.size, 0);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
});
test("accountPendingPromotions marks exact duplicate already represented before promotion as absorbed", () => {
const existing = mem("existing", "Prefer stable cache boundaries.", { source: "explicit" });
const pending = [mem("pending", "prefer stable cache boundaries.", { source: "explicit" })];
const before = [existing];
const after = [existing];
const result = accountPendingPromotions({ pending, before, after });
assert.equal(result.promotedKeys.size, 0);
assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]);
assert.equal(result.rejectedKeys.size, 0);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
});
test("accountPendingPromotions marks same exact key present before promotion as absorbed, not promoted", () => {
const existing = mem("existing", "Use stable cache boundaries.", { source: "explicit" });
const pending = [mem("pending", "Use stable cache boundaries.", { source: "explicit" })];
const before = [existing];
const after = [existing];
const result = accountPendingPromotions({ pending, before, after });
assert.equal(result.promotedKeys.size, 0,
"a pending memory whose exact key already existed before promotion is absorbed, not newly promoted");
assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]);
assert.equal(result.rejectedKeys.size, 0);
});
test("accountPendingPromotions ignores superseded exact keys when detecting existing absorption", () => {
const superseded = mem("superseded", "Revive this memory when it is remembered again.", {
source: "explicit",
status: "superseded",
});
const pending = [mem("pending", "Revive this memory when it is remembered again.", {
source: "explicit",
})];
const before = [superseded];
const after = [superseded, pending[0]];
const result = accountPendingPromotions({ pending, before, after });
assert.deepEqual([...result.promotedKeys], [memoryKey(pending[0])]);
assert.equal(result.absorbedKeys.size, 0);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
});
test("accountPendingPromotions marks same-topic decision represented after normalization as absorbed", () => {
const existing = mem("existing", "Parser supports 2 candidate formats.", {
type: "decision",
source: "compaction",
confidence: 0.9,
createdAt: "2026-04-27T10:00:00.000Z",
updatedAt: "2026-04-27T10:00:00.000Z",
});
const pending = [mem("pending", "Parser supports 3 candidate formats.", {
type: "decision",
source: "compaction",
confidence: 0.75,
createdAt: "2026-04-27T09:00:00.000Z",
updatedAt: "2026-04-27T09:00:00.000Z",
})];
const before = [existing];
const after = [existing];
const result = accountPendingPromotions({ pending, before, after });
assert.equal(result.promotedKeys.size, 0);
assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]);
assert.equal(result.rejectedKeys.size, 0);
});
test("accountPendingPromotions keeps pending memory rejected when no equivalent survived", () => {
const pending = [mem("pending", "Low priority memory that did not fit the workspace cap.", {
type: "reference",
source: "compaction",
})];
const before: LongTermMemoryEntry[] = [];
const after: LongTermMemoryEntry[] = [];
const result = accountPendingPromotions({ pending, before, after });
assert.equal(result.promotedKeys.size, 0);
assert.equal(result.absorbedKeys.size, 0);
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
assert.equal(result.clearableKeys.size, 0);
});
test("accountPendingPromotions clears accounting absorbed identity events", () => {
const pending = [mem("pending_identity", "This repo uses opencode-agenthub plugin system", {
type: "project",
source: "compaction",
})];
const result = accountPendingPromotions({
pending,
before: [],
after: [],
events: [event(pending[0], "absorbed_identity")],
});
assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
assert.equal(result.rejectedKeys.size, 0);
});
test("accountPendingPromotions separates accounting superseded events", () => {
const pending = [mem("pending_topic", "Parser supports 3 candidate formats.", {
type: "decision",
source: "compaction",
})];
const result = accountPendingPromotions({
pending,
before: [],
after: [],
events: [event(pending[0], "superseded_existing")],
});
assert.deepEqual([...result.supersededKeys], [memoryKey(pending[0])]);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
assert.equal(result.absorbedKeys.size, 0);
assert.equal(result.rejectedKeys.size, 0);
});
test("accountPendingPromotions clears compaction capacity rejection from accounting", () => {
const pending = [mem("pending_capacity", "Weak compaction reference that should lose capacity review.", {
type: "reference",
source: "compaction",
})];
const result = accountPendingPromotions({
pending,
before: [],
after: [],
events: [event(pending[0], "rejected_capacity")],
});
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
});
test("accountPendingPromotions keeps explicit capacity rejection pending", () => {
const pending = [mem("pending_explicit_capacity", "Explicit reference should retry if capacity rejected.", {
type: "reference",
source: "explicit",
})];
const result = accountPendingPromotions({
pending,
before: [],
after: [],
events: [event(pending[0], "rejected_capacity")],
});
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
assert.equal(result.clearableKeys.size, 0);
});
test("accountPendingPromotions clears compaction stale rejection from accounting", () => {
const pending = [mem("pending_stale", "Stale compaction reference should be terminal.", {
type: "reference",
source: "compaction",
})];
const result = accountPendingPromotions({
pending,
before: [],
after: [],
events: [event(pending[0], "rejected_stale")],
});
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
});
+693 -8
View File
@@ -1,7 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { renderWorkspaceMemory, enforceLongTermLimits } from "../src/workspace-memory.ts";
import { LONG_TERM_LIMITS } from "../src/types.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
import {
renderWorkspaceMemory,
enforceLongTermLimits,
dedupeLongTermEntriesWithAccounting,
enforceLongTermLimitsWithAccounting,
normalizeWorkspaceMemoryWithAccounting,
workspaceMemoryExactKey,
redactCredentials,
isProjectSnapshotViolation,
runMigrationP0Cleanup,
loadWorkspaceMemory,
saveWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
} from "../src/workspace-memory.ts";
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
const now = new Date().toISOString();
@@ -17,11 +35,32 @@ function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "de
};
}
/** Create an entry with a createdAt offset from now (negative = in the past) */
function agedEntry(
id: string,
text: string,
type: LongTermMemoryEntry["type"] = "decision",
opts: { daysAgo: number; source?: "compaction" | "explicit" | "manual"; staleAfterDays?: number } = { daysAgo: 0, source: "compaction" },
): LongTermMemoryEntry {
const createdAt = new Date(Date.now() - opts.daysAgo * 86400000).toISOString();
return {
id,
type,
text,
source: opts.source ?? "compaction",
confidence: 0.75,
status: "active",
createdAt,
updatedAt: createdAt,
staleAfterDays: opts.staleAfterDays,
};
}
// ============================================
// Task 2: renderWorkspaceMemory tests
// ============================================
test("renderWorkspaceMemory never truncates closing XML tag", () => {
test("renderWorkspaceMemory respects budget and fits entries", () => {
const entries = Array.from({ length: 28 }, (_, i) =>
entry(`mem_${i}`, `Long durable memory entry ${i} `.repeat(20))
);
@@ -36,8 +75,8 @@ test("renderWorkspaceMemory never truncates closing XML tag", () => {
const rendered = renderWorkspaceMemory(store);
assert.ok(rendered.endsWith("</workspace_memory>"),
`Rendered memory must end with closing tag. Got: ...${rendered.slice(-50)}`);
assert.ok(!rendered.includes("<workspace_memory>"),
"Should not contain XML tags");
assert.ok(rendered.length <= 700,
`Rendered memory must not exceed maxChars. Got: ${rendered.length}`);
});
@@ -56,7 +95,7 @@ test("renderWorkspaceMemory returns empty string when maxChars too small", () =>
"When maxChars too small for even minimal envelope, return empty string");
});
test("renderWorkspaceMemory respects budget and fits entries", () => {
test("renderWorkspaceMemory respects small budget", () => {
// Create entries that would overflow a small budget
const entries = [
entry("a", "First memory entry that is reasonably long"),
@@ -74,8 +113,8 @@ test("renderWorkspaceMemory respects budget and fits entries", () => {
const rendered = renderWorkspaceMemory(store);
assert.ok(rendered.endsWith("</workspace_memory>"),
"Must end with closing tag even when truncating entries");
assert.ok(!rendered.includes("<workspace_memory>"),
"Should not contain XML tags");
assert.ok(rendered.length <= 200,
`Must respect maxChars limit. Got: ${rendered.length}`);
});
@@ -207,4 +246,650 @@ test("enforceLongTermLimits respects maxEntries limit", () => {
const kept = enforceLongTermLimits(entries);
assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`);
});
});
test("dedupeLongTermEntriesWithAccounting reports exact duplicates as absorbed", () => {
const now = new Date().toISOString();
const lower: LongTermMemoryEntry = {
id: "lower",
type: "decision",
text: "OpenCode uses NPM CACHE for plugin loading",
source: "compaction",
confidence: 0.7,
status: "active",
createdAt: now,
updatedAt: now,
};
const higher: LongTermMemoryEntry = {
id: "higher",
type: "decision",
text: "opencode uses npm cache for plugin loading!!!",
source: "compaction",
confidence: 0.8,
status: "active",
createdAt: now,
updatedAt: now,
};
const result = dedupeLongTermEntriesWithAccounting([lower, higher]);
assert.equal(result.kept.length, 1);
assert.equal(result.kept[0].id, "higher");
assert.deepEqual(result.absorbed.map(event => event.reason), ["absorbed_exact"]);
assert.equal(result.absorbed[0].memory.id, "lower");
});
test("dedupeLongTermEntriesWithAccounting reports identity duplicates as absorbed", () => {
const older = agedEntry(
"older",
"This repo uses opencode-agenthub plugin system at /Users/sd_wo/work/opencode-working-memory/",
"project",
{ daysAgo: 5 },
);
const newer = agedEntry(
"newer",
"此 repo 在開發時使用 opencode-agenthub 插件系統,目錄位於 /Users/sd_wo/work/opencode-working-memory/.opencode-agenthub/",
"project",
{ daysAgo: 0 },
);
const result = dedupeLongTermEntriesWithAccounting([older, newer]);
assert.equal(result.kept.length, 1);
assert.equal(result.absorbed.length, 1);
assert.equal(result.absorbed[0].reason, "absorbed_identity");
assert.equal(result.absorbed[0].retainedId, result.kept[0].id);
});
test("dedupeLongTermEntriesWithAccounting reports topic duplicates as superseded", () => {
const older = agedEntry(
"older",
"Parser supports 3 formats: HTML comment, Markdown section, legacy XML",
"decision",
{ daysAgo: 5 },
);
const newer = agedEntry(
"newer",
"Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML",
"decision",
{ daysAgo: 0 },
);
const result = dedupeLongTermEntriesWithAccounting([older, newer]);
assert.equal(result.kept.length, 1);
assert.equal(result.kept[0].id, "newer");
assert.equal(result.superseded.length, 1);
assert.equal(result.superseded[0].reason, "superseded_existing");
assert.equal(result.superseded[0].supersededId, "older");
assert.equal(result.superseded[0].retainedId, "newer");
});
test("enforceLongTermLimitsWithAccounting reports capacity drops", () => {
const now = new Date().toISOString();
const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, i) => ({
id: `mem_${i}`,
type: "reference" as const,
text: `Unique low priority reference ${i}`,
source: "compaction" as const,
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
}));
const result = enforceLongTermLimitsWithAccounting(entries);
assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries);
assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 2);
assert.ok(result.dropped.every(event => event.memory.source === "compaction"));
});
test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () => {
const now = new Date().toISOString();
const entry: LongTermMemoryEntry = {
id: "key_alignment",
type: "decision",
text: "OpenCode uses NPM CACHE for plugin loading!!!",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
};
assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading");
});
test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => {
const root = "/repo";
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-26-p0-cleanup"],
entries: [{
id: "cred",
type: "reference",
text: "Admin PIN 是 456123",
rationale: "password: sushi",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
}],
updatedAt: now,
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.kept.length, 1);
assert.equal(result.kept[0].text, "Admin PIN 是 [REDACTED]");
assert.equal(result.kept[0].rationale, "password: [REDACTED]");
assert.equal(result.store.entries[0].text, "Admin PIN 是 [REDACTED]");
});
test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => {
const root = "/repo";
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-26-p0-cleanup"],
entries: Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
id: `overflow_${i}`,
type: "reference" as const,
text: `Overflow reference ${i}`,
source: "compaction" as const,
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
})),
updatedAt: now,
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries);
assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries);
assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 1);
});
test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async () => {
const root = "/repo";
const now = new Date().toISOString();
const stale = agedEntry(
"stale_normalize",
"Old compaction decision should be removed by normalization accounting",
"decision",
{ daysAgo: 90, staleAfterDays: 1 },
);
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-26-p0-cleanup"],
entries: [stale],
updatedAt: now,
};
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
assert.equal(result.kept.length, 0);
assert.equal(result.store.entries.length, 0);
assert.deepEqual(result.dropped.map(event => event.reason), ["rejected_stale"]);
assert.equal(result.dropped[0].memory.id, "stale_normalize");
});
test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted updates", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-update-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
const now = new Date().toISOString();
const result = await updateWorkspaceMemoryWithAccounting(root, store => {
store.entries.push(...Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
id: `persisted_${i}`,
type: "reference" as const,
text: `Persisted accounting reference ${i}`,
source: "compaction" as const,
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
})));
return store;
});
assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries);
assert.equal(result.events.filter(event => event.reason === "rejected_capacity").length, 1);
const persisted = await loadWorkspaceMemory(root);
assert.equal(persisted.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries);
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
await rm(sandbox, { recursive: true, force: true });
}
});
// ============================================
// P0d: identity-key dedup, supersession, staleness
// ============================================
test("enforceLongTermLimits project: bilingual variants collapse to one", () => {
// All three mention opencode-agenthub plugin system - should merge
const entries = [
agedEntry("p1", "此 repo 在開發時使用 opencode-agenthub 插件系統,目錄位於 /Users/sd_wo/work/opencode-working-memory/.opencode-agenthub/", "project", { daysAgo: 2 }),
agedEntry("p2", "此 repo 在開發時使用 opencode-agenthub 插件系統", "project", { daysAgo: 1 }),
agedEntry("p3", "This repo uses opencode-agenthub plugin system at /Users/sd_wo/work/opencode-working-memory/", "project", { daysAgo: 0 }),
];
const kept = enforceLongTermLimits(entries);
const projectEntries = kept.filter(e => e.type === "project");
assert.equal(projectEntries.length, 1, "All three project variants should merge to one");
});
test("enforceLongTermLimits reference: same config path variants collapse to one", () => {
const entries = [
agedEntry("r1", "OpenCode plugin config location: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 1 }),
agedEntry("r2", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 0 }),
];
const kept = enforceLongTermLimits(entries);
const refEntries = kept.filter(e => e.type === "reference");
assert.equal(refEntries.length, 1, "Both reference variants should merge to one");
});
test("enforceLongTermLimits decision: newer supersedes older on same topic", () => {
// "4 formats" supersedes "3 formats" on the same parser topic
const entries = [
agedEntry("d1", "Parser supports 3 formats: HTML comment, Markdown section, legacy XML", "decision", { daysAgo: 2 }),
agedEntry("d2", "Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML", "decision", { daysAgo: 0 }),
];
const kept = enforceLongTermLimits(entries);
const decisionEntries = kept.filter(e => e.text.includes("formats"));
assert.equal(decisionEntries.length, 1, "Newer 4-formats should supersede older 3-formats");
assert.ok(decisionEntries[0].text.includes("4 formats"), "Kept entry should be the 4-formats one");
});
test("enforceLongTermLimits feedback: newer supersedes older on same issue", () => {
const entries = [
agedEntry("f1", "Purple/italic text issue resolved by using plain text labels instead of any special markup syntax", "feedback", { daysAgo: 2 }),
agedEntry("f2", "Purple/italic text issue resolved by replacing default compaction template with ---free version using only Markdown headings", "feedback", { daysAgo: 0 }),
];
const kept = enforceLongTermLimits(entries);
const feedbackEntries = kept.filter(e => e.type === "feedback");
assert.equal(feedbackEntries.length, 1, "Newer purple/italic fix should supersede older");
assert.ok(feedbackEntries[0].text.includes("replacing default compaction template"), "Kept entry should be the newer fix");
});
test("enforceLongTermLimits stale: compaction entry older than staleAfterDays+grace is pruned", () => {
// decision with staleAfterDays=45, 76 days old (> 45+30 grace=75)
const entries = [
agedEntry("stale", "Compaction output contract changed from XML to HTML comments to avoid Markdown rendering issues", "decision", { daysAgo: 76, staleAfterDays: 45 }),
];
const kept = enforceLongTermLimits(entries);
assert.equal(kept.length, 0, "Stale compaction entry should be pruned");
});
test("enforceLongTermLimits stale: explicit entry is retained even if old", () => {
// explicit entry - never auto-pruned regardless of age
const entries = [
agedEntry("old_explicit", "User explicitly set Admin PIN 456123 for the system", "reference", { daysAgo: 500, source: "explicit", staleAfterDays: 90 }),
];
const kept = enforceLongTermLimits(entries);
assert.equal(kept.length, 1, "Explicit entry should never be age-pruned");
});
test("enforceLongTermLimits stale: feedback entry is retained regardless of age", () => {
// feedback - never age-pruned (only superseded)
const entries = [
agedEntry("old_feedback", "Users prefer darker themes over light themes", "feedback", { daysAgo: 300, staleAfterDays: 30 }),
];
const kept = enforceLongTermLimits(entries);
assert.equal(kept.length, 1, "Feedback entry should never be age-pruned");
});
test("enforceLongTermLimits stale: compaction entry within grace period is retained", () => {
// decision staleAfterDays=45, 60 days old (< 45+30=75 grace) - should keep
const entries = [
agedEntry("within_grace", "Some compaction decision made two months ago", "decision", { daysAgo: 60, staleAfterDays: 45 }),
];
const kept = enforceLongTermLimits(entries);
assert.equal(kept.length, 1, "Entry within grace period should be retained");
});
test("enforceLongTermLimits dedup before trim: cleanup runs before maxEntries slice", () => {
// 30 entries that should dedupe to < 28, confirming trim doesn't run before dedupe
const entries = [
...Array.from({ length: 15 }, (_, i) =>
agedEntry(`a${i}`, "opencode uses npm cache for plugin loading", "decision", { daysAgo: 0 })
),
...Array.from({ length: 15 }, (_, i) =>
agedEntry(`b${i}`, "opencode uses npm cache for plugin loading", "decision", { daysAgo: 0 })
),
];
const kept = enforceLongTermLimits(entries);
assert.equal(kept.length, 1, "All duplicates should merge to 1 entry, far below maxEntries");
});
test("enforceLongTermLimits priority: freshness used as tie-breaker among same priority entries", () => {
// Same type, same source, same confidence — newer should win
const older = agedEntry("older", "Some durable configuration fact about the workspace", "reference", { daysAgo: 30, source: "compaction", staleAfterDays: 90 });
const newer = agedEntry("newer", "Some durable configuration fact about the workspace", "reference", { daysAgo: 5, source: "compaction", staleAfterDays: 90 });
const kept = enforceLongTermLimits([older, newer]);
assert.equal(kept.length, 1);
assert.equal(kept[0].id, "newer", "Newer entry should win as tie-breaker");
});
test("enforceLongTermLimits feedback: 500 error and port issue are NOT collapsed", () => {
// Distinct feedback entries should remain separate
const entries = [
agedEntry("f1", "瀏覽器登入出現 500 internal_error,代碼邏輯正確但原因不明", "feedback", { daysAgo: 0 }),
agedEntry("f2", "端口 9473 可能被舊進程佔用,需殺掉後重啟", "feedback", { daysAgo: 0 }),
];
const kept = enforceLongTermLimits(entries);
const feedbackEntries = kept.filter(e => e.type === "feedback");
assert.equal(feedbackEntries.length, 2, "Distinct feedback items should not collapse");
});
test("enforceLongTermLimits config: unrelated plugin configs are NOT collapsed", () => {
const entries = [
agedEntry("c1", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 0 }),
agedEntry("c2", "Vite plugin config location: vite.config.ts at project root", "reference", { daysAgo: 0 }),
];
const kept = enforceLongTermLimits(entries);
const refEntries = kept.filter(e => e.type === "reference");
assert.equal(refEntries.length, 2, "Unrelated plugin configs should remain separate");
});
test("enforceLongTermLimits supersession: newer shorter decision beats older longer one", () => {
// Same topic, same source, same confidence — newer wins even if shorter
const older = agedEntry("d1", "Parser supports 3 formats: HTML comment, Markdown section, legacy XML with backward compatibility", "decision", { daysAgo: 5 });
const newer = agedEntry("d2", "Parser supports 4 formats", "decision", { daysAgo: 0 });
const kept = enforceLongTermLimits([older, newer]);
const decisions = kept.filter(e => e.type === "decision" && /parser.*format/i.test(e.text));
assert.equal(decisions.length, 1, "Newer shorter decision should supersede older longer one");
assert.ok(decisions[0].text.includes("4 formats"), "Kept entry should be the newer 4-formats");
});
test("enforceLongTermLimits feedback: English port issue does NOT collapse with server error", () => {
const entries = [
agedEntry("e1", "Browser login 500 internal_error, code correct but cause unknown", "feedback", { daysAgo: 0 }),
agedEntry("e2", "Port 9473 occupied by old process, may need to kill and restart", "feedback", { daysAgo: 0 }),
];
const kept = enforceLongTermLimits(entries);
const feedbackEntries = kept.filter(e => e.type === "feedback");
assert.equal(feedbackEntries.length, 2, "English port issue and server error should remain separate");
});
test("enforceLongTermLimits config: unrelated generic plugin configs do NOT collapse", () => {
const entries = [
agedEntry("c1", "Vite plugin config location: vite.config.ts at project root", "reference", { daysAgo: 0 }),
agedEntry("c2", "ESLint plugin config location: eslint.config.js at project root", "reference", { daysAgo: 0 }),
];
const kept = enforceLongTermLimits(entries);
const refEntries = kept.filter(e => e.type === "reference");
assert.equal(refEntries.length, 2, "Unrelated plugin configs without entity key should remain separate");
});
test("enforceLongTermLimits feedback: supersession prefers newer shorter over older longer", () => {
// Same purple/italic issue, newer shorter fix supersedes older verbose fix
const older = agedEntry("f1", "Purple/italic text issue resolved by using plain text labels instead of any special markup syntax in the prompt", "feedback", { daysAgo: 5 });
const newer = agedEntry("f2", "Purple/italic text fixed via template replacement", "feedback", { daysAgo: 0 });
const kept = enforceLongTermLimits([older, newer]);
const feedbackEntries = kept.filter(e => e.type === "feedback");
assert.equal(feedbackEntries.length, 1, "Newer shorter feedback should supersede older longer");
assert.ok(feedbackEntries[0].text.includes("template replacement"), "Kept entry should be the newer fix");
});
// ============================================
// Workspace cleanup migration tests
// ============================================
test("redactCredentials preserves PIN delimiter variants", () => {
assert.equal(redactCredentials("Admin PIN 是 456123"), "Admin PIN 是 [REDACTED]");
assert.equal(redactCredentials("Admin PIN = 456123"), "Admin PIN = [REDACTED]");
assert.equal(redactCredentials("Admin PIN 456123"), "Admin PIN [REDACTED]");
});
test("redactCredentials handles multilingual passwords", () => {
assert.equal(redactCredentials("パスワード:secret"), "パスワード:[REDACTED]");
assert.equal(redactCredentials("비밀번호: secret"), "비밀번호: [REDACTED]");
assert.equal(redactCredentials("contraseña: secret"), "contraseña: [REDACTED]");
});
test("redactCredentials handles username+password pair and punctuation boundary", () => {
assert.equal(
redactCredentials("測試用戶名:shihlab,密碼:sushi"),
"測試用戶名:[REDACTED],密碼:[REDACTED]",
);
assert.equal(
redactCredentials("密碼:sushi,用於測試"),
"密碼:[REDACTED],用於測試",
);
});
test("redactCredentials is idempotent and also redacts rationale text", () => {
assert.equal(redactCredentials("password: [REDACTED]"), "password: [REDACTED]");
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
migrations: [],
entries: [
{
id: "cred",
type: "reference",
text: "Admin PIN 是 456123",
rationale: "password: sushi",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
},
],
updatedAt: now,
};
const migrated = runMigrationP0Cleanup(
{
...store,
entries: store.entries.map(entry => ({
...entry,
text: redactCredentials(entry.text),
rationale: entry.rationale ? redactCredentials(entry.rationale) : undefined,
})),
},
now,
);
assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]");
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
});
test("isProjectSnapshotViolation detects wave progress and avoids limit context false positives", () => {
assert.equal(isProjectSnapshotViolation("1237 tests pass, 226 suites"), true);
assert.equal(isProjectSnapshotViolation("USB 同步:37 個文件"), true);
assert.equal(isProjectSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true);
assert.equal(isProjectSnapshotViolation("Upload limit is 10 files"), false);
assert.equal(isProjectSnapshotViolation("Project supports 5 test suites"), false);
});
test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs once", () => {
const now = new Date().toISOString();
const later = new Date(Date.now() + 1000).toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
migrations: [],
entries: [
{
id: "project-snapshot",
type: "project",
text: "Phase 1-4 已完成",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
},
{
id: "project-explicit",
type: "project",
text: "Waves 1-5 已完成",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
},
{
id: "feedback-snapshot-like",
type: "feedback",
text: "1237 tests pass",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
},
],
updatedAt: now,
};
const once = runMigrationP0Cleanup(store, now);
assert.deepEqual(once.migrations, ["2026-04-26-p0-cleanup"]);
assert.equal(once.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
assert.equal(once.entries.find(e => e.id === "project-explicit")?.status, "active");
assert.equal(once.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
const twice = runMigrationP0Cleanup(once, later);
assert.deepEqual(twice.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
});
test("renderWorkspaceMemory excludes superseded entries", () => {
const now = new Date().toISOString();
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
migrations: ["2026-04-26-p0-cleanup"],
entries: [
{
id: "active-1",
type: "decision",
text: "Use pnpm for this workspace",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
},
{
id: "sup-1",
type: "project",
text: "Waves 1-5 已完成",
source: "compaction",
confidence: 0.75,
status: "superseded",
createdAt: now,
updatedAt: now,
},
],
updatedAt: now,
};
const rendered = renderWorkspaceMemory(store);
assert.match(rendered, /Use pnpm/);
assert.doesNotMatch(rendered, /Waves 1-5 已完成/);
});
test("loadWorkspaceMemory normalizes and persists credentials from legacy unredacted store", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-redact-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
const now = new Date().toISOString();
// Write UNREDACTED JSON directly to disk (simulating legacy store)
const unredactedStore: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "test" },
limits: {
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: LONG_TERM_LIMITS.maxEntries,
},
entries: [
{
id: "cred-1",
text: "Admin PIN 是 456123",
type: "project",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
},
],
migrations: [],
updatedAt: now,
};
// Write directly to disk WITHOUT using saveWorkspaceMemory (which would redact)
const { mkdir, writeFile } = await import("node:fs/promises");
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify(unredactedStore, null, 2), "utf-8");
// Load should normalize and redact
const loaded = await loadWorkspaceMemory(root);
assert.equal(loaded.entries[0].text, "Admin PIN 是 [REDACTED]");
// Verify persisted to disk (not just in-memory)
const { readFile } = await import("node:fs/promises");
const persistedRaw = await readFile(storePath, "utf-8");
const persisted = JSON.parse(persistedRaw);
assert.equal(persisted.entries[0].text, "Admin PIN 是 [REDACTED]");
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
await rm(sandbox, { recursive: true, force: true });
}
});