mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22774c5ed2 | |||
| 9892012d8b | |||
| f988af4453 | |||
| 606dcfac12 | |||
| 802ef62636 | |||
| ff4639d153 | |||
| 1bba0511bb | |||
| 2d7cb6cdf4 | |||
| 9f9763c0e1 | |||
| df54232fb9 | |||
| 72dc919ece |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -3,32 +3,28 @@
|
||||
[](https://www.npmjs.com/package/opencode-working-memory)
|
||||
[](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**
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
+13
-3
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user