Compare commits

...

11 Commits

Author SHA1 Message Date
Ralph Chang 22774c5ed2 docs: add RELEASE_NOTES.md for v1.2.0 2026-04-26 14:18:34 +08:00
Ralph Chang 9892012d8b chore: prepare for v1.2.0 release
- Bump version to 1.2.0
- Add package.json exports and files whitelist
- Update .gitignore to exclude .opencode/ and .opencode-agenthub/
- Fix docs: active files tracked in tool.execute.after (not before)
- Fix docs: exitCode undefined is ignored (not success)
- Fix docs: session ID is hashed in storage path
- Fix docs: workspace memory survives within same workspace
2026-04-26 14:17:54 +08:00
Ralph Chang f988af4453 docs: enhance README with visual three-layer architecture diagram
- Add table showing layer scope, tracking, and persistence
- Add visual ASCII diagram emphasizing cross-session capability
- Add compaction flow diagram showing no extra API call
- Highlight piggyback on existing compaction summary
2026-04-26 13:43:06 +08:00
Ralph Chang 606dcfac12 docs: fix hook names and prompt tags in documentation
- Fix outdated hook names: prompt:before → experimental.chat.system.transform
- Fix outdated hook names: tool.execute.before → (removed, file tracking is in tool.execute.after)
- Fix outdated hook names: compaction:before → experimental.session.compacting
- Add missing event hook documentation
- Fix README example to show correct <hot_session_state> tag instead of <workspace_memory_candidates>
2026-04-26 13:36:49 +08:00
Ralph Chang 802ef62636 docs: update documentation to Memory V2 architecture
- Replace four-tier architecture with three-layer Memory V2
- Remove references to non-existent tools (core_memory_*, working_memory_*)
- Update storage paths to ~/.local/share/opencode-working-memory/
- Update configuration to LONG_TERM_LIMITS and HOT_STATE_LIMITS
- Fix installation verification to check system prompt instead of tools
2026-04-26 13:27:14 +08:00
Ralph Chang ff4639d153 fix: PR-2 memory plugin behavior improvements
## Task 5: Canonical exact dedupe
- Already implemented in PR-1 with enforceLongTermLimits()
- Source priority: explicit > manual > compaction
- Same source: higher confidence wins

## Task 6: Structured negative guard
- Add isNegatedMemoryRequest() for adjacency detection
- "不要記住" / "don't remember" are now properly ignored
- "not forget to remember" no longer false positive (not a directive)
- Restrict patterns to line-start (^|\n) to avoid mid-sentence matches

## Task 7: Compaction quality gate
- Add shouldAcceptWorkspaceMemoryCandidate() predicate
- Reject low-quality candidates: git hashes, errors, stack traces
- Reject temporary progress, code signatures, path-heavy facts
- Only accept entries with >= 20 chars

## Pattern improvements
- All patterns use matchAll() with proper g flag
- Dedupe by canonical text in extractExplicitMemories()
- Line-start anchor prevents "to remember" mid-sentence matches
- Add more trigger patterns: save/add to memory, commit to memory

Tests: 36 passing
2026-04-26 13:06:36 +08:00
Ralph Chang 1bba0511bb fix: PR-1 memory plugin quality fixes
## Task 1: Fix exitCode undefined false positive
- Add `typeof exitCode !== "number"` check in plugin.ts
- Only extract errors when exitCode is explicitly non-zero
- Prevent git-log/cat with "errors" text from creating false positives

## Task 2: Fix workspace memory XML truncation
- Budget-aware line-by-line rendering
- Always include closing </workspace_memory> tag
- Return empty string when budget too small
- Bonus: canonical exact deduplication with source priority

## Task 3: Remove "always" as trigger
- Replace "always" with "going forward" in patterns
- Add word boundary via `g` flag and matchAll loop
- "from now on" still works as expected

## Task 4: Verification
- 22 tests passing
- typecheck passing

