mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe6ce36e09 | |||
| 3cc6dff7ae | |||
| 1c748f3ee2 | |||
| ca68b7f55c | |||
| 023589a905 | |||
| 24f807fed0 | |||
| 097235e43b | |||
| 14bbb76cf1 | |||
| fd8d730e3b | |||
| 4309cb855f | |||
| 2437a9dc71 | |||
| 3560868f52 | |||
| e7c7a5cfb2 | |||
| 026c75a5e4 | |||
| eb74a9f03e | |||
| f6f35e87c1 | |||
| 6603fe869d | |||
| 3d44269228 | |||
| a154139b27 | |||
| 7527765207 | |||
| f9acfd6136 | |||
| ca71c20a8f | |||
| 5e9ada6859 | |||
| 721544e7a8 | |||
| 32fa2bd454 | |||
| af539a42f3 | |||
| eff0d3784c | |||
| 2354b62350 | |||
| 92e90124de |
@@ -48,3 +48,6 @@ pnpm-lock.yaml
|
||||
.opencode/
|
||||
.opencode-agenthub/
|
||||
.opencode-agenthub.user.json
|
||||
|
||||
# Superpowers local planning artifacts
|
||||
docs/superpowers/plans/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -1,31 +1,40 @@
|
||||
# OpenCode Working Memory Plugin
|
||||
# OpenCode Working Memory
|
||||
|
||||
[](https://www.npmjs.com/package/opencode-working-memory)
|
||||
[](LICENSE)
|
||||
|
||||
**Automatic memory system that keeps your AI agent context-aware across compactions.**
|
||||
Automatic memory for OpenCode agents.
|
||||
|
||||
Stop losing context when OpenCode compacts your conversation. This plugin automatically tracks what matters — decisions, active files, open errors — and preserves it across sessions.
|
||||
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 OpenCode’s existing compaction flow.
|
||||
- **No manual tools** — memory is injected automatically into the system prompt.
|
||||
- **Quality guards** — filters noisy memories, temporary progress snapshots, stack traces, raw errors, and credentials.
|
||||
|
||||
## 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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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])]);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user