Tests cover:
- git log/cat with loose "errors" ignored
- TS2345/TypeError strong signals captured
- undefined exitCode: no create, no clear
- exitCode 0: clears errors
- exitCode non-zero: creates error
- XML never truncated mid-tag
- "always" not a trigger
2026-04-26 12:52:21 +08:00
Ralph Chang 2d7cb6cdf4 fix: strengthen plugin regression test with strong error signals
- Use TS2345 error instead of loose 'errors' word
- Add try/finally for temp directory cleanup
- Remove duplicate placeholder test block
- Clarify quality gate placement in parseWorkspaceMemoryCandidates()
2026-04-26 12:26:42 +08:00
Ralph Chang 9f9763c0e1 docs: add comprehensive memory plugin quality fixes plan
- PR-1: Fix bash error false positive, XML truncation, remove always trigger
- PR-2: Canonical exact dedupe, structured negative guard, quality gate
- Add executable plugin hook regression tests
- Add source priority for explicit > manual > compaction
- Define quality gate predicates with test matrix
2026-04-26 12:23:39 +08:00
Ralph Chang df54232fb9 refactor: simplify entry point to v2 architecture
- Replace 2000+ line monolithic index.ts with thin wrapper importing MemoryV2Plugin
- Update package.json description to reflect three-layer architecture
- Add test script for Node.js built-in test runner
2026-04-26 11:13:57 +08:00
Ralph Chang 72dc919ece chore: ignore worktrees directory 2026-04-25 18:20:42 +08:00
22 changed files with 4008 additions and 2975 deletions
+8
View File
@@ -40,3 +40,11 @@ temp/
package-lock.json
yarn.lock
pnpm-lock.yaml
# Git worktrees
.worktrees/
# OpenCode plugin runtime
.opencode/
.opencode-agenthub/
.opencode-agenthub.user.json
+172 -184
View File
@@ -2,11 +2,11 @@
## Project Overview
The **OpenCode Working Memory Plugin** provides a four-tier memory architecture for AI agents:
- **Core Memory** - Persistent blocks (goal/progress/context) that survive compaction
- **Working Memory** - Session-scoped context with slots (error/decision/todo/dependency) and memory pool
- **Smart Pruning** - Automatic filtering of tool outputs before adding to context
- **Pressure Monitoring** - Tracks context usage and triggers interventions at thresholds
The **OpenCode Working Memory Plugin** 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
3. **Native OpenCode State** - Delegated to OpenCode's built-in todos during compaction
Written in **TypeScript** for the OpenCode agent environment.
@@ -17,6 +17,8 @@ Written in **TypeScript** for the OpenCode agent environment.
git clone https://github.com/sdwolf4103/opencode-working-memory.git
cd opencode-working-memory
npm install
npm test
npm run typecheck
# For usage (see README.md)
```
@@ -30,24 +32,45 @@ npx tsc --noEmit
```
### Testing
Tests are manually verified through OpenCode sessions:
```bash
# 1. Load plugin in OpenCode session
# 2. Run commands that trigger hooks (e.g., tool execution, compaction)
# 3. Inspect .opencode/memory-core/ and .opencode/memory-working/
# 4. Verify memory blocks appear in system prompts
# Run all tests
npm test
# Run specific test file
npx node --test --experimental-strip-types tests/plugin.test.ts
```
Tests verify:
- Error extraction false positive guards
- Explicit memory trigger patterns
- Negative memory request filtering
- Compaction candidate quality gate
- Workspace memory rendering
- Session state tracking
### File Structure
```
opencode-working-memory/
├── index.ts # Main plugin (1700+ lines)
├── package.json # Plugin manifest
├── tsconfig.json # TypeScript config
├── LICENSE # MIT license
├── README.md # User documentation
├── AGENTS.md # This file (developer guide)
└── docs/ # Detailed documentation
├── index.ts # Plugin entry point (exports PluginModule)
├── src/
│ ├── plugin.ts # Main plugin implementation
│ ├── extractors.ts # Memory extraction logic
│ ├── workspace-memory.ts # Workspace memory management
│ ├── session-state.ts # Session state tracking
│ ├── storage.ts # File storage utilities
│ ├── paths.ts # Path utilities
│ ├── opencode.ts # OpenCode SDK types
│ └── types.ts # Type definitions
├── tests/
│ ├── plugin.test.ts # Plugin hook tests
│ ├── extractors.test.ts # Extractor tests
│ └── workspace-memory.test.ts # Workspace memory tests
├── package.json # Plugin manifest
├── tsconfig.json # TypeScript config
├── LICENSE # MIT license
├── README.md # User documentation
├── AGENTS.md # This file (developer guide)
└── docs/ # Detailed documentation
├── installation.md
├── architecture.md
└── configuration.md
@@ -59,39 +82,38 @@ opencode-working-memory/
```typescript
// ✅ REQUIRED: Full type annotations, no implicit any
async function loadCoreMemory(
directory: string,
sessionID: string
): Promise<CoreMemory | null>
async function loadWorkspaceMemory(
workspaceKey: string
): Promise<WorkspaceMemoryStore | null>
// ❌ AVOID: Implicit any types
async function loadCoreMemory(directory, sessionID) { }
async function loadWorkspaceMemory(workspaceKey) { }
```
### Type Definitions
```typescript
// ✅ REQUIRED: Define types at module top
type CoreMemory = {
sessionID: string;
blocks: {
goal: CoreBlock;
progress: CoreBlock;
context: CoreBlock;
};
export type LongTermMemoryEntry = {
id: string;
type: LongTermType;
text: string;
source: LongTermSource;
confidence: number;
status: "active" | "superseded";
createdAt: string;
updatedAt: string;
};
// ✅ USE: Union types for variants (not enums)
type PressureLevel = "safe" | "moderate" | "high" | "critical";
export type LongTermType = "feedback" | "project" | "decision" | "reference";
export type LongTermSource = "explicit" | "compaction" | "manual";
// ✅ USE: Record<> for keyed configs
const SLOT_CONFIG: Record<SlotType, number> = {
error: 3,
decision: 5,
todo: 3,
dependency: 3,
};
// ✅ USE: const assertions for limits
export const LONG_TERM_LIMITS = {
maxRenderedChars: 5200,
maxEntries: 28,
} as const;
```
### Imports & Module Organization
@@ -105,57 +127,52 @@ import { join } from "path";
// 2. Third-party (OpenCode SDK)
import type { Plugin } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";
// 3. Local modules (if any)
// (none currently)
// 3. Local modules
import { loadWorkspaceMemory } from "./storage.js";
```
### Naming Conventions
```typescript
// ✅ REQUIRED: camelCase for variables & functions
const maxItems = 50;
async function loadCoreMemory() { }
const maxEntries = 28;
async function loadWorkspaceMemory() { }
// ✅ REQUIRED: SCREAMING_SNAKE_CASE for constants
const CORE_MEMORY_LIMITS = { goal: 1000, progress: 2000, context: 1500 };
const SLOT_CONFIG = { error: 3, decision: 5, todo: 3, dependency: 3 };
const LONG_TERM_LIMITS = { maxRenderedChars: 5200, maxEntries: 28 };
const HOT_STATE_LIMITS = { maxRenderedChars: 1200 };
// ✅ REQUIRED: PascalCase for types
type CoreMemory = { ... };
type WorkingMemoryItem = { ... };
type WorkspaceMemoryStore = { ... };
type SessionState = { ... };
// ✅ REQUIRED: get*/set*/load*/save* naming for file operations
function getCoreMemoryPath(directory: string, sessionID: string): string { }
async function loadCoreMemory(directory: string, sessionID: string): Promise<CoreMemory | null> { }
async function saveCoreMemory(directory: string, memory: CoreMemory): Promise<void> { }
// ✅ REQUIRED: ensure*/validate* for pre-checks
async function ensureCoreMemoryDir(directory: string): Promise<void> { }
// ✅ REQUIRED: Prefix private/internal functions with _
function _compressPath(filePath: string): string { }
// ✅ REQUIRED: get*/load*/save* naming for file operations
function getWorkspaceMemoryPath(workspaceKey: string): string { }
async function loadWorkspaceMemory(workspaceKey: string): Promise<WorkspaceMemoryStore | null> { }
async function saveWorkspaceMemory(memory: WorkspaceMemoryStore): Promise<void> { }
```
### Function Signatures & Organization
```typescript
// ✅ REQUIRED: Parameters on separate lines if > 80 chars
async function loadWorkingMemory(
directory: string,
sessionID: string
): Promise<WorkingMemory | null> {
async function extractWorkspaceMemoryCandidates(
content: string
): Promise<WorkspaceMemoryCandidate[]> {
// ...
}
// ✅ REQUIRED: Explicit return types (no inference)
function getCompactionLogPath(directory: string, sessionID: string): string {
return join(directory, ".opencode", "memory-working", `${sessionID}_compaction.json`);
function renderWorkspaceMemory(
entries: LongTermMemoryEntry[],
maxChars: number
): string {
// ...
}
// ✅ REQUIRED: Async for file/network I/O
async function saveCoreMemory(directory: string, memory: CoreMemory): Promise<void> {
async function saveWorkspaceMemory(memory: WorkspaceMemoryStore): Promise<void> {
// ...
}
```
@@ -163,28 +180,23 @@ async function saveCoreMemory(directory: string, memory: CoreMemory): Promise<vo
### Error Handling
```typescript
// ✅ REQUIRED: Try-catch with descriptive console.error
async function loadCoreMemory(directory: string, sessionID: string): Promise<CoreMemory | null> {
const path = getCoreMemoryPath(directory, sessionID);
// ✅ REQUIRED: Try-catch with graceful degradation
async function loadWorkspaceMemory(workspaceKey: string): Promise<WorkspaceMemoryStore | null> {
const path = getWorkspaceMemoryPath(workspaceKey);
if (!existsSync(path)) return null;
try {
const content = await readFile(path, "utf-8");
return JSON.parse(content) as CoreMemory;
return JSON.parse(content) as WorkspaceMemoryStore;
} catch (error) {
console.error("Failed to load core memory:", error);
return null; // Graceful degradation
// Plugin should never block agent - return null and continue
return null;
}
}
// ✅ REQUIRED: Type guards for runtime safety
if (!existsSync(path)) {
return null;
}
// ✅ REQUIRED: Validate JSON before use
const data = JSON.parse(content);
const typedData = data as CoreMemory; // Explicit cast after validation
const typedData = data as WorkspaceMemoryStore; // Explicit cast after validation
```
### Comments & Documentation
@@ -192,149 +204,125 @@ const typedData = data as CoreMemory; // Explicit cast after validation
```typescript
// ✅ REQUIRED: Section headers for major sections
// ============================================================================
// Phase 1: Core Memory Foundation
// Workspace Memory: Long-term cross-session storage
// ============================================================================
// ✅ REQUIRED: Block comments for complex logic
// Migration: Convert old format (items array) to new format (slots + pool)
if (data.items && !data.slots) {
// ... migration logic
// Quality gate: Reject candidates that are git hashes, errors, or path-heavy
function shouldAcceptWorkspaceMemoryCandidate(candidate: string): boolean {
// ...
}
// ✅ USE: Inline comments sparingly
const gamma = 0.85; // Exponential decay rate (15% per event)
// ✅ AVOID: Over-commenting obvious code
const name = "test"; // Set name to test ❌ (obvious)
```
### Code Organization
```typescript
// ✅ REQUIRED: Organize plugin file by phase/feature
// 1. Header & module documentation
// 2. Imports
// 3. Types & schemas (grouped by phase)
// 4. Constants & configs
// 5. Helper functions (private first, public after)
// 6. Main plugin export
// 7. Hook implementations
export default {
// Plugin definition
} as Plugin;
```
### Working with OpenCode Plugin SDK
```typescript
// ✅ REQUIRED: Use proper hook signatures
import { tool, type Plugin } from "@opencode-ai/plugin";
export default {
id: "working-memory",
name: "Working Memory Plugin",
// ✅ Core hooks
hooks: {
"tool.execute.after": async (ctx) => {
// Tool just executed
},
"experimental.chat.system.transform": async (ctx) => {
// Transform system prompt before sending
},
"experimental.session.compacting": async (ctx) => {
// Session is being compacted (clearing old messages)
},
},
// ✅ Exposed tools
tools: [
tool({
id: "core_memory_update",
name: "Update Core Memory",
description: "Update goal/progress/context blocks",
// ... schema & execute
}),
],
} as Plugin;
// ✅ USE: Inline comments sparingly for non-obvious logic
const canonical = normalizeText(text); // Lowercase, strip punctuation, collapse whitespace
```
## Key Implementation Details
### Core Memory Files
- Location: `.opencode/memory-core/<sessionID>.json`
- Schema: `{ sessionID, blocks: { goal, progress, context }, updatedAt }`
- Limits: goal (1000 chars), progress (2000 chars), context (1500 chars)
### Plugin Entry Point
### Working Memory Files
- Location: `.opencode/memory-working/<sessionID>.json`
- Schema: `{ sessionID, slots, pool, eventCounter, updatedAt }`
- Slot limits: error (3), decision (5), todo (3), dependency (3)
- Pool decay: γ=0.85 per event
```typescript
// index.ts
import { MemoryV2Plugin } from "./src/plugin.ts";
### Pressure Monitoring
- Triggers at: 70% (safe→moderate), 85% (moderate→high), 95% (high→critical)
- Files: `.opencode/memory-working/<sessionID>_pressure.json`
- Intervention: Sends `promptAsync()` with complete visible prompt
export default {
id: "working-memory",
server: MemoryV2Plugin,
};
```
### Storage Governance (Layer 1 & 2)
- **Layer 1**: Session deletion cleanup - removes orphaned memory files
- **Layer 2**: Tool output cache sweep - maintains 300 most recent files, 7-day TTL
- Triggered at `eventCounter % 500 === 0` (automatic maintenance)
### Workspace Memory Files
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
- **Schema**: See `src/types.ts:WorkspaceMemoryStore`
- **Limits**: 5200 chars, 28 entries max
### Session State Files
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/sessions/{sessionID}.json`
- **Session ID**: Hash of session ID from OpenCode
- **Schema**: See `src/types.ts:SessionState`
### Memory Types
| Type | Purpose | Stale After |
|------|---------|-------------|
| `feedback` | User preferences | 90 days |
| `project` | Project info | 60 days |
| `decision` | Important decisions | 45 days |
| `reference` | Key references | 90 days |
### Quality Guards
1. **False Positive Error Prevention**: Commands with "error" in output but `exitCode === undefined` are not tracked as errors
2. **Negative Memory Filtering**: "don't remember" patterns are correctly interpreted
3. **Compaction Quality Gate**: Rejects git hashes, stack traces, path-heavy facts
## Plugin Hooks
### `experimental.chat.system.transform`
Injects workspace memory and hot session state into system prompt.
### `tool.execute.after`
- Tracks active files (read, grep, edit, write actions)
- Tracks open errors from failed commands
- Clears errors when commands succeed
- Ignores `exitCode === undefined`
### `experimental.session.compacting`
Extracts workspace memory candidates from conversation, applies quality gate and deduplication.
### `event`
- `session.compacted`: Promote session decisions to workspace memory
- `session.deleted`: Clean up session state files
## Debugging & Testing
### Manual Testing Steps
1. **Phase 1 (Core Memory)**: Check `.opencode/memory-core/` after `core_memory_update`
2. **Phase 2 (Smart Pruning)**: Verify tool outputs are filtered before context injection
3. **Phase 3 (Working Memory)**: Check `.opencode/memory-working/` for slot/pool items
4. **Phase 4 (Pressure Monitoring)**: Monitor pressure % in system prompts, verify interventions
5. **Phase 4.5 (Storage Governance)**: Run 500+ events, check sweep logs
1. **Workspace Memory**: Check `~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json` after compaction
2. **Session State**: Check `~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json` after tool usage
3. **Error Tracking**: Run failing commands, verify errors appear in session state
4. **Error Clearing**: Run successful commands, verify errors are cleared
### Common Issues
- **File not found**: Ensure `.opencode/` directory exists and is writable
- **File not found**: Ensure `~/.local/share/opencode-working-memory/` exists and is writable
- **Type errors**: Check all imports use `import type { ... }` for types
- **Lost memory**: Verify `.opencode/memory-*/` is in `.gitignore` (not committed)
- **Sweep not running**: Check `eventCounter` in `<sessionID>.json`, should trigger at multiples of 500
- **Memory not persisting**: Verify workspace key is consistent (same workspace = same key)
- **False positive errors**: Check `exitCode` handling in `plugin.ts`
## Performance Considerations
- **Memory budgets**: Core (5.5k chars total), Working (1.6k chars for system prompt)
- **Pruning**: Hyper-aggressive mode activates at ≥85% pressure
- **Compaction**: Preserves most recent 10 items when space-constrained
- **Decay**: Pool items scored by exponential decay (γ=0.85) + mention count
- **Storage sweep**: Limits cache to 300 files, removes files older than 7 days
## File Path References
When referencing code locations in documentation/comments, use:
```
path/to/file.ts:L123 or path/to/file.ts:Line 123
```
Example: `Function sendPressureInterventionMessage() @ index.ts:L1286`
- **Workspace memory budget**: 5200 chars injected into system prompt
- **Session state budget**: 1200 chars injected into system prompt
- **Total overhead**: ~1500-6000 chars per message (minimal)
- **Storage footprint**: ~2-5 KB per workspace for memory, ~1-3 KB per session
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Make changes following the code style guidelines above
4. Test manually in OpenCode session
5. Commit with descriptive message: `git commit -m "Add feature: ..."`
4. Run tests: `npm test && npm run typecheck`
5. Commit with descriptive message: `git commit -m "feat: add ..."`
6. Push to your fork: `git push origin feature/my-feature`
7. Open a pull request
## Architecture Documentation
See `docs/architecture.md` for detailed technical documentation including:
- Memory tier hierarchy
- Pruning algorithms
- Decay formulas
- Pressure monitoring logic
- Storage governance policies
- Three-layer memory architecture
- Memory extraction and quality gates
- Error fingerprinting
- Deduplication strategies
---
**Last Updated**: February 2026
**Plugin Status**: Production (Phases 1-4.5 complete)
**Last Updated**: April 2026
**Plugin Status**: Production (Memory V2 architecture)
+187 -168
View File
@@ -3,32 +3,28 @@
[![npm version](https://img.shields.io/npm/v/opencode-working-memory.svg)](https://www.npmjs.com/package/opencode-working-memory)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
**Advanced four-tier memory architecture that keeps your AI agent sharp, focused, and never forgets what matters.**
**Automatic memory system that keeps your AI agent context-aware across compactions.**
Stop losing context across compactions. Stop watching your agent repeat the same mistakes. This plugin gives your OpenCode agent a professional-grade memory system that scales with your project complexity.
Stop losing context when OpenCode compacts your conversation. This plugin automatically tracks what matters — decisions, active files, open errors — and preserves it across sessions.
## What You Get
- 🧠 **Core Memory** - Persistent goal/progress/context blocks that survive compaction
- 💡 **Working Memory** - Smart slot-based system (errors, decisions, todos, dependencies)
- 🎯 **Memory Pressure Monitoring** - Real-time token tracking with automatic interventions
- 🧹 **Auto Storage Governance** - Prevents unbounded disk growth (300 files/session, 7-day TTL)
- 📊 **Smart Pruning** - Pressure-aware tool output compression
- **Zero Configuration** - Works out of the box, highly customizable
**Three-layer memory, zero extra API calls:**
| 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 |
**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
## Installation
**Option A: Let an AI agent do it**
Paste this into any AI agent:
```
Install opencode-working-memory plugin by following:
https://raw.githubusercontent.com/sdwolf4103/opencode-working-memory/main/docs/installation.md
```
**Option B: Manual setup**
Add to your `~/.config/opencode/opencode.json`:
```json
@@ -37,167 +33,196 @@ Add to your `~/.config/opencode/opencode.json`:
}
```
Restart OpenCode. The plugin is downloaded and installed automatically.
### For LLM Agents
1. Edit `~/.config/opencode/opencode.json`
2. Add `"opencode-working-memory"` to the `plugin` array
3. Verify the config file was saved correctly
## Quick Start
The plugin works automatically once installed. No configuration needed.
**Try telling your agent:**
```
Use core_memory_update to set my current goal
```
```
Use core_memory_read to show me what you remember
```
```
Use working_memory_add to remember this file path
```
## Features
### 🧠 Core Memory
Persistent blocks that survive conversation resets:
- **goal** (1000 chars) - Current task/objective
- **progress** (2000 chars) - What's done, in-progress, next steps
- **context** (1500 chars) - Key file paths, conventions, patterns
### 💡 Working Memory
Auto-extracts and ranks important information:
- **Slots** (guaranteed visibility): errors, decisions, todos, dependencies
- **Pool** (ranked by relevance): file paths, recent activity
- Exponential decay keeps memory fresh
- FIFO limits prevent bloat
### 🎯 Memory Pressure Monitoring
Real-time token tracking from session database:
- Monitors context window usage (75% moderate → 90% high)
- Proactive intervention messages when pressure is high
- Pressure-aware smart pruning (adapts compression based on pressure)
### 🧹 Storage Governance
Prevents unbounded disk growth:
- Auto-cleanup on session deletion (all artifacts removed)
- Active cache management (max 300 files/session, 7-day TTL)
- Silent background operation
### 📊 Smart Pruning
Intelligent tool output compression:
- Per-tool strategies (keep-all, keep-ends, keep-last, discard)
- Pressure-aware limits (2k/5k/10k lines based on memory pressure)
- Preserves important context while reducing noise
## Documentation
- [Installation Guide](docs/installation.md) - Detailed setup instructions
- [Architecture Overview](docs/architecture.md) - How it works under the hood
- [Configuration](docs/configuration.md) - Customization options
- [Agent Developer Guide](AGENTS.md) - For plugin developers
## Tools Provided
The plugin exposes these tools to your OpenCode agent:
- `core_memory_update` - Update goal/progress/context blocks
- `core_memory_read` - Read current memory state
- `working_memory_add` - Manually add important items
- `working_memory_clear` - Clear all working memory
- `working_memory_clear_slot` - Clear specific slot (errors/decisions)
- `working_memory_remove` - Remove specific item by content
Restart OpenCode. The plugin activates automatically — no manual setup needed.
## How It Works
**Three layers, zero API calls, automatic persistence:**
```
┌───────────────────────────────────────────────────────────┐
Core Memory (Always Visible)
│ ┌─────────────────────────────┐
│ │ Goal │ Progress │ Context │
└─────────┴──────────┴──────────┘
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
│ Working Memory (Auto-Extracted)
────────────────────────────────────
│ │ Slots (FIFO) │ Pool (Ranked) │ │
│ │ • errors │ • file-paths │ │
│ │ • decisions │ • recent │
│ │ • todos │ • mentions │ │
│ • dependencies • decay score
────────────────────────────────────
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
Memory Pressure Monitor
• Tracks tokens from session DB
• Warns at 75% (moderate) / 90% (high)
│ • Sends proactive interventions │
│ • Adjusts pruning aggressiveness │
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
Storage Governance
• Session deletion → cleanup all artifacts
• Every 20 calls → sweep old cache (300 max, 7d TTL)
• Silent background operation
───────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────
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!
```
## Why This Plugin?
### The Compaction Flow (No Extra API Call)
**Without this plugin:**
- 🔴 Agent forgets context after compaction
- 🔴 Repeats resolved errors
- 🔴 Loses track of project structure
- 🔴 Context window fills up uncontrollably
- 🔴 Disk space grows unbounded
```
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) │
└─────────────────────────────────┘
```
**With this plugin:**
- ✅ Persistent memory across compactions
- ✅ Smart auto-extraction of important info
- ✅ Real-time pressure monitoring with interventions
- ✅ Automatic storage cleanup
- ✅ Pressure-aware compression
- ✅ Zero configuration, works immediately
### Workspace Memory (Long-term)
## Does working-memory system increase token usage? It depends.
Persists across sessions within the same workspace. Automatically extracted during compaction when the agent marks something with "remember" or "note":
It depends on your workflow.
```
<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>
```
- 🧹 **Clean Slate user** (for example, using DCP and frequently restarting sessions)
- ⚠️ Yes, it might add slight overhead.
- Because you keep starting fresh, automated memory persistence does not get enough time to pay off.
**Memory types:**
- `feedback` - User preferences for this workspace
- `project` - Project-level information
- `decision` - Important decisions made
- `reference` - Key references (paths, patterns)
- 🚀 **Long Haul user** (staying in one session until token limits/compaction hit)
- ✅ This plugin is a token saver.
- Without it, compaction can cause the agent to lose the goal, forget active files, or make wrong assumptions, which creates correction loops.
- By preserving high-value context (Goals, Progress, Active Files), the agent inherits its previous state quickly. The small memory-prompt cost avoids the larger cost of the agent getting lost.
**Sources:**
- `explicit` - User explicitly said "remember this" (confidence: 1.0)
- `compaction` - Extracted during compaction (confidence: 0.75)
- `manual` - Added programmatically (confidence: varies)
## Configuration (Optional)
### Hot Session State (Short-term)
The plugin works great with zero configuration. To customize behavior, modify the constants at the top of `index.ts`. See the [Configuration Guide](docs/configuration.md) for all tunable options.
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>
```
## Quality Guarantees
The plugin includes several quality guards:
- **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
## No Tools Required
Unlike other memory plugins, **this plugin has no manual tools**. Everything is automatic:
- 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
Just install and let it run. The plugin hooks into OpenCode's lifecycle events and does the right thing.
## Configuration
The plugin works out of the box with sensible defaults:
- **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}/`
See [Configuration Guide](docs/configuration.md) for customization options.
## For AI Agents
When using this plugin, the memory context appears in your system prompt. You can:
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
To add something to long-term memory explicitly:
```
Remember this: [your note here]
```
The plugin captures this during compaction.
## 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
## Development
```bash
git clone https://github.com/sdwolf4103/opencode-working-memory.git
cd opencode-working-memory
npm install
npm test
npm run typecheck
```
## Requirements
- OpenCode >= 1.0.0
- Node.js >= 18.0.0
- `@opencode-ai/plugin` >= 1.2.0
## License
@@ -208,12 +233,6 @@ MIT License - see [LICENSE](LICENSE) file for details.
- 📖 [Documentation](docs/)
- 🐛 [Report Issues](https://github.com/sdwolf4103/opencode-working-memory/issues)
## Credits
Inspired by the needs of real-world OpenCode usage and built to solve actual pain points in AI-assisted development.
> This project is not affiliated with or endorsed by the OpenCode team.
---
**Made with ❤️ for the OpenCode community**
**Made with ❤️ for the OpenCode community**
+111
View File
@@ -0,0 +1,111 @@
# Release Notes
## 1.2.0 (2026-04-26)
### Memory V2 Architecture
This release introduces a complete architectural redesign from the previous four-tier memory system to a streamlined three-layer architecture:
```
┌──────────────────────────────────────────────────────────────────┐
│ LAYER 1: WORKSPACE MEMORY │
│ Persists across sessions in same workspace (survives restart) │
│ Extracted during compaction - NO EXTRA API CALL │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ LAYER 2: HOT SESSION STATE │
│ Per-session, auto-tracked, resets on new session │
│ Tracks: Active files, Open errors, Recent decisions │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ LAYER 3: NATIVE OPENCODE STATE │
│ Uses OpenCode's built-in todos - No plugin storage needed │
└──────────────────────────────────────────────────────────────────┘
```
### Key Features
- **Cross-session memory**: Workspace memory persists between sessions in the same workspace
- **No extra API calls**: Memory extraction piggybacks on OpenCode's existing compaction summary
- **Zero configuration**: Works out of the box with sensible defaults
- **Zero tools**: No manual memory management needed - fully automatic
### Added
- **Workspace Memory**: Long-term memory that survives restarts and compactions
- Types: `feedback`, `project`, `decision`, `reference`
- Sources: `explicit` (user), `compaction`, `manual`
- Limits: 5200 chars / 28 entries
- **Hot Session State**: Automatic tracking for current session
- Active files with action-based ranking
- Open errors with fingerprinting
- Recent decisions for compaction promotion
- **Quality Gates**:
- Canonical deduplication of workspace memories
- Negative memory request filtering ("don't remember")
- Compaction quality gate (rejects git hashes, stack traces, path-heavy facts)
### Fixed
- **False positive error tracking**: `exitCode === undefined` no longer creates spurious errors from commands like `git log` or `cat`
- **XML truncation**: Workspace memory rendering never truncates closing `</workspace_memory>` tag
- **Negative memory filtering**: Correctly interprets "don't remember this" and "不要記住"
- **"always" trigger removed**: No longer treats "always" as a memory trigger keyword
### Changed
- **Storage location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/`
- **No manual tools**: Removed `core_memory_update`, `core_memory_read` - memory is fully automatic
- **Hook-based extraction**: Memory is extracted during `experimental.session.compacting` hook
### Breaking Changes
- Previous four-tier architecture is replaced with three-layer architecture
- Core Memory blocks (goal/progress/context) removed in favor of typed entries
- Working Memory slots and pool replaced with Hot Session State
- Pressure monitoring and smart pruning removed (not needed with new architecture)
### Migration
- Old memory files (`.opencode/memory-core/`, `.opencode/memory-working/`) are not migrated
- New storage location is used (`~/.local/share/opencode-working-memory/`)
- No action required - plugin starts fresh with new architecture
### Technical Details
- **Plugin entry**: `index.ts` exports `{ id: "working-memory", server: MemoryV2Plugin }`
- **Hooks implemented**:
- `experimental.chat.system.transform` - Inject workspace memory and hot session state
- `tool.execute.after` - Track active files and open errors
- `experimental.session.compacting` - Extract workspace memory candidates
- `event` - Handle session lifecycle events
### Files in Package
```
index.ts
src/extractors.ts
src/opencode.ts
src/paths.ts
src/plugin.ts
src/session-state.ts
src/storage.ts
src/types.ts
src/workspace-memory.ts
README.md
LICENSE
```
---
## 1.1.2 (Previous Version)
- Four-tier memory architecture
- Core Memory blocks (goal/progress/context)
- Working Memory with slots and pool
- Pressure monitoring with interventions
- Smart pruning of tool outputs
+254 -278
View File
@@ -2,374 +2,350 @@
## Overview
The Working Memory Plugin implements a **four-tier memory architecture** designed to maximize context efficiency for AI agents in OpenCode sessions.
The Working Memory Plugin implements a **three-layer memory architecture** designed to preserve context across OpenCode session compactions.
```
┌─────────────────────────────────────────────────────────────┐
TIER 1: CORE MEMORY
│ Persistent blocks: goal (1000) | progress (2000) | context (1500)
Survives compaction, always visible in system prompt
LAYER 1: WORKSPACE MEMORY (Long-term, cross-session)
Persistent storage: ~/.local/share/opencode-working-...
• Types: feedback | project | decision | reference
│ • Sources: explicit | compaction | manual │
│ • Limits: 5200 chars / 28 entries │
│ • Survives: session reset, compaction (same workspace) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
TIER 2: WORKING MEMORY
│ Session-scoped slots + memory pool
Slots: error(3) | decision(5) | todo(3) | dependency(3)
Pool: Exponential decay (γ=0.85) + mention tracking
LAYER 2: HOT SESSION STATE (Short-term, per-session)
Session-scoped tracking: active files, open errors
• Storage: sessions/{sessionID}.json
• Auto-extracted from tool usage patterns
│ • Cleared: on new session start │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
TIER 3: SMART PRUNING
Filters tool outputs before adding to conversation │
Removes: file lists, verbose logs, repetitive content
Modes: normal → aggressive → hyper-aggressive
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ TIER 4: PRESSURE MONITORING │
│ Tracks context usage: safe → moderate → high │
│ Thresholds: 75% (moderate) | 90% (high) │
│ Intervention: Sends promptAsync() with full visible prompt │
LAYER 3: NATIVE OPENCODE STATE
• Uses OpenCode's built-in todos during compaction
• No additional storage required
• Delegated to OpenCode's native features
└─────────────────────────────────────────────────────────────┘
```
## Phase 1: Core Memory Foundation
## Layer 1: Workspace Memory
### Purpose
Provide persistent memory blocks that survive conversation compaction and are always injected into the system prompt.
Long-term memory that persists across sessions within the same workspace. Perfect for:
- Project conventions and patterns
- Important decisions that span sessions
- User preferences for this codebase
### Storage
- **Location**: `.opencode/memory-core/<sessionID>.json`
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
- **Schema**:
```typescript
{
sessionID: string;
blocks: {
goal: { content: string; chars: number; maxChars: 1000; updatedAt: string };
progress: { content: string; chars: number; maxChars: 2000; updatedAt: string };
context: { content: string; chars: number; maxChars: 1500; updatedAt: string };
};
updatedAt: string;
version: 1,
workspace: { root: string, key: string },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
entries: LongTermMemoryEntry[],
updatedAt: string
}
```
### Character Limits
- **goal**: 1000 chars (ONE specific task)
- **progress**: 2000 chars (done/in-progress/blocked checklist)
- **context**: 1500 chars (current working files + key patterns)
### Entry Types
### Operations
- **replace**: Completely replace block content
- **append**: Add content to end (auto-adds newline)
| Type | Purpose | Example |
|------|---------|---------|
| `feedback` | User preferences | "User prefers functional React components" |
| `project` | Project-level info | "This monorepo uses turborepo" |
| `decision` | Important decisions | "Use PostgreSQL for primary database" |
| `reference` | Key references | "API endpoints defined in `src/api/`" |
### Tools
- `core_memory_update`: Update or append to blocks
- `core_memory_read`: Read current state of all blocks
### Entry Sources
| Source | Confidence | How Added |
|--------|------------|-----------|
| `explicit` | 1.0 | User said "remember this" |
| `compaction` | 0.75 | Extracted during compaction |
| `manual` | varies | Programmatically added |
### Memory Extraction
During compaction, the plugin scans for `<workspace_memory_candidates>` blocks:
```
<workspace_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:
- 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
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
### System Prompt Injection
Blocks are injected into every agent message as:
Workspace memory is injected at the top of every message:
```
<core_memory>
<goal chars="87/1000">...</goal>
<progress chars="560/2000">...</progress>
<context chars="479/1500">...</context>
</core_memory>
<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>
```
## Phase 2: Smart Pruning
## Layer 2: Hot Session State
### Purpose
Reduce context bloat by filtering tool outputs before they enter the conversation history.
### Pruning Modes
#### Normal Mode (Pressure < 75%)
- Remove file/directory listings > 50 lines
- Truncate verbose tool outputs
- Keep first/last 30 lines of long outputs
- Preserve error messages and key information
#### Aggressive Mode (75% ≤ Pressure < 90%)
- Threshold drops to 30 lines
- More aggressive truncation (first/last 20 lines)
- Filter repetitive content
#### Hyper-Aggressive Mode (Pressure ≥ 90%)
- Threshold drops to 15 lines
- Keep only first/last 10 lines
- Maximum compression
### Pruning Heuristics
1. **File Listings**: Detect `ls`, `find`, `glob` outputs
2. **Directory Trees**: Detect tree-like structures with `/`
3. **Log Files**: Detect timestamp patterns, stack traces
4. **Repetitive Content**: Detect similar consecutive lines
5. **Synthetic Content**: Preserve `synthetic: true` markers
### Implementation
Pruning happens in `tool.execute.after` hook before tool output enters conversation.
## Phase 3: Working Memory
### Purpose
Provide session-scoped memory with structured slots and a general-purpose pool with intelligent decay.
Track current session context automatically:
- What files are you working on?
- What errors are currently open?
- What decisions were made recently?
### Storage
- **Location**: `.opencode/memory-working/<sessionID>.json`
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/sessions/{hashedSessionID}.json`
- **Schema**:
```typescript
{
sessionID: string;
slots: {
error: Array<WorkingMemoryItem>; // Max 3
decision: Array<WorkingMemoryItem>; // Max 5
todo: Array<WorkingMemoryItem>; // Max 3
dependency: Array<WorkingMemoryItem>; // Max 3
};
pool: Array<WorkingMemoryItem>;
eventCounter: number;
updatedAt: string;
version: 1,
sessionID: string,
turn: number,
updatedAt: string,
activeFiles: ActiveFile[],
openErrors: OpenError[],
recentDecisions: SessionDecision[]
}
```
### Slot Types
### Active Files
| Slot | Max Items | Purpose |
|------|-----------|---------|
| **error** | 3 | Recent errors that need fixing |
| **decision** | 5 | Important decisions made |
| **todo** | 3 | Current task checklist |
| **dependency** | 3 | File/package dependencies |
Automatically tracked from `tool.execute.after` events:
### Memory Pool
| Action | Weight |
|--------|--------|
| `edit` | 50 |
| `write` | 45 |
| `grep` | 30 |
| `read` | 20 |
General-purpose storage with **exponential decay**:
Files are ranked by: `ACTION_WEIGHT[action] + count * 3`
```typescript
score = exp(-γ * age) + mentionCount
```
### Open Errors
Where:
- `γ = 0.85` (decay rate, 15% per event)
- `age = eventCounter - item.eventNumber`
- `mentionCount`: Number of times item mentioned in conversation
Tracked from `tool.execute.after` events when `exitCode !== 0`:
Items with `score < 0.01` are pruned.
| Category | Trigger Pattern |
|----------|-----------------|
| `typecheck` | `TS####:` or TypeScript errors |
| `test` | Test failures |
| `lint` | ESLint warnings/errors |
| `build` | Build failures |
| `runtime` | `Error:`, `TypeError:`, etc. |
### Auto-Extraction
**False Positive Guards**:
- Commands like `git log`, `cat` with "error" in output are ignored
- Only actual command failures (`exitCode !== 0`) trigger errors
- `exitCode === undefined` is ignored (no error created, no error cleared)
Working memory items are **automatically extracted** from:
- Tool outputs (file paths, errors, dependencies)
- User messages (decisions, todos)
- Assistant responses (key information)
### Error Fingerprinting
### Manual Management
Errors are fingerprinted by:
1. Extract error message summary
2. Generate fingerprint: `first 12 chars of sha256(summary)`
3. Group similar errors by fingerprint
Tools:
- `working_memory_add`: Manually add item
- `working_memory_clear`: Clear all items
- `working_memory_clear_slot`: Clear specific slot (e.g., after fixing all errors)
- `working_memory_remove`: Remove specific item by content match
### recentDecisions
Short-term decisions made this session. Candidates for promotion to workspace memory during compaction.
### System Prompt Injection
Hot session state is injected after workspace memory:
```
<working_memory>
Recent session context (auto-managed, sorted by relevance):
---
<workspace_memory_candidates>
- [project] This repo uses TypeScript with strict mode
</workspace_memory_candidates>
⚠️ Errors:
- TypeError at line 42 in utils.ts
- Missing import in index.ts
Active Files:
- src/plugin.ts (edit, 18x)
- tests/plugin.test.ts (edit, 5x)
📁 Key Files:
- src/components/Button.tsx
- src/utils/helpers.ts
(15 items shown, updated: 9:46:47 AM)
</working_memory>
Open Errors: (none)
```
## Phase 4: Pressure Monitoring
## Layer 3: Native OpenCode State
### Purpose
Track conversation context usage and trigger interventions when approaching limits.
### Pressure Calculation
Delegate task tracking to OpenCode's native features.
### Behavior
- Uses OpenCode's built-in `todos` during compaction
- No additional storage or injection required
- Allows the agent to manage task lists natively
## Plugin Hooks
The plugin hooks into OpenCode lifecycle events:
### `experimental.chat.system.transform`
Injects workspace memory and hot session state into system prompt.
### `tool.execute.after`
- Tracks active files (read, grep, edit, write actions)
- Tracks open errors from failed commands
- Clears errors when commands succeed
- Ignores `exitCode === undefined` (successful commands without explicit exit codes)
### `experimental.session.compacting`
Extracts workspace memory candidates from conversation.
Applies quality gate, deduplication, and source priority.
### `event` (session.compacted, session.deleted)
- `session.compacted`: Promote session decisions to workspace memory
- `session.deleted`: Clean up session state files
## Quality Guarantees
### No False Positive Errors
```typescript
pressure = (visiblePromptChars / estimatedContextLimit) * 100
// Bad: Would create false positive
"Error: something failed" in output
// Good: Actually failed
exitCode === 1 && output.includes("Error")
// Good: Actually succeeded
exitCode === 0 (clears errors for that category)
// Good: Ignore ambiguous cases
exitCode === undefined → skip error tracking
```
Where:
- `visiblePromptChars`: Total characters in system prompt + tool outputs
- `estimatedContextLimit`: ~180,000 chars (conservative estimate)
### Pressure Levels
| Level | Threshold | Behavior |
|-------|-----------|----------|
| **safe** | < 75% | Normal operation |
| **moderate** | 75-89% | Warning in system prompt + aggressive pruning |
| **high** | ≥ 90% | Hyper-aggressive pruning + intervention |
### Pressure Storage
- **Location**: `.opencode/memory-working/<sessionID>_pressure.json`
- **Schema**:
```typescript
{
sessionID: string;
level: "safe" | "moderate" | "high";
percentage: number;
visiblePromptChars: number;
estimatedLimit: 180000;
lastChecked: string;
interventionsSent: number;
}
```
### Intervention Mechanism
When pressure reaches **high** (≥90%):
1. Plugin sends `promptAsync()` message to agent
2. Message includes full visible prompt for review
3. Agent can compress core memory, clear working memory, or continue
4. Intervention tracked in `interventionsSent` counter
### System Prompt Injection
```
[Memory Pressure: 87% (high) - 156,600/180,000 chars]
⚠️ High memory pressure detected. Consider:
- Compressing core_memory blocks (use core_memory_update)
- Clearing resolved errors (use working_memory_clear_slot)
- Removing old pool items (auto-pruned at score < 0.01)
```
## Phase 4.5: Storage Governance
### Purpose
Prevent `.opencode/` directory bloat from accumulating tool output caches and orphaned memory files.
### Layer 1: Session Deletion Cleanup
**Trigger**: `experimental.session.deleted` hook
**Actions**:
1. Remove `.opencode/memory-core/<sessionID>.json`
2. Remove `.opencode/memory-working/<sessionID>.json`
3. Remove `.opencode/memory-working/<sessionID>_pressure.json`
4. Remove `.opencode/memory-working/<sessionID>_compaction.json`
### Layer 2: Tool Output Cache Sweep
**Trigger**: Every 500 events (`eventCounter % 500 === 0`)
**Target**: `.opencode/cache/tool-outputs/` directory
**Policy**:
- Keep most recent **300 files** (sorted by mtime)
- Delete files older than **7 days** (TTL policy)
**Logging**: Write sweep results to `.opencode/memory-working/<sessionID>_sweep.json`
### Negative Memory Filtering
```typescript
{
sessionID: string;
timestamp: string;
eventCounter: number;
results: {
filesScanned: number;
filesDeleted: number;
bytesReclaimed: number;
errors: Array<string>;
};
}
// Correctly interpreted
"don't remember this" → NOT added to memory
"不要記住這個" → NOT added to memory
"remember this" → added to memory candidates
```
### Canonical Deduplication
```typescript
// Same memory (after normalization)
"Use npm cache for plugins"
"USE NPM CACHE for plugins!!"
"use npm cache for plugins."
// All map to same canonical key
canonical("Use npm cache for plugins") === "use npm cache for plugins"
```
### Compaction Quality Gate
```typescript
// Rejected (not valuable as long-term memory)
"4832b38 fix: something" // git hash
"Error: something failed" // raw error
"at Object.method (file.ts:42)" // stack trace
"/Users/x/project/file.ts /Users/x/project/other.ts" // path-heavy
// Accepted
"[decision] Use npm cache for plugin loading" // good pattern
```
## File System Layout
```
~/.local/share/opencode-working-memory/
└── workspaces/
└── {workspaceKey}/
├── workspace-memory.json # Long-term memory
└── sessions/
└── {hashedSessionID}.json # Session state
```
### Workspace Key
```typescript
// First 16 chars of SHA-256 hash of workspace root realpath
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
```
## Performance Considerations
### Memory Budgets
- **Core Memory**: 4,500 chars (injected every message)
- **Working Memory**: ~1,600 chars (injected every message)
- **Total Overhead**: ~6,100 chars per message
### Compaction Behavior
When OpenCode compacts conversation (clears old messages):
- Core memory: **Preserved** (persistent across compactions)
- Working memory: **Preserved** (session-scoped, cleared on session end)
- Pressure state: **Preserved** (tracks across compaction)
- Compaction log: Saved to `<sessionID>_compaction.json`
| Layer | Max Chars | Max Entries |
|-------|-----------|-------------|
| Workspace Memory | 5200 | 28 |
| Hot Session State | 1200 | 8 files, 3 errors |
### Injection Overhead
- Workspace memory: ~200-500 chars per message
- Hot session state: ~200-400 chars per message
- Total: ~400-900 chars per message (minimal)
### Storage Footprint
- Each session: 4 JSON files (~5-20 KB total)
- Tool output cache: Max 300 files (~10-50 MB depending on outputs)
- Sweep every 500 events keeps storage bounded
- Workspace memory: ~2-5 KB per workspace
- Session state: ~1-3 KB per session
- Auto-cleanup on workspace/session deletion
## Extension Points
### Custom Slot Types
To add new slot types:
1. Update `SlotType` union in types
2. Add to `SLOT_CONFIG` with max items
3. Update `formatWorkingMemoryForPrompt()` for display
4. Update extraction heuristics in `tool.execute.after`
### Custom Memory Types
### Custom Pruning Rules
To add pruning heuristics:
1. Update `shouldPrune()` with new detection logic
2. Add to `pruneToolOutput()` with filtering rules
3. Test with representative tool outputs
### Custom Pressure Thresholds
Adjust in constants:
Add new types in `src/types.ts`:
```typescript
const PRESSURE_THRESHOLDS = {
moderate: 70,
high: 85,
critical: 95,
};
export type LongTermType = "feedback" | "project" | "decision" | "reference" | "custom";
```
## Migration & Compatibility
### Custom Error Categories
### Old Format → New Format
Plugin automatically migrates from old format:
Add new categories in `src/types.ts`:
```typescript
// Old format (pre-Phase 3)
{ items: Array<Item> }
// New format (Phase 3+)
{ slots: Record<SlotType, Array<Item>>, pool: Array<Item> }
export type ErrorCategory = "typecheck" | "test" | "lint" | "build" | "runtime" | "custom";
```
Migration happens on first load of old format files.
### Custom Extraction Patterns
## File System Layout
Modify `src/extractors.ts` to add new extraction patterns.
```
.opencode/
├── memory-core/
│ └── <sessionID>.json # Core memory blocks
├── memory-working/
│ ├── <sessionID>.json # Working memory (slots + pool)
│ ├── <sessionID>_pressure.json # Pressure monitoring state
│ ├── <sessionID>_compaction.json # Compaction event log
│ └── <sessionID>_sweep.json # Storage sweep log
└── cache/
└── tool-outputs/
└── *.json # Tool output cache (auto-swept)
```
## Migration Notes
## Security Considerations
### Memory V1 to V2
- All files written with `0644` permissions (owner read/write, group/others read)
- Directories created with `0755` permissions (owner rwx, group/others rx)
- No sensitive data should be stored in memory blocks (user responsibility)
- Session IDs are opaque identifiers, not derived from sensitive data
The plugin automatically migrates old format files to the new three-layer architecture. No manual intervention needed.
---
**Last Updated**: February 2026
**Implementation**: `index.ts` (1700+ lines)
**Last Updated**: April 2026
**Implementation**: `src/plugin.ts`, `src/extractors.ts`, `src/workspace-memory.ts`, `src/session-state.ts`
+140 -303
View File
@@ -2,250 +2,127 @@
## Overview
The Working Memory Plugin works out-of-the-box with sensible defaults. Advanced users can customize behavior by modifying constants in `index.ts`.
The Working Memory Plugin works out-of-the-box with sensible defaults. Configuration is defined in `src/types.ts` as constants.
## Core Memory Limits
## Workspace Memory Limits
```typescript
const CORE_MEMORY_LIMITS = {
goal: 1000, // ONE specific task (not project-wide goals)
progress: 2000, // Checklist format (✅ done, ⏳ in-progress, ❌ blocked)
context: 1500, // Current working files + key patterns
const LONG_TERM_LIMITS = {
maxRenderedChars: 5200, // Maximum characters in system prompt
targetRenderedChars: 4200, // Target characters (leave buffer)
maxEntries: 28, // Maximum number of entries
maxEntryTextChars: 260, // Maximum characters per entry text
maxRationaleChars: 180, // Maximum characters per entry rationale
};
```
**Recommendations**:
- Keep **goal** focused on current task (clear when completed)
- Use **progress** for checklists (avoid line numbers, commit hashes, API signatures)
- Use **context** for files you're actively editing (avoid type definitions, function signatures)
- Keep `maxRenderedChars` under 5500 to avoid context bloat
- `maxEntries` of 28 provides good coverage without overwhelming
- Entry text limits ensure entries stay concise
## Working Memory Configuration
### Slot Limits
## Hot Session State Limits
```typescript
const SLOT_CONFIG: Record<SlotType, number> = {
error: 3, // Recent errors needing fixes
decision: 5, // Important decisions made
todo: 3, // Current task checklist
dependency: 3, // File/package dependencies
const HOT_STATE_LIMITS = {
maxRenderedChars: 1200, // Maximum characters in system prompt
maxActiveFilesStored: 20, // Maximum files tracked in state
maxActiveFilesRendered: 8, // Maximum files shown in prompt
maxOpenErrorsStored: 5, // Maximum errors tracked
maxOpenErrorsRendered: 3, // Maximum errors shown in prompt
maxRecentDecisionsStored: 8, // Maximum decisions tracked
};
```
**Tuning**:
- Increase slot limits if you need more items tracked
- Decrease for stricter memory budgets
- Total overhead: ~100-200 chars per item
**Recommendations**:
- Keep `maxRenderedChars` under 1500 for fast prompts
- `maxActiveFilesRendered` of 8 provides good context coverage
- `maxOpenErrorsRendered` of 3 avoids overwhelming error lists
### Memory Pool Decay
## Memory Types
```typescript
const POOL_DECAY_GAMMA = 0.85; // Exponential decay rate (15% per event)
const POOL_MIN_SCORE = 0.01; // Items below this score are pruned
### Long-Term Memory Types
| Type | Purpose | Stale After (days) |
|------|---------|---------------------|
| `feedback` | User preferences for workspace | 90 |
| `project` | Project-level information | 60 |
| `decision` | Important decisions | 45 |
| `reference` | Key references | 90 |
### Memory Sources
| Source | Confidence | Description |
|--------|------------|-------------|
| `explicit` | 1.0 | User explicitly said "remember this" |
| `compaction` | 0.75 | Extracted during conversation compaction |
| `manual` | varies | Added programmatically |
## Active File Scoring
Files are ranked by action type:
| Action | Weight | Description |
|--------|--------|-------------|
| `write` | 4 | File created/overwritten |
| `edit` | 3 | File modified |
| `read` | 2 | File read |
| `grep` | 1 | Grep searched in file |
Score formula: `count * action_weight * recency_decay`
## Error Categories
| Category | Recognition Pattern |
|----------|---------------------|
| `typecheck` | TS errors, TypeScript failures |
| `test` | Test failures |
| `lint` | ESLint warnings/errors |
| `build` | Build failures |
| `runtime` | Uncaught errors, Node exceptions |
| `tool` | Tool execution failures |
## Storage Paths
```
~/.local/share/opencode-working-memory/
└── workspaces/
└── {workspaceKey}/
├── workspace-memory.json # Long-term memory
└── sessions/
└── {hashedSessionID}.json # Session state (hashed)
```
**Formula**: `score = exp(-γ * age) + mentionCount`
**Tuning**:
- Lower `γ` (e.g., 0.75) → faster decay, more aggressive pruning
- Higher `γ` (e.g., 0.90) → slower decay, items stay longer
- Lower `POOL_MIN_SCORE` (e.g., 0.005) → more items retained
### Pool Size Limits
### Workspace Key
```typescript
const POOL_MAX_ITEMS = 50; // Hard limit on pool size
// First 16 characters of SHA-256 hash
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16);
```
**Tuning**:
- Increase for longer sessions with more context
- Decrease for stricter memory budgets
- Each item adds ~50-150 chars to system prompt
## Pressure Monitoring
### Thresholds
### Session ID
```typescript
const PRESSURE_THRESHOLDS = {
moderate: 75, // Warning appears in system prompt
high: 90, // Aggressive pruning activates + intervention sent
// Hashed session ID for privacy
const hashedSessionID = sha256(sessionID).slice(0, 32);
```
## Customization
To customize limits, edit the constants in `src/types.ts`:
```typescript
// Example: Increase workspace memory limit
export const LONG_TERM_LIMITS = {
maxRenderedChars: 6000, // Increased from 5200
maxEntries: 35, // Increased from 28
// ...
};
```
**Tuning**:
- Increase thresholds for more relaxed monitoring
- Decrease for earlier warnings and interventions
### Context Limit Estimate
```typescript
const ESTIMATED_CONTEXT_LIMIT = 180000; // Conservative estimate (chars)
```
**Note**: OpenCode actual limit varies by model. Adjust based on your observations.
## Smart Pruning
### Line Thresholds
```typescript
// Normal mode (pressure < 75%)
const PRUNE_THRESHOLD_NORMAL = 50;
// Aggressive mode (75% ≤ pressure < 90%)
const PRUNE_THRESHOLD_AGGRESSIVE = 30;
// Hyper-aggressive mode (pressure ≥ 90%)
const PRUNE_THRESHOLD_HYPER = 15;
```
**Tuning**:
- Increase thresholds to keep more tool output
- Decrease for more aggressive pruning
### Keep Lines
```typescript
// Normal mode
const KEEP_LINES_NORMAL = 30; // Keep first/last 30 lines
// Aggressive mode
const KEEP_LINES_AGGRESSIVE = 20; // Keep first/last 20 lines
// Hyper-aggressive mode
const KEEP_LINES_HYPER = 10; // Keep first/last 10 lines
```
**Tuning**:
- Increase to preserve more context from tool outputs
- Decrease for stricter truncation
## Storage Governance
### Session Cleanup
Automatically triggered on `experimental.session.deleted` hook. No configuration needed.
### Tool Output Cache Sweep
```typescript
const SWEEP_INTERVAL = 500; // Trigger every N events
const SWEEP_MAX_FILES = 300; // Keep most recent N files
const SWEEP_TTL_DAYS = 7; // Delete files older than N days
```
**Tuning**:
- Increase `SWEEP_INTERVAL` for less frequent sweeps (lower overhead)
- Increase `SWEEP_MAX_FILES` to cache more tool outputs (more disk usage)
- Increase `SWEEP_TTL_DAYS` to keep older files longer
## Compaction Behavior
### Item Preservation
```typescript
const COMPACTION_KEEP_ITEMS = 10; // Preserve N most recent items on compaction
```
**Tuning**:
- Increase to preserve more working memory across compactions
- Decrease for stricter memory reset on compaction
## System Prompt Injection
### Core Memory Format
```typescript
// Injected as:
<core_memory>
<goal chars="87/1000">...</goal>
<progress chars="560/2000">...</progress>
<context chars="479/1500">...</context>
</core_memory>
```
**Customization**: Modify `formatCoreMemoryForPrompt()` in `index.ts` to change format.
### Working Memory Format
```typescript
// Injected as:
<working_memory>
Recent session context (auto-managed, sorted by relevance):
Errors:
- item content
📁 Key Files:
- file path
(N items shown, updated: HH:MM:SS AM)
</working_memory>
```
**Customization**: Modify `formatWorkingMemoryForPrompt()` in `index.ts` to change:
- Section emoji/icons
- Display format
- Item ordering
### Pressure Warning Format
```typescript
// Injected as:
[Memory Pressure: 87% (high) - 156,600/180,000 chars]
High memory pressure detected. Consider:
- Action item 1
- Action item 2
```
**Customization**: Modify `formatPressureWarning()` in `index.ts`.
## Auto-Extraction Heuristics
### File Path Detection
```typescript
// Detects:
- Absolute paths: /users/name/project/file.ts
- Relative paths: src/components/Button.tsx
- Dot paths: ./utils/helpers.ts
- Tilde paths: ~/project/file.ts
```
**Customization**: Modify regex in `extractFilePaths()`.
### Error Detection
```typescript
// Detects:
- "Error:", "ERROR:", "error:"
- Stack traces with "at " prefix
- TypeScript errors with "TS####:"
```
**Customization**: Modify `extractErrors()` heuristics.
### Decision Detection
```typescript
// Detects:
- "decided to...", "decision:", "chose to..."
- "using X instead of Y"
- "will use X approach"
```
**Customization**: Modify `extractDecisions()` heuristics.
## Environment Variables
Currently, the plugin does not support environment variables. All configuration is done via constants in `index.ts`.
**Future Enhancement**: Consider adding `.env` support for:
```
OPENCODE_WM_CORE_GOAL_LIMIT=1000
OPENCODE_WM_POOL_DECAY_GAMMA=0.85
OPENCODE_WM_SWEEP_INTERVAL=500
**Note**: After customization, rebuild the plugin:
```bash
npm run build
```
## Performance Tuning
@@ -253,123 +130,83 @@ OPENCODE_WM_SWEEP_INTERVAL=500
### High-Frequency Sessions (500+ messages)
```typescript
// Aggressive pruning
const PRUNE_THRESHOLD_NORMAL = 30;
const PRUNE_THRESHOLD_AGGRESSIVE = 20;
// Faster decay
const POOL_DECAY_GAMMA = 0.75;
// More frequent sweeps
const SWEEP_INTERVAL = 250;
// Reduce memory overhead
const HOT_STATE_LIMITS = {
maxRenderedChars: 800, // Reduced
maxActiveFilesRendered: 5, // Reduced
maxOpenErrorsRendered: 2, // Reduced
};
```
### Long-Running Sessions (Multi-day)
```typescript
// Preserve more context
const POOL_MAX_ITEMS = 100;
const COMPACTION_KEEP_ITEMS = 20;
// Slower decay
const POOL_DECAY_GAMMA = 0.90;
// Longer TTL
const SWEEP_TTL_DAYS = 14;
const LONG_TERM_LIMITS = {
maxEntries: 40, // Increased
targetRenderedChars: 5000, // Increased
};
```
### Memory-Constrained Environments
```typescript
// Strict limits
const CORE_MEMORY_LIMITS = {
goal: 500,
progress: 1000,
context: 800,
const LONG_TERM_LIMITS = {
maxRenderedChars: 3000,
maxEntries: 15,
};
const POOL_MAX_ITEMS = 20;
// Aggressive pruning
const PRUNE_THRESHOLD_NORMAL = 20;
const HOT_STATE_LIMITS = {
maxRenderedChars: 600,
maxActiveFilesRendered: 4,
};
```
## Debugging Configuration
### Enable Verbose Logging
Add `console.log()` statements in key functions:
```typescript
// In loadCoreMemory()
console.log("[Core Memory] Loaded:", memory);
// In applyDecay()
console.log("[Pool Decay] Pruned items:", prunedCount);
// In sweepToolOutputCache()
console.log("[Sweep] Deleted files:", deletedCount);
```
## Debugging
### Inspect Memory Files
```bash
# Core memory
cat .opencode/memory-core/<sessionID>.json | jq
# Workspace memory
cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json | jq
# Working memory
cat .opencode/memory-working/<sessionID>.json | jq
# Pressure state
cat .opencode/memory-working/<sessionID>_pressure.json | jq
# Sweep log
cat .opencode/memory-working/<sessionID>_sweep.json | jq
# Session state
cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
```
## Migration Notes
### Clear Workspace Memory
### Upgrading from Pre-Phase 3
Old format files are automatically migrated:
```typescript
// Old format
{ items: Array<Item> }
// New format (auto-migrated)
{ slots: { error: [], decision: [], ... }, pool: [...] }
```bash
# Remove workspace memory (start fresh)
rm ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json
```
No manual intervention required.
### Clear Session State
### Upgrading from Phase 3 to Phase 4.5
Storage governance is backward compatible. No migration needed.
```bash
# Remove all session states
rm ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json
```
## Best Practices
1. **Core Memory Discipline**:
- Clear `goal` immediately after task completion
- Keep `progress` concise (use checklist format)
- Only put actively edited files in `context`
1. **Workspace Memory Hygiene**:
- Let the plugin extract memories automatically
- Use explicit "remember this" for important information
- Don't manually edit memory files unless testing
2. **Working Memory Hygiene**:
- Clear `error` slot after fixing all errors (`working_memory_clear_slot`)
- Let pool decay naturally (avoid manual removal unless necessary)
- Review working memory periodically (use `working_memory_read`)
2. **Session State**:
- Let the plugin track active files automatically
- Errors are cleared when commands succeed
- No manual intervention needed
3. **Pressure Management**:
- Respond to "moderate" warnings proactively
- Compress core memory at "high" pressure
- Clear working memory at "critical" pressure
4. **Storage Maintenance**:
- Let sweep run automatically (no manual intervention)
- Delete old session files manually if needed
- Monitor `.opencode/` directory size periodically
3. **Memory Extraction**:
- Use `<workspace_memory_candidates>` during compaction
- Follow the pattern: `- [type] text`
- Quality gate rejects invalid candidates
---
**Last Updated**: February 2026
**Configuration File**: `index.ts` (constants section)
**Last Updated**: April 2026
**Configuration File**: `src/types.ts`
+76 -12
View File
@@ -10,7 +10,7 @@ Add to your `~/.config/opencode/opencode.json`:
}
```
Restart OpenCode. The plugin is downloaded and installed automatically — no `npm install` needed.
Restart OpenCode. The plugin activates automatically — no manual setup needed.
> **Note**: The correct key is `plugin` (singular), not `plugins`.
@@ -22,19 +22,44 @@ Restart OpenCode. The plugin is downloaded and installed automatically — no `n
## Verification
After restarting OpenCode, ask your agent:
After restarting OpenCode, memory context appears automatically in system prompts. You'll see:
```
Use core_memory_read to show me what you remember
<workspace_memory>
- [decision] ... (if any long-term memories exist)
</workspace_memory>
---
<workspace_memory_candidates>
- [project] ... (candidates for long-term memory)
</workspace_memory_candidates>
Active Files:
- path/to/file.ts (action, count)
Open Errors: (none, or listed)
```
If the tool responds, the plugin is active.
**No tools to call**. The plugin works automatically via hooks.
## How Memory Works
### Workspace Memory (Long-term)
Persists across sessions. Automatically extracted during compaction when you say "remember this" or when important decisions are made.
### Hot Session State (Short-term)
Tracks current session:
- Active files (what you're working on)
- Open errors (unresolved issues)
- Recent decisions (for compaction candidate promotion)
## Troubleshooting
### Plugin Not Loading
**Symptom**: No `core_memory_update` tool available
**Symptom**: No memory context in system prompt
**Solution**:
1. Check `~/.config/opencode/opencode.json` uses `"plugin"` (not `"plugins"`)
@@ -43,11 +68,21 @@ If the tool responds, the plugin is active.
### Memory Files Not Created
**Symptom**: No `.opencode/memory-core/` or `.opencode/memory-working/` directories
**Symptom**: No `~/.local/share/opencode-working-memory/` directory
**Solution**:
1. Ensure OpenCode has write permissions in project directory
2. Trigger memory operations (e.g., use `core_memory_update` tool)
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
### Memory Not Persisting
**Symptom**: Workspace memory empty after restart
**Solution**:
1. Verify you're in the same workspace (different workspace = different memory)
2. Ensure `<workspace_memory_candidates>` were captured during compaction
3. Check `workspace-memory.json` exists
### Type Errors During Development
@@ -56,16 +91,45 @@ If the tool responds, the plugin is active.
**Solution**:
1. Run `npm install` to install dev dependencies
2. Run `npm run typecheck` to check for errors
3. See [AGENTS.md](../AGENTS.md) for code style guidelines
3. Run `npm test` to verify functionality
## Uninstallation
Remove `"opencode-working-memory"` from the `plugin` array in `~/.config/opencode/opencode.json`.
Memory files in `.opencode/memory-*` will persist unless manually deleted.
Memory files in `~/.local/share/opencode-working-memory/` persist unless manually deleted.
## Manual Memory Management
### View Workspace Memory
```bash
cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json | jq
```
### View Session State
```bash
cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
```
### Clear Workspace Memory
```bash
rm ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json
```
### Clear All Session States
```bash
rm -rf ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json
```
## Next Steps
- Read [Architecture Documentation](./architecture.md) to understand how memory tiers work
- Read [Architecture Documentation](./architecture.md) to understand how the three layers work
- See [Configuration Guide](./configuration.md) for customization options
- Check [AGENTS.md](../AGENTS.md) for development guidelines
---
**Last Updated**: April 2026
File diff suppressed because it is too large Load Diff
+5 -2025
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -1,12 +1,22 @@
{
"name": "opencode-working-memory",
"version": "1.1.2",
"description": "Advanced four-tier memory architecture for OpenCode with intelligent pressure monitoring and auto-storage governance",
"version": "1.2.0",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
"exports": {
".": "./index.ts"
},
"files": [
"index.ts",
"src/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types tests/*.test.ts"
},
"keywords": [
"opencode",
+255
View File
@@ -0,0 +1,255 @@
import { createHash } from "crypto";
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
import { LONG_TERM_LIMITS } from "./types.ts";
function id(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function hash(value: string): string {
return createHash("sha1").update(value).digest("hex").slice(0, 12);
}
/**
* Check if a memory request is negated (e.g., "不要記住", "don't remember").
* Uses structured adjacency detection to avoid false positives.
*/
function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
const prefix = text.slice(Math.max(0, matchIndex - 30), matchIndex);
// Chinese negative: 不要/別/不用 + optional 幫我, must be adjacent to trigger
if (/(?:|||||)\s*(?:|)?\s*$/u.test(prefix)) {
return true;
}
// English negative: do not / don't / never / not + optional please, must be adjacent to trigger
if (/(?:do\s+not|don't|dont|never|not)\s+(?:please\s+)?$/i.test(prefix)) {
return true;
}
return false;
}
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
const patterns = [
// 中文:請/幫我 + 記住 + 可選後綴
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住)(?:这一点|這一點|这点|這點|这个|這個)?[:,]?\s*(.+)$/gim,
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
/(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[:,]?\s*(.+)$/gim,
// save/add to memory
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[:,]?\s*(.+)$/gim,
// commit to memory
/(?:^|\n)\s*(?:please\s+)?commit\s+(?:this|that)?\s*to memory[:,]?\s*(.+)$/gim,
// going forward / from now on
/(?:从现在开始|從現在開始|从今以后|從今以後|from now on|going forward)[:,]?\s*(.+)$/gim,
// 偏好
/(?:我的偏好是|我偏好|以后请|以後請|以后都|以後都)[:,]?\s*(.+)$/gim,
/(?:^|\n)\s*(?:my preference is|i prefer)[:,]?\s*(.+)$/gim,
];
const now = new Date().toISOString();
const entries: LongTermMemoryEntry[] = [];
const seen = new Set<string>();
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const body = match[1]?.trim();
if (!body || body.length < 8) continue;
// Calculate actual trigger position (after possible newline)
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
// Check if this is a negated request (e.g., "不要記住")
if (isNegatedMemoryRequest(text, triggerIndex)) continue;
// Check if it's a deferral (e.g., "later", "next time")
if (/^(再说|再說|later|next time)$/i.test(body)) continue;
// Dedupe by canonical body
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
if (seen.has(key)) continue;
seen.add(key);
const type = classifyExplicitMemory(body);
entries.push({
id: id("mem"),
type,
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
staleAfterDays: staleAfterDaysFor(type),
});
}
}
return entries;
}
function classifyExplicitMemory(text: string): LongTermType {
const lower = text.toLowerCase();
if (/https?:\/\/|linear|slack|notion|dashboard|grafana/.test(lower)) return "reference";
if (/decide|decision|choose|chosen|决定|決定|选择|選擇/.test(lower)) return "decision";
if (/project|repo|项目|專案/.test(lower)) return "project";
return "feedback";
}
export function staleAfterDaysFor(type: LongTermType): number | undefined {
if (type === "feedback") return undefined;
if (type === "decision") return 45;
if (type === "project") return 60;
return 90;
}
export function extractActiveFiles(
toolName: string,
args: Record<string, unknown>,
output: string,
): Array<{ path: string; action: ActiveFile["action"] }> {
if (toolName === "read" && typeof args.filePath === "string") return [{ path: args.filePath, action: "read" }];
if (toolName === "edit" && typeof args.filePath === "string") return [{ path: args.filePath, action: "edit" }];
if (toolName === "write" && typeof args.filePath === "string") return [{ path: args.filePath, action: "write" }];
if (toolName === "grep") return extractGrepPaths(output).map(path => ({ path, action: "grep" as const }));
return [];
}
function extractGrepPaths(output: string): string[] {
const matches = output.match(/^(\/[^\n]+\.(ts|tsx|js|jsx|json|md|py|go|rs|toml|yml|yaml)):/gm) ?? [];
return [...new Set(matches.map(match => match.replace(/:$/, "")))].slice(0, 10);
}
function isErrorLine(line: string, knownValidationCommand: boolean): boolean {
// 無條件捕捉的強訊號
if (/TS\d{4}|ERR!|Traceback \(most recent call last\):|panic:/i.test(line)) return true;
// Error 類型前綴(獨立行)
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(line)) {
return true;
}
// 已知 validation command 才用寬鬆匹配
if (knownValidationCommand) {
return /\b(error|failed|failure|exception)\b/i.test(line);
}
return false;
}
export function extractErrorsFromBash(command: string, output: string): OpenError[] {
const classifiedCategory = classifyCommand(command);
const knownValidationCommand = classifiedCategory !== null;
const lines = output
.split("\n")
.filter(line => isErrorLine(line, knownValidationCommand))
.slice(0, 5);
if (lines.length === 0) return [];
const category = classifiedCategory ?? "runtime";
const summary = lines.join(" ").slice(0, 280);
const fingerprint = hash(`${category}:${summary.toLowerCase().replace(/\s+/g, " ")}`);
const now = Date.now();
return [
{
id: `err_${fingerprint}`,
category,
summary,
command,
file: extractFirstPath(summary),
fingerprint,
status: "open",
firstSeen: now,
lastSeen: now,
seenCount: 1,
},
];
}
export function classifyCommand(command: string): OpenError["category"] | null {
const c = command.toLowerCase();
if (/\b(tsc|typecheck)\b/.test(c)) return "typecheck";
if (/\b(test|vitest|jest|mocha|pytest|go test|cargo test)\b/.test(c)) return "test";
if (/\b(lint|eslint|biome)\b/.test(c)) return "lint";
if (/\b(build|vite build|webpack|tsup)\b/.test(c)) return "build";
return null;
}
function extractFirstPath(text: string): string | undefined {
return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0];
}
/**
* Quality gate for workspace memory candidates.
* Rejects low-quality entries like git hashes, error messages, etc.
*/
function shouldAcceptWorkspaceMemoryCandidate(entry: {
type: LongTermType;
text: string;
}): boolean {
const text = entry.text.trim();
// Too short
if (text.length < 20) return false;
// Git history / commit hash
if (/\b[0-9a-f]{7,40}\b/.test(text)) return false;
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return false;
// Raw error / stack trace
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError):/i.test(text)) return false;
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return false;
// Active file list
if (/^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text)) return false;
// Temporary progress
if (/^(currently|now|pending|in progress|todo|wip):/i.test(text)) return false;
// Code signature / API doc
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false;
// Path-heavy facts (rediscoverable from repo)
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
if (pathCount > 2) return false;
return true;
}
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
const match = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
if (!match) return [];
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);
if (!item) continue;
const type = item[1].toLowerCase() as LongTermType;
const body = item[2].trim();
if (body.length < 12) continue;
// Apply quality gate
if (!shouldAcceptWorkspaceMemoryCandidate({ type, text: body })) continue;
entries.push({
id: id("mem"),
type,
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
staleAfterDays: staleAfterDaysFor(type),
});
}
return entries;
}
+177
View File
@@ -0,0 +1,177 @@
/**
* OpenCode SDK helper functions for memory plugin.
*
* These functions wrap OpenCode client API calls to extract:
* - Latest user message text (for explicit memory extraction)
* - Latest compaction summary (for memory candidate parsing)
* - Pending todos (for compaction context)
*/
/**
* Extract the latest user message text from a session.
* Returns { id, text } or null if no user message found.
*/
export async function latestUserText(
client: unknown,
sessionID: string
): Promise<{ id: string; text: string } | null> {
try {
// Cast client to access session.messages API
const api = client as {
session: {
messages: (params: { path: { id: string } }) => Promise<{
data?: Array<{
info?: {
role?: string;
id?: string;
};
parts?: Array<{
type?: string;
text?: string;
}>;
}>;
}>;
};
};
const result = await api.session.messages({ path: { id: sessionID } });
const messages = result.data ?? [];
// Scan backwards from most recent messages to find the latest user message
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.info?.role !== "user") continue;
// Concatenate all text parts
const text = (msg.parts ?? [])
.filter((p: { type?: string }) => p.type === "text")
.map((p: { text?: string }) => p.text ?? "")
.join("\n");
if (text.trim()) {
return {
id: msg.info?.id ?? "",
text: text.trim(),
};
}
}
return null;
} catch {
return null;
}
}
/**
* Extract the latest compaction summary from a session.
* Compaction summaries are assistant messages marked with summary=true.
*/
export async function latestCompactionSummary(
client: unknown,
sessionID: string
): Promise<string | null> {
try {
const api = client as {
session: {
messages: (params: { path: { id: string } }) => Promise<{
data?: Array<{
info?: {
role?: string;
summary?: boolean;
};
parts?: Array<{
type?: string;
text?: string;
}>;
}>;
}>;
};
};
const result = await api.session.messages({ path: { id: sessionID } });
const messages = result.data ?? [];
// Scan backwards to find the most recent summary (compaction)
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.info?.role !== "assistant" || msg.info?.summary !== true) continue;
const text = (msg.parts ?? [])
.filter((p: { type?: string }) => p.type === "text")
.map((p: { text?: string }) => p.text ?? "")
.join("\n");
if (text.trim()) {
return text.trim();
}
}
return null;
} catch {
return null;
}
}
/**
* Fetch pending todos from a session.
* Returns todos that are not marked as completed.
*/
export async function pendingTodos(
client: unknown,
sessionID: string
): Promise<Array<{ content: string; status: string; priority?: string }>> {
try {
const api = client as {
session: {
todo: (params: { path: { id: string } }) => Promise<{
data?: Array<{
content?: string;
status?: string;
priority?: string;
}>;
}>;
};
};
const result = await api.session.todo({ path: { id: sessionID } });
const todos = result.data ?? [];
// Filter out completed todos
return todos
.filter((todo: { status?: string }) => todo.status !== "completed")
.map((todo: { content?: string; status?: string; priority?: string }) => ({
content: todo.content ?? "",
status: todo.status ?? "pending",
priority: todo.priority,
}));
} catch {
return [];
}
}
/**
* Check if a session is a sub-agent (has a parent session).
* Sub-agents are short-lived and should not have their own memory tracking.
*/
export async function isSubAgent(
client: unknown,
sessionID: string
): Promise<boolean> {
try {
const api = client as {
session: {
get: (params: { path: { id: string } }) => Promise<{
data?: {
parentID?: string | null;
};
}>;
};
};
const result = await api.session.get({ path: { id: sessionID } });
return result.data?.parentID != null;
} catch {
// If we can't determine, assume it's NOT a sub-agent (safe default)
return false;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { createHash } from "crypto";
import { homedir } from "os";
import { join } from "path";
import { realpath } from "fs/promises";
export function dataHome(): string {
return process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
}
export async function workspaceKey(root: string): Promise<string> {
const resolved = await realpath(root).catch(() => root);
return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
}
export async function memoryRoot(root: string): Promise<string> {
return join(dataHome(), "opencode-working-memory", "workspaces", await workspaceKey(root));
}
export async function workspaceMemoryPath(root: string): Promise<string> {
return join(await memoryRoot(root), "workspace-memory.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`);
}
+374
View File
@@ -0,0 +1,374 @@
/**
* 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 3: Native OpenCode State (todos owned by OpenCode, read during compaction)
*
* This plugin:
* - Caches frozen workspace memory per sessionID
* - Processes explicit memory from latest user text once per message id
* - Injects frozen workspace memory and dynamic hot session state into system prompt
* - Updates session state after tool execution
* - Augments compaction context with memory, hot state, todos, and instruction
* - Parses compaction summaries for memory candidates and merges them
*/
import { rm } from "fs/promises";
import type { Plugin } from "@opencode-ai/plugin";
import {
extractExplicitMemories,
extractActiveFiles,
extractErrorsFromBash,
parseWorkspaceMemoryCandidates,
} from "./extractors.ts";
import {
loadWorkspaceMemory,
updateWorkspaceMemory,
renderWorkspaceMemory,
} from "./workspace-memory.ts";
import {
loadSessionState,
updateSessionState,
touchActiveFile,
upsertOpenError,
clearErrorsForSuccessfulCommand,
markErrorsMaybeFixedForFile,
addRecentDecision,
renderHotSessionState,
} from "./session-state.ts";
import { sessionStatePath } from "./paths.ts";
import {
latestUserText,
latestCompactionSummary,
pendingTodos,
} from "./opencode.ts";
/**
* Generate the memory candidate instruction to include in compaction context.
*/
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();
}
/**
* Render todos for compaction context.
*/
function renderTodos(todos: Array<{ content: string; status: string; priority?: string }>): string {
if (todos.length === 0) return "";
const lines = ["<pending_todos>"];
for (const todo of todos) {
const priority = todo.priority ? ` [${todo.priority}]` : "";
lines.push(`- ${todo.content}${priority}`);
}
lines.push("</pending_todos>");
return lines.join("\n");
}
export const MemoryV2Plugin: Plugin = async (input) => {
const { directory, client } = input;
// Cache for sub-agent detection — avoids repeated API calls per session.
// Maps sessionID → parentID (string) or null (root session).
const sessionParentCache = new Map<string, string | null>();
async function isSubAgent(sessionID: string): Promise<boolean> {
if (sessionParentCache.has(sessionID)) {
return sessionParentCache.get(sessionID) !== null;
}
try {
const result = await client.session.get({ path: { id: sessionID } });
const parentID = result.data?.parentID ?? null;
sessionParentCache.set(sessionID, parentID);
return parentID !== null;
} catch {
// If we can't determine, assume it's NOT a sub-agent (safe default).
sessionParentCache.set(sessionID, null);
return false;
}
}
// Cache for frozen workspace memory per session
const frozenWorkspaceMemoryCache = new Map<
string,
{
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
loadedAt: number;
}
>();
// Cache for processed user message IDs (to avoid duplicate processing)
const processedUserMessages = new Map<string, Set<string>>();
async function processLatestUserMessage(sessionID: string): Promise<void> {
const processedForSession = processedUserMessages.get(sessionID) ?? new Set<string>();
const latestMessage = await latestUserText(client, sessionID);
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
const memories = extractExplicitMemories(latestMessage.text);
const 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;
});
// Update frozen cache
const cached = frozenWorkspaceMemoryCache.get(sessionID);
if (cached) {
cached.store = workspaceMemory;
}
}
if (decisions.length > 0) {
await updateSessionState(directory, sessionID, state => {
for (const decision of decisions) {
addRecentDecision(state, {
text: decision.text,
rationale: decision.rationale,
source: "user",
});
}
return state;
});
}
processedForSession.add(latestMessage.id);
processedUserMessages.set(sessionID, processedForSession);
}
function bashExitCode(hookOutput: unknown): number | undefined {
const output = hookOutput as {
exitCode?: unknown;
metadata?: Record<string, unknown>;
output?: string;
};
const candidates = [
output.exitCode,
output.metadata?.exitCode,
output.metadata?.exit_code,
output.metadata?.code,
output.metadata?.status,
];
for (const candidate of candidates) {
if (typeof candidate === "number") return candidate;
if (typeof candidate === "string" && /^-?\d+$/.test(candidate)) return Number(candidate);
}
const text = output.output ?? "";
const match = text.match(/(?:exit\s*code|exitCode|status)[:=]\s*(-?\d+)/i);
return match ? Number(match[1]) : undefined;
}
/**
* Get frozen workspace memory for a session.
* Loads from disk once per session, then caches in memory.
*/
async function getFrozenWorkspaceMemory(
root: string,
sessionID: string
): Promise<Awaited<ReturnType<typeof loadWorkspaceMemory>>> {
const now = Date.now();
const cached = frozenWorkspaceMemoryCache.get(sessionID);
// Cache is valid for the session lifetime
if (cached) {
return cached.store;
}
const store = await loadWorkspaceMemory(root);
frozenWorkspaceMemoryCache.set(sessionID, { store, loadedAt: now });
return store;
}
/**
* Clear frozen workspace memory cache (e.g., after compaction).
*/
function clearFrozenWorkspaceMemoryCache(sessionID: string): void {
frozenWorkspaceMemoryCache.delete(sessionID);
}
return {
// Inject workspace memory and hot session state into system prompt
"experimental.chat.system.transform": async (hookInput, output) => {
const { sessionID } = hookInput;
if (!sessionID) return;
// Sub-agents are short-lived - skip memory system
if (await isSubAgent(sessionID)) return;
// 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 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);
}
// Render and inject hot session state
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
output.system.push(hotPrompt);
}
},
// Track tool usage and update session state
"tool.execute.after": async (hookInput, hookOutput) => {
const { sessionID, tool: toolName, args } = hookInput;
const { output: toolOutput } = hookOutput;
if (!sessionID) return;
// Sub-agents don't need memory tracking
if (await isSubAgent(sessionID)) return;
await updateSessionState(directory, sessionID, state => {
// Track active files from tool usage
if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") {
const files = extractActiveFiles(
toolName,
args as Record<string, unknown>,
toolOutput ?? ""
);
for (const { path, action } of files) {
touchActiveFile(state, path, action);
if (action === "edit" || action === "write") {
markErrorsMaybeFixedForFile(state, path, directory);
}
}
}
// Track errors from failed bash commands
if (toolName === "bash") {
const argsRecord = args as Record<string, unknown>;
const command: string = typeof argsRecord?.command === "string"
? argsRecord.command
: "";
const outputText: string = toolOutput ?? "";
// Check if command succeeded - clear errors for that category
const exitCode = bashExitCode(hookOutput);
if (typeof exitCode !== "number") {
// Unknown exit status: do not extract and do not clear
} else if (exitCode === 0 && command) {
clearErrorsForSuccessfulCommand(state, command);
} else if (command) {
// Only extract errors for commands with explicit non-zero exit
const errors = extractErrorsFromBash(command, outputText);
for (const error of errors) {
upsertOpenError(state, error);
}
}
}
return state;
});
// Process explicit memory from latest user message
// Only process once per message ID
await processLatestUserMessage(sessionID);
},
// Add compaction context before summarization
"experimental.session.compacting": async (hookInput, output) => {
const { sessionID } = hookInput;
if (!sessionID) return;
// Sub-agents don't need compaction support
if (await isSubAgent(sessionID)) return;
// Add compaction context with memory, hot state, todos, and instruction
const contextParts: string[] = [];
// 1. Frozen workspace memory
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
if (workspacePrompt) {
contextParts.push(workspacePrompt);
}
// 2. Hot session state
const sessionState = await loadSessionState(directory, sessionID);
const hotPrompt = renderHotSessionState(sessionState, directory);
if (hotPrompt) {
contextParts.push(hotPrompt);
}
// 3. Pending todos from OpenCode
const todos = await pendingTodos(client, sessionID);
const todosPrompt = renderTodos(todos);
if (todosPrompt) {
contextParts.push(todosPrompt);
}
// 4. Memory candidate instruction
contextParts.push(memoryCandidateInstruction());
// Add to compaction context (output.context is an array)
for (const part of contextParts) {
output.context.push(part);
}
},
// 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;
if (!sessionID) return;
// Sub-agents don't need post-compaction processing
if (await isSubAgent(sessionID)) return;
// Parse latest compaction summary for memory candidates
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;
});
// Clear frozen cache so next session reloads with new memories
clearFrozenWorkspaceMemoryCache(sessionID);
}
}
}
if (event.type === "session.deleted") {
const sessionID = (event.properties as { info?: { id?: string } })?.info?.id;
if (sessionID) {
// Clean up caches
frozenWorkspaceMemoryCache.delete(sessionID);
processedUserMessages.delete(sessionID);
sessionParentCache.delete(sessionID);
await rm(await sessionStatePath(directory, sessionID), { force: true });
}
}
},
};
}
+240
View File
@@ -0,0 +1,240 @@
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 { HOT_STATE_LIMITS } from "./types.ts";
const ACTION_WEIGHT: Record<ActiveFile["action"], number> = {
edit: 50,
write: 45,
grep: 30,
read: 20,
};
export function createEmptySessionState(sessionID: string): SessionState {
return {
version: 1,
sessionID,
turn: 0,
updatedAt: new Date().toISOString(),
activeFiles: [],
openErrors: [],
recentDecisions: [],
};
}
export async function loadSessionState(root: string, sessionID: string): Promise<SessionState> {
const fallback = createEmptySessionState(sessionID);
const loaded = await readJSON(await sessionStatePath(root, sessionID), () => fallback);
loaded.sessionID = sessionID;
loaded.activeFiles = Array.isArray(loaded.activeFiles) ? loaded.activeFiles : [];
loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : [];
loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : [];
return loaded;
}
export async function saveSessionState(root: string, state: SessionState): Promise<void> {
await atomicWriteJSON(await sessionStatePath(root, state.sessionID), normalizeSessionState(state));
}
export async function updateSessionState(
root: string,
sessionID: string,
updater: (state: SessionState) => SessionState | Promise<SessionState>,
): Promise<SessionState> {
const path = await sessionStatePath(root, sessionID);
return updateJSON(path, () => createEmptySessionState(sessionID), async current => {
current.sessionID = sessionID;
current.activeFiles = Array.isArray(current.activeFiles) ? current.activeFiles : [];
current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : [];
current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : [];
return normalizeSessionState(await updater(current));
});
}
function normalizeSessionState(state: SessionState): SessionState {
state.updatedAt = new Date().toISOString();
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);
return state;
}
export function touchActiveFile(state: SessionState, filePath: string, action: ActiveFile["action"]): void {
const now = Date.now();
const existing = state.activeFiles.find(item => item.path === filePath);
if (existing) {
existing.count += 1;
existing.lastSeen = now;
if (ACTION_WEIGHT[action] >= ACTION_WEIGHT[existing.action]) {
existing.action = action;
}
} else {
state.activeFiles.push({
path: filePath,
action,
count: 1,
lastSeen: now,
});
}
state.activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesStored);
state.updatedAt = new Date().toISOString();
}
export function upsertOpenError(state: SessionState, error: OpenError): void {
const now = Date.now();
const existing = state.openErrors.find(item => item.fingerprint === error.fingerprint);
if (existing) {
existing.summary = error.summary;
existing.command = error.command ?? existing.command;
existing.file = error.file ?? existing.file;
existing.lastSeen = now;
existing.status = "open";
existing.seenCount += 1;
} else {
state.openErrors.unshift({
...error,
firstSeen: error.firstSeen ?? now,
lastSeen: now,
seenCount: Math.max(error.seenCount ?? 1, 1),
status: "open",
});
}
state.openErrors.sort((a, b) => b.lastSeen - a.lastSeen);
state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored);
state.updatedAt = new Date().toISOString();
}
export function markErrorsMaybeFixedForFile(
state: SessionState,
filePath: string,
workspaceRoot = "",
): void {
const candidates = new Set<string>([filePath]);
if (workspaceRoot && filePath.startsWith(workspaceRoot)) {
candidates.add(relative(workspaceRoot, filePath));
}
let changed = false;
for (const error of state.openErrors) {
if (error.status !== "open") continue;
if (!error.file) continue;
for (const candidate of candidates) {
if (pathsMatch(error.file, candidate)) {
error.status = "maybe_fixed";
error.lastSeen = Date.now();
changed = true;
break;
}
}
}
if (changed) state.updatedAt = new Date().toISOString();
}
export function addRecentDecision(
state: SessionState,
decision: Pick<SessionDecision, "text" | "source" | "rationale">,
): void {
const normalized = decision.text.toLowerCase().replace(/\s+/g, " ").trim();
const existing = state.recentDecisions.find(item => (
item.text.toLowerCase().replace(/\s+/g, " ").trim() === normalized
));
const now = Date.now();
if (existing) {
existing.createdAt = now;
existing.rationale = decision.rationale ?? existing.rationale;
existing.source = decision.source;
} else {
state.recentDecisions.push({
id: `decision_${now}_${Math.random().toString(36).slice(2, 8)}`,
text: decision.text,
rationale: decision.rationale,
source: decision.source,
createdAt: now,
});
}
state.recentDecisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
state.updatedAt = new Date().toISOString();
}
export function clearErrorsForSuccessfulCommand(state: SessionState, command: string): void {
const category = classifyCommand(command);
if (!category) return;
state.openErrors = state.openErrors.filter(error => error.category !== category);
state.updatedAt = new Date().toISOString();
}
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
const activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesRendered);
const openErrors = [...state.openErrors]
.sort((a, b) => b.lastSeen - a.lastSeen)
.slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered);
const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0) return "";
const lines: string[] = ["<hot_session_state>"];
if (activeFiles.length > 0) {
lines.push("active_files:");
for (const item of activeFiles) {
const viewPath = displayPath(workspaceRoot, item.path);
lines.push(`- ${viewPath} (${item.action}, ${item.count}x)`);
}
}
if (openErrors.length > 0) {
lines.push("open_errors:");
for (const err of openErrors) {
lines.push(`- [${err.category}] ${err.summary}`);
}
}
if (decisions.length > 0) {
lines.push("recent_decisions:");
for (const decision of decisions) {
lines.push(`- ${decision.text}`);
}
}
lines.push("</hot_session_state>");
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
}
function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] {
return [...activeFiles].sort((a, b) => {
const scoreA = ACTION_WEIGHT[a.action] + a.count * 3;
const scoreB = ACTION_WEIGHT[b.action] + b.count * 3;
if (scoreA !== scoreB) return scoreB - scoreA;
return b.lastSeen - a.lastSeen;
});
}
function displayPath(workspaceRoot: string, filePath: string): string {
if (!workspaceRoot || !filePath.startsWith(workspaceRoot)) return filePath;
return relative(workspaceRoot, filePath) || ".";
}
function pathsMatch(errorFile: string, touchedFile: string): boolean {
const normalizedError = errorFile.replace(/\\/g, "/").replace(/^\.\//, "");
const normalizedTouched = touchedFile.replace(/\\/g, "/").replace(/^\.\//, "");
return normalizedError === normalizedTouched
|| normalizedTouched.endsWith(`/${normalizedError}`)
|| normalizedError.endsWith(`/${normalizedTouched}`);
}
function classifyCommand(command: string): OpenError["category"] | null {
const c = command.toLowerCase();
if (/\b(tsc|typecheck)\b/.test(c)) return "typecheck";
if (/\b(test|vitest|jest|mocha|pytest|go test|cargo test)\b/.test(c)) return "test";
if (/\b(lint|eslint|biome)\b/.test(c)) return "lint";
if (/\b(build|vite build|webpack|tsup)\b/.test(c)) return "build";
return null;
}
+49
View File
@@ -0,0 +1,49 @@
import { existsSync } from "fs";
import { randomUUID } from "crypto";
import { mkdir, readFile, rename, writeFile } from "fs/promises";
import { dirname } from "path";
const fileLocks = new Map<string, Promise<unknown>>();
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
if (!existsSync(path)) return fallback();
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch {
return fallback();
}
}
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
await rename(tmp, path);
}
export async function updateJSON<T>(
path: string,
fallback: () => T,
updater: (current: T) => T | Promise<T>,
): Promise<T> {
const previous = fileLocks.get(path) ?? Promise.resolve();
let release: () => void = () => {};
const currentLock = new Promise<void>(resolve => {
release = resolve;
});
const queued = previous.then(() => currentLock, () => currentLock);
fileLocks.set(path, queued);
try {
await previous.catch(() => undefined);
const current = await readJSON(path, fallback);
const updated = await updater(current);
await atomicWriteJSON(path, updated);
return updated;
} finally {
release();
if (fileLocks.get(path) === queued) {
fileLocks.delete(path);
}
}
}
+88
View File
@@ -0,0 +1,88 @@
export type LongTermType = "feedback" | "project" | "decision" | "reference";
export type LongTermSource = "explicit" | "compaction" | "manual";
export type LongTermMemoryEntry = {
id: string;
type: LongTermType;
text: string;
rationale?: string;
source: LongTermSource;
confidence: number;
status: "active" | "superseded";
createdAt: string;
updatedAt: string;
staleAfterDays?: number;
supersedes?: string[];
tags?: string[];
};
export type WorkspaceMemoryStore = {
version: 1;
workspace: {
root: string;
key: string;
};
limits: {
maxRenderedChars: number;
maxEntries: number;
};
entries: LongTermMemoryEntry[];
updatedAt: string;
};
export type ActiveFile = {
path: string;
action: "read" | "grep" | "edit" | "write";
count: number;
lastSeen: number;
};
export type OpenError = {
id: string;
category: "typecheck" | "test" | "lint" | "build" | "runtime" | "tool";
summary: string;
command?: string;
file?: string;
fingerprint: string;
status: "open" | "maybe_fixed";
firstSeen: number;
lastSeen: number;
seenCount: number;
};
export type SessionDecision = {
id: string;
text: string;
rationale?: string;
source: "assistant" | "user" | "compaction";
createdAt: number;
promotedToLongTerm?: boolean;
};
export type SessionState = {
version: 1;
sessionID: string;
turn: number;
updatedAt: string;
activeFiles: ActiveFile[];
openErrors: OpenError[];
recentDecisions: SessionDecision[];
};
export const LONG_TERM_LIMITS = {
maxRenderedChars: 5200,
targetRenderedChars: 4200,
maxEntries: 28,
maxEntryTextChars: 260,
maxRationaleChars: 180,
} as const;
export const HOT_STATE_LIMITS = {
maxRenderedChars: 1200,
maxActiveFilesStored: 20,
maxActiveFilesRendered: 8,
maxOpenErrorsStored: 5,
maxOpenErrorsRendered: 3,
maxRecentDecisionsStored: 8,
} as const;
+174
View File
@@ -0,0 +1,174 @@
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
import { LONG_TERM_LIMITS } from "./types.ts";
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
const MIN_ENVELOPE_LENGTH = 80;
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
return {
version: 1,
workspace: { root, key: await workspaceKey(root) },
limits: {
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: LONG_TERM_LIMITS.maxEntries,
},
entries: [],
updatedAt: new Date().toISOString(),
};
}
export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
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,
};
loaded.entries = Array.isArray(loaded.entries) ? loaded.entries : [];
return loaded;
}
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
const normalized = await normalizeWorkspaceMemory(root, store);
await atomicWriteJSON(await workspaceMemoryPath(root), normalized);
}
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));
});
}
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,
};
store.entries = enforceLongTermLimits(store.entries);
store.updatedAt = new Date().toISOString();
return store;
}
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
if (source === "explicit") return 3;
if (source === "manual") return 2;
return 1;
}
function canonicalMemoryText(text: string): string {
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[\s\p{P}]+/gu, " ")
.trim();
}
export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
const byKey = new Map<string, LongTermMemoryEntry>();
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)}`;
const existing = byKey.get(key);
// Source priority: explicit > manual > compaction
// Same source: higher confidence wins
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 });
}
}
}
return [...byKey.values()]
.sort((a, b) => priority(b) - priority(a))
.slice(0, LONG_TERM_LIMITS.maxEntries);
}
function priority(entry: LongTermMemoryEntry): number {
const typeWeight = {
feedback: 400,
decision: 300,
project: 200,
reference: 100,
}[entry.type];
const sourceWeight = entry.source === "explicit" ? 1000 : 0;
return sourceWeight + typeWeight + entry.confidence * 10;
}
function wouldFit(
lines: string[],
nextLine: string,
closingLine: string,
maxChars: number
): boolean {
return [...lines, nextLine, closingLine].join("\n").length <= maxChars;
}
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
const active = enforceLongTermLimits(store.entries);
if (active.length === 0) return "";
const maxChars = Math.min(
store.limits.maxRenderedChars,
LONG_TERM_LIMITS.maxRenderedChars
);
// 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.",
];
for (const type of ["feedback", "project", "decision", "reference"] as const) {
const items = active.filter(entry => entry.type === type);
if (items.length === 0) continue;
const sectionLines: string[] = [`${type}:`];
for (const item of items) {
const line = `- ${renderEntry(item)}`;
if (wouldFit([...lines, ...sectionLines], line, closing, maxChars)) {
sectionLines.push(line);
}
}
if (sectionLines.length > 1 && wouldFit(lines, sectionLines[0], closing, maxChars)) {
lines.push(...sectionLines);
}
}
lines.push(closing);
return lines.join("\n");
}
function renderEntry(entry: LongTermMemoryEntry): string {
const ageDays = Math.floor((Date.now() - new Date(entry.createdAt).getTime()) / 86_400_000);
const stale = entry.staleAfterDays && ageDays > entry.staleAfterDays ? ` [${ageDays}d old, verify]` : "";
const rationale = entry.rationale
? ` Why: ${entry.rationale.slice(0, LONG_TERM_LIMITS.maxRationaleChars)}`
: "";
return `${entry.text}${rationale}${stale}`;
}
+215
View File
@@ -0,0 +1,215 @@
import test from "node:test";
import assert from "node:assert/strict";
import { extractErrorsFromBash, extractExplicitMemories } from "../src/extractors.ts";
// ============================================
// Task 1: extractErrorsFromBash tests
// ============================================
test("git log output mentioning errors is ignored", () => {
const errors = extractErrorsFromBash(
"cd /repo && rtk git log --oneline -5",
"4832b38 fix: silence memory load errors in working-memory"
);
assert.equal(errors.length, 0);
});
test("cat session json with openErrors is ignored", () => {
const errors = extractErrorsFromBash(
"rtk cat ~/.local/share/opencode-working-memory/session.json",
'"openErrors": []'
);
assert.equal(errors.length, 0);
});
test("typecheck failure is captured", () => {
const errors = extractErrorsFromBash(
"npm run typecheck",
"src/index.ts(10,3): error TS2345: bad type"
);
assert.equal(errors.length, 1);
assert.equal(errors[0].category, "typecheck");
});
test("runtime Error prefix is captured for failed unknown command", () => {
const errors = extractErrorsFromBash(
"node script.js",
"Error: Cannot find module './missing'"
);
assert.equal(errors.length, 1);
assert.equal(errors[0].category, "runtime");
});
test("unknown command with loose error words is ignored", () => {
const errors = extractErrorsFromBash(
"some-unknown-command",
"this output has errors in it but no clear signal"
);
assert.equal(errors.length, 0);
});
test("TypeError prefix is captured", () => {
const errors = extractErrorsFromBash(
"node script.js",
"TypeError: Cannot read property 'x' of undefined"
);
assert.equal(errors.length, 1);
});
test("TS error pattern is always captured", () => {
const errors = extractErrorsFromBash(
"cat some-file.txt", // unknown command, but TS error is strong signal
"src/index.ts(10,3): error TS2345: Argument of type 'string' is not assignable"
);
assert.equal(errors.length, 1);
assert.equal(errors[0].category, "runtime");
});
// ============================================
// Task 3: extractExplicitMemories tests
// ============================================
test("extractExplicitMemories does not treat always as memory trigger", () => {
const items = extractExplicitMemories("tests always fail on CI when cache is stale");
assert.equal(items.length, 0);
});
test("extractExplicitMemories still captures going forward", () => {
const items = extractExplicitMemories("going forward: use pnpm instead of npm");
assert.equal(items.length, 1);
assert.match(items[0].text, /pnpm/);
});
test("extractExplicitMemories captures from now on", () => {
const items = extractExplicitMemories("from now on: reply in Traditional Chinese");
assert.equal(items.length, 1);
assert.match(items[0].text, /Traditional Chinese/);
});
// ============================================
// Task 6: Negative memory request tests
// ============================================
test("extractExplicitMemories ignores Chinese negative request", () => {
const items = extractExplicitMemories("不要記住:這個 repo 使用 npm cache");
assert.equal(items.length, 0);
});
test("extractExplicitMemories ignores English negative request", () => {
const items = extractExplicitMemories("please don't remember this: use npm cache");
assert.equal(items.length, 0);
});
test("extractExplicitMemories does not false positive on 'not forget'", () => {
// "remember this" in middle of sentence should NOT match (not a directive)
const items = extractExplicitMemories("I will not forget to remember this: use pnpm");
assert.equal(items.length, 0);
});
test("extractExplicitMemories captures remember at line start", () => {
// "remember this" at line start IS a directive
const items = extractExplicitMemories("remember this: use pnpm for all packages");
assert.equal(items.length, 1);
assert.match(items[0].text, /pnpm/);
});
test("extractExplicitMemories still captures positive request after negative", () => {
// Ensure negative guard doesn't block positive requests
const items = extractExplicitMemories("記住:使用 pnpm 來管理套件");
assert.equal(items.length, 1);
assert.match(items[0].text, /pnpm/);
});
test("extractExplicitMemories captures multiple memories in same message", () => {
const items = extractExplicitMemories("請記住:使用 pnpm 管理套件\n記住這點:用 TypeScript 撰寫程式碼");
assert.equal(items.length, 2);
});
// ============================================
// Task 7: Compaction quality gate tests
// ============================================
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
test("parseWorkspaceMemoryCandidates rejects short text", () => {
const summary = `
<workspace_memory_candidates>
- [decision] short text
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
});
test("parseWorkspaceMemoryCandidates rejects git commit hash", () => {
const summary = `
<workspace_memory_candidates>
- [project] abc123def456 is the commit hash
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
});
test("parseWorkspaceMemoryCandidates rejects raw error", () => {
const summary = `
<workspace_memory_candidates>
- [feedback] TypeError: Cannot read property 'x' of undefined
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
});
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
const summary = `
<workspace_memory_candidates>
- [reference] at foo (bar.ts:10:5)
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
});
test("parseWorkspaceMemoryCandidates rejects commit prefix", () => {
const summary = `
<workspace_memory_candidates>
- [project] fix: add new feature
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
});
test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
const summary = `
<workspace_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);
});
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
const summary = `
<workspace_memory_candidates>
- [decision] Use pnpm instead of npm for package management
</workspace_memory_candidates>
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 1);
assert.equal(items[0].type, "decision");
assert.match(items[0].text, /pnpm/);
});
test("parseWorkspaceMemoryCandidates accepts valid project info", () => {
const summary = `
<workspace_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");
});
+195
View File
@@ -0,0 +1,195 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MemoryV2Plugin } from "../src/plugin.ts";
import { loadSessionState, saveSessionState } from "../src/session-state.ts";
import type { OpenError } from "../src/types.ts";
// Mock client for root session (not a sub-agent)
function mockRootClient() {
return {
session: {
get: async () => ({ data: { parentID: null } }),
},
};
}
// Helper: create session state with pre-populated open error
function createSessionWithError(sessionID: string, error: OpenError) {
return {
version: 1 as const,
sessionID,
turn: 0,
updatedAt: new Date().toISOString(),
activeFiles: [],
openErrors: [error],
recentDecisions: [],
};
}
test("tool.execute.after: undefined exitCode does NOT create open error", async () => {
// 1. Temp directory for isolated file I/O
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
// 2. Mock client — root session, no user messages
const client = mockRootClient();
// 3. Instantiate plugin
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
// 4. Simulate bash output with NO exitCode, but output contains TS error
// This would create an open error if exitCode was non-zero
// Using STRONG error signal (TS2345) to catch the bug where undefined !== 0
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "bash",
sessionID: "test-session-1",
args: { command: "npm run typecheck" },
},
{
// exitCode deliberately absent (undefined !== 0 is the bug we're testing)
output: "src/index.ts(10,3): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'",
}
);
// 5. Assert: session state has ZERO open errors
const state = await loadSessionState(tmpDir, "test-session-1");
assert.equal(state.openErrors.length, 0,
"exitCode === undefined must not create open errors even with strong error signal");
} finally {
// Cleanup
await rm(tmpDir, { recursive: true, force: true });
}
});
test("tool.execute.after: undefined exitCode does NOT clear existing open error", async () => {
// 1. Temp directory
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
// 2. Pre-populate session state with a real open error
const preExistingError: OpenError = {
id: "err_critical_abc",
category: "typecheck",
summary: "TS2345: Argument of type 'string' is not assignable to parameter of type 'number'",
command: "npm run typecheck",
fingerprint: "ee7b3f9a1c2d",
status: "open",
firstSeen: Date.now() - 3600000,
lastSeen: Date.now() - 3600000,
seenCount: 3,
};
await saveSessionState(tmpDir, createSessionWithError("test-session-2", preExistingError));
// 3. Mock client
const client = mockRootClient();
// 4. Instantiate plugin
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
// 5. Simulate bash output with NO exitCode (inspection command)
// Using STRONG error signal (TS error) to verify undefined exitCode doesn't clear
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "bash",
sessionID: "test-session-2",
args: { command: "rtk cat ~/.local/share/opencode-working-memory/session.json" },
},
{
// exitCode deliberately absent (undefined)
// Even with TS error in output, should NOT clear existing error
output: "src/other.ts(5,10): error TS2794: Expected 0 arguments, but got 1",
}
);
// 6. Assert: pre-existing open error is PRESERVED
const state = await loadSessionState(tmpDir, "test-session-2");
assert.equal(state.openErrors.length, 1,
"exitCode === undefined must not clear pre-existing open errors");
assert.equal(state.openErrors[0].fingerprint, "ee7b3f9a1c2d",
"The original open error must remain intact");
} finally {
// Cleanup
await rm(tmpDir, { recursive: true, force: true });
}
});
test("tool.execute.after: exitCode 0 clears errors for same category", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
// Pre-populate session with a typecheck error
const preExistingError: OpenError = {
id: "err_test",
category: "typecheck",
summary: "TS2345: some error",
command: "npm run typecheck",
fingerprint: "abc123",
status: "open",
firstSeen: Date.now() - 3600000,
lastSeen: Date.now() - 3600000,
seenCount: 1,
};
await saveSessionState(tmpDir, createSessionWithError("test-session-3", preExistingError));
const client = mockRootClient();
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
// Simulate successful typecheck (exitCode 0)
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "bash",
sessionID: "test-session-3",
args: { command: "npm run typecheck" },
},
{
exitCode: 0,
output: "",
}
);
const state = await loadSessionState(tmpDir, "test-session-3");
assert.equal(state.openErrors.length, 0,
"exitCode 0 should clear typecheck errors");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("tool.execute.after: exitCode non-zero creates open error", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const client = mockRootClient();
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
// Simulate failed typecheck (exitCode 1)
await (plugin as Record<string, Function>)["tool.execute.after"](
{
tool: "bash",
sessionID: "test-session-4",
args: { command: "npm run typecheck" },
},
{
exitCode: 1,
output: "src/index.ts(10,3): error TS2345: Argument of type 'string' is not assignable",
}
);
const state = await loadSessionState(tmpDir, "test-session-4");
assert.equal(state.openErrors.length, 1,
"exitCode non-zero should create open error");
assert.equal(state.openErrors[0].category, "typecheck");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
+210
View File
@@ -0,0 +1,210 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
import { renderWorkspaceMemory, enforceLongTermLimits } from "../src/workspace-memory.ts";
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
const now = new Date().toISOString();
return {
id,
type,
text,
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
};
}
// ============================================
// Task 2: renderWorkspaceMemory tests
// ============================================
test("renderWorkspaceMemory never truncates closing XML tag", () => {
const entries = Array.from({ length: 28 }, (_, i) =>
entry(`mem_${i}`, `Long durable memory entry ${i} `.repeat(20))
);
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 700, maxEntries: 28 },
entries,
updatedAt: new Date().toISOString(),
};
const rendered = renderWorkspaceMemory(store);
assert.ok(rendered.endsWith("</workspace_memory>"),
`Rendered memory must end with closing tag. Got: ...${rendered.slice(-50)}`);
assert.ok(rendered.length <= 700,
`Rendered memory must not exceed maxChars. Got: ${rendered.length}`);
});
test("renderWorkspaceMemory returns empty string when maxChars too small", () => {
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 50, maxEntries: 28 },
entries: [entry("test", "test memory")],
updatedAt: new Date().toISOString(),
};
const rendered = renderWorkspaceMemory(store);
assert.equal(rendered, "",
"When maxChars too small for even minimal envelope, return empty string");
});
test("renderWorkspaceMemory respects budget and fits entries", () => {
// Create entries that would overflow a small budget
const entries = [
entry("a", "First memory entry that is reasonably long"),
entry("b", "Second memory entry that is also reasonably long"),
entry("c", "Third memory entry that is also reasonably long"),
];
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 200, maxEntries: 28 },
entries,
updatedAt: new Date().toISOString(),
};
const rendered = renderWorkspaceMemory(store);
assert.ok(rendered.endsWith("</workspace_memory>"),
"Must end with closing tag even when truncating entries");
assert.ok(rendered.length <= 200,
`Must respect maxChars limit. Got: ${rendered.length}`);
});
test("renderWorkspaceMemory returns empty for no entries", () => {
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root: "/repo", key: "abc" },
limits: { maxRenderedChars: 5200, maxEntries: 28 },
entries: [],
updatedAt: new Date().toISOString(),
};
const rendered = renderWorkspaceMemory(store);
assert.equal(rendered, "");
});
// ============================================
// PR-2 Task 5 tests (for enforceLongTermLimits)
// ============================================
test("enforceLongTermLimits dedupes with canonical text", () => {
const now = new Date().toISOString();
const a: LongTermMemoryEntry = {
id: "a",
type: "decision",
text: "OpenCode uses NPM CACHE for plugin loading",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: now,
updatedAt: now,
};
const b: LongTermMemoryEntry = {
id: "b",
type: "decision",
text: "opencode uses npm cache for plugin loading!!!",
source: "compaction",
confidence: 0.8,
status: "active",
createdAt: now,
updatedAt: now,
};
const kept = enforceLongTermLimits([a, b]);
assert.equal(kept.length, 1, "Should dedupe similar texts");
assert.equal(kept[0].confidence, 0.8, "Higher confidence should win for same source");
});
test("enforceLongTermLimits preserves explicit over compaction", () => {
const now = new Date().toISOString();
const explicit: LongTermMemoryEntry = {
id: "explicit",
type: "decision",
text: "Use pnpm for this project",
source: "explicit",
confidence: 0.5,
status: "active",
createdAt: now,
updatedAt: now,
};
const compaction: LongTermMemoryEntry = {
id: "compaction",
type: "decision",
text: "Use pnpm for this project",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: now,
updatedAt: now,
};
const kept = enforceLongTermLimits([explicit, compaction]);
assert.equal(kept.length, 1);
assert.equal(kept[0].source, "explicit",
"Explicit source should win over compaction even with lower confidence");
assert.equal(kept[0].confidence, 0.5, "Original explicit confidence preserved");
});
test("enforceLongTermLimits same source higher confidence wins", () => {
const now = new Date().toISOString();
const a: LongTermMemoryEntry = {
id: "a",
type: "decision",
text: "Project uses TypeScript",
source: "compaction",
confidence: 0.7,
status: "active",
createdAt: now,
updatedAt: now,
};
const b: LongTermMemoryEntry = {
id: "b",
type: "decision",
text: "Project uses TypeScript",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: now,
updatedAt: now,
};
const kept = enforceLongTermLimits([a, b]);
assert.equal(kept.length, 1);
assert.equal(kept[0].confidence, 0.9, "Higher confidence wins for same source");
});
test("enforceLongTermLimits respects maxEntries limit", () => {
const now = new Date().toISOString();
const entries = Array.from({ length: 50 }, (_, i) => ({
id: `mem_${i}`,
type: "decision" as const,
text: `Unique memory entry number ${i}`,
source: "compaction" as const,
confidence: 0.75,
status: "active" as const,
createdAt: now,
updatedAt: now,
}));
const kept = enforceLongTermLimits(entries);
assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`);
});
+4 -2
View File
@@ -21,8 +21,10 @@
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["index.ts"],
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}