mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6603fe869d | |||
| 3d44269228 | |||
| a154139b27 | |||
| 7527765207 | |||
| f9acfd6136 | |||
| ca71c20a8f | |||
| 5e9ada6859 | |||
| 721544e7a8 | |||
| 32fa2bd454 | |||
| af539a42f3 | |||
| eff0d3784c | |||
| 2354b62350 | |||
| 92e90124de | |||
| 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,157 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.2.1 (2026-04-26)
|
||||
|
||||
### Compaction Memory Quality — Four-Layer Defense
|
||||
|
||||
This release addresses systemic quality issues in workspace memory: duplicates, stale entries, and silently lost memory candidates. A four-layer defense is now in place:
|
||||
|
||||
```
|
||||
Prompt → Durable-content guidance keeps LLM on factual memories
|
||||
Parser → Accepts bracketless format, filters session snapshots
|
||||
Storage → Entity-key dedup + topic supersession + source priority
|
||||
Staleness → Age-based pruning of obsolete compaction/manual entries
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Self-cleaning memory**: Entity-key deduplication, topic supersession, and age-based staleness pruning automatically maintain memory quality
|
||||
- **Robust parser**: Accepts both bracketless (`- type text`) and bracketed (`- [type] text`) formats — no more silently lost memories
|
||||
- **Durable-content prompt**: Compaction template now guides LLM toward factual, long-lived memories while explicitly discouraging session ephemera
|
||||
- **Smart snapshot filtering**: Automatically rejects project-type snapshots (file counts, test counts, Phase progress) that don't belong in long-term memory
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bracketless format bug**: Parser regex only matched `- [type]` pattern; real LLM output often uses `- type` (no brackets). Both formats now accepted. (P0a)
|
||||
- **Purple/italic text in OpenCode UI**: Replaced XML/HTML comment templates with clean Markdown headings. Further hardened with negative instructions to forbid YAML frontmatter. (P0b β)
|
||||
- **Session snapshots polluting memory**: Project entries like "37 個文件", "26 tests pass", "Phase 2 completed" now rejected by parser filter. (P0c)
|
||||
- **Duplicate entries**: Entities deduped by key (e.g., `opencode-agenthub plugin system`). Topic conflicts resolved via supersession: newer shorter facts beat older verbose ones for decisions/feedback. (P0d)
|
||||
- **Stale entries never cleaned**: Compaction/manual entries with `staleAfterDays` now auto-pruned after 30-day grace period.
|
||||
- **Short reference entries rejected**: Admin PIN (`456123`) and config values (`Scrypt n=32768`) now allowed through config value allowlist despite being under 20 chars.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chooseBetterMemory`**: Now accepts `"entity"` mode (length preferred, for project/reference) and `"supersession"` mode (freshness preferred, for decision/feedback).
|
||||
- **Source priority in sort**: Manual/source priority now included as secondary sort tie-breaker after entry priority.
|
||||
|
||||
### Technical Details
|
||||
|
||||
- **Parser formats**: 4 accepted (plain text label primary, plus Markdown section, legacy section, legacy XML)
|
||||
- **Chinese counter words**: Regex matches `個`/`个` between numbers and nouns (e.g., `37 個文件`)
|
||||
- **Entity keys cautious**: Only known product keys extracted (`opencode-agenthub`); generic config references fall back to canonical text dedup
|
||||
|
||||
### Tests
|
||||
|
||||
- **70/70 tests pass** (24 workspace-memory, 34 extractors, 12 plugin)
|
||||
|
||||
---
|
||||
|
||||
## 1.2.0 (2026-04-26)
|
||||
|
||||
### Memory V2 Architecture
|
||||
|
||||
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
|
||||
@@ -0,0 +1,976 @@
|
||||
# Memory V2 Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the current heavy four-tier memory plugin with a low-token, no-extra-agent-call memory system that provides workspace-scoped long-term memory and session hot state.
|
||||
|
||||
**Architecture:** Implement three layers: stable workspace memory, hot session state, and native OpenCode state integration. Workspace memory is frozen per session and refreshed at compaction boundaries; hot session state tracks active files and unresolved blocking errors automatically from tool events; OpenCode todos remain owned by OpenCode and are only read during compaction.
|
||||
|
||||
**Tech Stack:** TypeScript, OpenCode Plugin hooks, Node/Bun file APIs, JSON sidecar storage under user data directory, TypeScript typecheck via `npm run typecheck`.
|
||||
|
||||
---
|
||||
|
||||
## Design Summary
|
||||
|
||||
### What changes
|
||||
|
||||
- Remove default agent-visible memory tools from the normal flow.
|
||||
- Remove raw tool-output cache and pressure-monitor intervention from the core path.
|
||||
- Add workspace-scoped long-term memory that persists across sessions but does not cross workspaces.
|
||||
- Add hot session state that is fully automatic and tiny: active files, open blocking errors, and recent decisions for compaction only.
|
||||
- Reuse OpenCode compaction to extract long-term memory candidates with no extra LLM call.
|
||||
- Read OpenCode todos during compaction instead of duplicating todo storage.
|
||||
|
||||
### What stays out of memory
|
||||
|
||||
- Long-term memory does **not** save file lists, stack traces, code signatures, API docs, git history, architecture snapshots, or temporary task progress.
|
||||
- Short-term memory does **not** save todos or dependency facts because OpenCode and project files already own those.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Current project has a single `index.ts`. This plan splits memory behavior into focused modules while keeping `index.ts` as the plugin entrypoint.
|
||||
|
||||
### Create
|
||||
|
||||
- `src/paths.ts` — computes workspace-scoped storage paths under user data directory.
|
||||
- `src/storage.ts` — atomic JSON read/write helpers with safe defaults.
|
||||
- `src/types.ts` — canonical schemas and constants for long-term memory and session state.
|
||||
- `src/workspace-memory.ts` — load/save/merge/render long-term workspace memory.
|
||||
- `src/session-state.ts` — load/save/update/render active files, open errors, recent decisions.
|
||||
- `src/extractors.ts` — deterministic extraction from user messages, tool args, bash output, and compaction summaries.
|
||||
- `src/opencode.ts` — thin wrappers around OpenCode SDK calls for latest user messages, summaries, and todos.
|
||||
- `src/plugin.ts` — hook orchestration.
|
||||
- `tests/extractors.test.ts` — unit tests for deterministic extraction.
|
||||
- `tests/workspace-memory.test.ts` — unit tests for merge, dedupe, limits, staleness rendering.
|
||||
- `tests/session-state.test.ts` — unit tests for active files and error lifecycle.
|
||||
|
||||
### Modify
|
||||
|
||||
- `index.ts` — replace monolithic implementation with `export { default } from "./src/plugin";`.
|
||||
- `package.json` — add a test script using Node’s built-in test runner or Bun test depending available runtime.
|
||||
- `README.md` — update feature description from four-tier memory to Memory V2.
|
||||
- `docs/architecture.md` — replace stale four-tier docs with three-layer design.
|
||||
- `docs/configuration.md` — document limits and optional debug tools.
|
||||
- `AGENTS.md` — update development guide, storage paths, and testing commands.
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — Storage, Types, and Deterministic Core
|
||||
|
||||
### Task 1: Add canonical types and limits
|
||||
|
||||
**Files:**
|
||||
- Create: `src/types.ts`
|
||||
|
||||
- [ ] **Step 1: Create memory and session schemas**
|
||||
|
||||
Add this file:
|
||||
|
||||
```ts
|
||||
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;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
|
||||
Expected: PASS or existing unrelated failures only. Since file is not imported yet, it should not introduce errors.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add workspace-scoped paths and atomic storage
|
||||
|
||||
**Files:**
|
||||
- Create: `src/paths.ts`
|
||||
- Create: `src/storage.ts`
|
||||
|
||||
- [ ] **Step 1: Create `src/paths.ts`**
|
||||
|
||||
```ts
|
||||
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> {
|
||||
return join(await memoryRoot(root), "sessions", `${sessionID}.json`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `src/storage.ts`**
|
||||
|
||||
```ts
|
||||
import { existsSync } from "fs";
|
||||
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
|
||||
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()}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
|
||||
await rename(tmp, path);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add extractor tests before implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/extractors.test.ts`
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Add test script**
|
||||
|
||||
Modify `package.json` scripts:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing tests**
|
||||
|
||||
Create `tests/extractors.test.ts`:
|
||||
|
||||
```ts
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
extractExplicitMemories,
|
||||
extractActiveFiles,
|
||||
extractErrorsFromBash,
|
||||
parseWorkspaceMemoryCandidates,
|
||||
} from "../src/extractors.ts";
|
||||
|
||||
test("extractExplicitMemories captures clear remember instruction", () => {
|
||||
const items = extractExplicitMemories("请记住:这个 workspace 的 memory 功能必须默认无感");
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].type, "feedback");
|
||||
assert.match(items[0].text, /默认无感/);
|
||||
});
|
||||
|
||||
test("extractExplicitMemories avoids casual negative commands", () => {
|
||||
assert.equal(extractExplicitMemories("不要吃这个").length, 0);
|
||||
assert.equal(extractExplicitMemories("以后再说").length, 0);
|
||||
});
|
||||
|
||||
test("extractActiveFiles uses tool args before output", () => {
|
||||
assert.deepEqual(extractActiveFiles("read", { filePath: "/repo/index.ts" }, "random content"), [
|
||||
{ path: "/repo/index.ts", action: "read" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("extractErrorsFromBash captures typecheck failure", () => {
|
||||
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");
|
||||
assert.match(errors[0].summary, /TS2345/);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates parses compaction block", () => {
|
||||
const entries = parseWorkspaceMemoryCandidates(`summary
|
||||
<workspace_memory_candidates>
|
||||
- [decision] Use JSON as canonical storage because it is easier to validate.
|
||||
- [reference] External design notes are in Notion.
|
||||
</workspace_memory_candidates>`);
|
||||
assert.equal(entries.length, 2);
|
||||
assert.equal(entries[0].type, "decision");
|
||||
assert.equal(entries[1].type, "reference");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests and confirm failure**
|
||||
|
||||
Run: `npm test`
|
||||
|
||||
Expected: FAIL because `src/extractors.ts` does not exist.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Implement deterministic extractors
|
||||
|
||||
**Files:**
|
||||
- Create: `src/extractors.ts`
|
||||
|
||||
- [ ] **Step 1: Add extractor implementation**
|
||||
|
||||
```ts
|
||||
import { createHash } from "crypto";
|
||||
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types";
|
||||
import { LONG_TERM_LIMITS } from "./types";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
const patterns = [
|
||||
/(?:请记住|記住|记住这一点|remember this|commit to memory)[::]?\s*(.+)$/im,
|
||||
/(?:从现在开始|從現在開始|从今以后|從今以後|from now on|always)[::]?\s*(.+)$/im,
|
||||
];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
const body = match?.[1]?.trim();
|
||||
if (!body || body.length < 8) continue;
|
||||
if (/^(再说|再說|later|next time)$/i.test(body)) continue;
|
||||
|
||||
entries.push({
|
||||
id: id("mem"),
|
||||
type: classifyExplicitMemory(body),
|
||||
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
staleAfterDays: staleAfterDaysFor(classifyExplicitMemory(body)),
|
||||
});
|
||||
}
|
||||
|
||||
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|workspace|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(/^(\/[^
|
||||
return [...new Set(matches.map(match => match.replace(/:$/, "")))].slice(0, 10);
|
||||
}
|
||||
|
||||
export function extractErrorsFromBash(command: string, output: string): OpenError[] {
|
||||
const lines = output.split("\n").filter(line => /error|failed|failure|exception|TS\d{4}|ERR!/i.test(line)).slice(0, 5);
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const category = classifyCommand(command) ?? "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];
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run extractor tests**
|
||||
|
||||
Run: `npm test`
|
||||
|
||||
Expected: PASS for extractor tests.
|
||||
|
||||
---
|
||||
|
||||
### Wave 1 verification checkpoint
|
||||
|
||||
- [ ] **Step 1: Run all checks**
|
||||
|
||||
Run: `npm test && npm run typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Review wave output**
|
||||
|
||||
Confirm: Types, paths, storage helpers, and deterministic extractors exist and tests cover clear remember, false positives, active files, bash errors, and compaction candidates.
|
||||
|
||||
- [ ] **Step 3: Commit wave**
|
||||
|
||||
```bash
|
||||
git add package.json src tests
|
||||
git commit -m "refactor: add memory v2 core primitives"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — Workspace Memory and Hot Session State
|
||||
|
||||
### Task 5: Implement workspace memory store
|
||||
|
||||
**Files:**
|
||||
- Create: `src/workspace-memory.ts`
|
||||
- Test: `tests/workspace-memory.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `tests/workspace-memory.test.ts`:
|
||||
|
||||
```ts
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { LongTermMemoryEntry } from "../src/types.ts";
|
||||
import { enforceLongTermLimits, renderWorkspaceMemory } from "../src/workspace-memory.ts";
|
||||
|
||||
function entry(text: string, type: LongTermMemoryEntry["type"] = "feedback"): LongTermMemoryEntry {
|
||||
const now = new Date().toISOString();
|
||||
return { id: text, type, text, source: "explicit", confidence: 1, status: "active", createdAt: now, updatedAt: now };
|
||||
}
|
||||
|
||||
test("enforceLongTermLimits dedupes entries", () => {
|
||||
const kept = enforceLongTermLimits([entry("Memory must be invisible"), entry("Memory must be invisible")]);
|
||||
assert.equal(kept.length, 1);
|
||||
});
|
||||
|
||||
test("renderWorkspaceMemory includes verify marker for stale decisions", () => {
|
||||
const old = entry("Use JSON storage", "decision");
|
||||
old.createdAt = "2020-01-01T00:00:00.000Z";
|
||||
old.staleAfterDays = 45;
|
||||
const rendered = renderWorkspaceMemory({ version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: 5200, maxEntries: 28 }, entries: [old], updatedAt: old.createdAt });
|
||||
assert.match(rendered, /verify/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement workspace memory functions**
|
||||
|
||||
Create `src/workspace-memory.ts` with:
|
||||
|
||||
```ts
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types";
|
||||
import { LONG_TERM_LIMITS } from "./types";
|
||||
import { workspaceKey, workspaceMemoryPath } from "./paths";
|
||||
import { atomicWriteJSON, readJSON } from "./storage";
|
||||
|
||||
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> {
|
||||
return readJSON(await workspaceMemoryPath(root), () => ({
|
||||
version: 1,
|
||||
workspace: { root, key: "unknown" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
|
||||
store.workspace = { root, key: await workspaceKey(root) };
|
||||
store.entries = enforceLongTermLimits(store.entries);
|
||||
store.updatedAt = new Date().toISOString();
|
||||
await atomicWriteJSON(await workspaceMemoryPath(root), store);
|
||||
}
|
||||
|
||||
export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
const byKey = new Map<string, LongTermMemoryEntry>();
|
||||
for (const entry of entries.filter(e => e.status === "active")) {
|
||||
const text = entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars);
|
||||
const key = `${entry.type}:${text.toLowerCase().replace(/\s+/g, " ").trim()}`;
|
||||
const existing = byKey.get(key);
|
||||
if (!existing || entry.source === "explicit") 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 type = { feedback: 400, decision: 300, project: 200, reference: 100 }[entry.type];
|
||||
const source = entry.source === "explicit" ? 1000 : 0;
|
||||
return source + type + entry.confidence * 10;
|
||||
}
|
||||
|
||||
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
|
||||
const active = enforceLongTermLimits(store.entries);
|
||||
if (active.length === 0) return "";
|
||||
const lines = [
|
||||
"<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(e => e.type === type);
|
||||
if (items.length === 0) continue;
|
||||
lines.push(`${type}:`);
|
||||
for (const item of items) lines.push(`- ${renderEntry(item)}`);
|
||||
}
|
||||
lines.push("</workspace_memory>");
|
||||
return lines.join("\n").slice(0, store.limits.maxRenderedChars);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `npm test`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Implement session state lifecycle
|
||||
|
||||
**Files:**
|
||||
- Create: `src/session-state.ts`
|
||||
- Test: `tests/session-state.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `tests/session-state.test.ts`:
|
||||
|
||||
```ts
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createEmptySessionState, touchActiveFile, upsertOpenError, clearErrorsForSuccessfulCommand, renderHotSessionState } from "../src/session-state.ts";
|
||||
import type { OpenError } from "../src/types.ts";
|
||||
|
||||
test("touchActiveFile weights edits above reads", () => {
|
||||
const state = createEmptySessionState("s1");
|
||||
touchActiveFile(state, "/repo/a.ts", "read");
|
||||
touchActiveFile(state, "/repo/b.ts", "edit");
|
||||
assert.equal(state.activeFiles[0].path, "/repo/b.ts");
|
||||
});
|
||||
|
||||
test("clearErrorsForSuccessfulCommand clears category", () => {
|
||||
const state = createEmptySessionState("s1");
|
||||
const err: OpenError = { id: "e", category: "typecheck", summary: "TS error", fingerprint: "f", status: "open", firstSeen: 1, lastSeen: 1, seenCount: 1 };
|
||||
upsertOpenError(state, err);
|
||||
clearErrorsForSuccessfulCommand(state, "npm run typecheck");
|
||||
assert.equal(state.openErrors.length, 0);
|
||||
});
|
||||
|
||||
test("renderHotSessionState includes active files and open errors", () => {
|
||||
const state = createEmptySessionState("s1");
|
||||
touchActiveFile(state, "/repo/index.ts", "edit");
|
||||
upsertOpenError(state, { id: "e", category: "test", summary: "test failed", fingerprint: "f", status: "open", firstSeen: 1, lastSeen: 1, seenCount: 1 });
|
||||
const rendered = renderHotSessionState(state, "/repo");
|
||||
assert.match(rendered, /index.ts/);
|
||||
assert.match(rendered, /test failed/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement session state functions**
|
||||
|
||||
Create `src/session-state.ts` with create/load/save/touch/upsert/clear/render functions matching the tests.
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `npm test`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Wave 2 verification checkpoint
|
||||
|
||||
- [ ] **Step 1: Run all checks**
|
||||
|
||||
Run: `npm test && npm run typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Review wave output**
|
||||
|
||||
Confirm: Long-term store enforces limits and renders staleness. Hot session state ranks active files, stores open errors, and clears category errors on successful validation commands.
|
||||
|
||||
- [ ] **Step 3: Commit wave**
|
||||
|
||||
```bash
|
||||
git add src tests
|
||||
git commit -m "feat: add workspace memory and hot session state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wave 3 — Plugin Hook Integration
|
||||
|
||||
### Task 7: Wire OpenCode helper functions
|
||||
|
||||
**Files:**
|
||||
- Create: `src/opencode.ts`
|
||||
|
||||
- [ ] **Step 1: Add SDK wrappers**
|
||||
|
||||
Create `src/opencode.ts` with helpers:
|
||||
|
||||
```ts
|
||||
export async function latestUserText(client: any, sessionID: string): Promise<{ id: string; text: string } | null> {
|
||||
const result = await client.session.messages({ path: { id: sessionID } });
|
||||
const messages = result.data ?? [];
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.info?.role !== "user") continue;
|
||||
const text = msg.parts?.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") ?? "";
|
||||
if (text.trim()) return { id: msg.info.id, text };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function latestCompactionSummary(client: any, sessionID: string): Promise<string | null> {
|
||||
const result = await client.session.messages({ path: { id: sessionID } });
|
||||
const messages = result.data ?? [];
|
||||
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: any) => p.type === "text").map((p: any) => p.text).join("\n") ?? "";
|
||||
if (text.trim()) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function pendingTodos(client: any, sessionID: string): Promise<Array<{ content: string; status: string; priority?: string }>> {
|
||||
try {
|
||||
const result = await client.session.todo({ path: { id: sessionID } });
|
||||
return (result.data ?? []).filter((todo: any) => todo.status !== "completed");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Implement plugin orchestration
|
||||
|
||||
**Files:**
|
||||
- Create: `src/plugin.ts`
|
||||
- Modify: `index.ts`
|
||||
|
||||
- [ ] **Step 1: Replace `index.ts` entrypoint**
|
||||
|
||||
```ts
|
||||
export { default } from "./src/plugin";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement hooks in `src/plugin.ts`**
|
||||
|
||||
Create plugin that:
|
||||
|
||||
- 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
|
||||
- updates session state after tools
|
||||
- augments compaction context with memory, hot state, todos, and memory candidate instruction
|
||||
- parses compaction summaries from `session.compacted` event and merges candidates
|
||||
|
||||
The compaction instruction must be:
|
||||
|
||||
```ts
|
||||
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();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Wave 3 verification checkpoint
|
||||
|
||||
- [ ] **Step 1: Run all checks**
|
||||
|
||||
Run: `npm test && npm run typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Manual plugin smoke test**
|
||||
|
||||
Run OpenCode with local plugin and verify:
|
||||
|
||||
- user message `请记住:这个 workspace 的 memory 功能要默认无感` creates a long-term entry
|
||||
- reading/editing files updates hot session state
|
||||
- failed typecheck creates an open error
|
||||
- successful typecheck clears typecheck errors
|
||||
|
||||
- [ ] **Step 3: Commit wave**
|
||||
|
||||
```bash
|
||||
git add index.ts src tests
|
||||
git commit -m "feat: wire memory v2 plugin hooks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wave 4 — Documentation and Migration
|
||||
|
||||
### Task 9: Update documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture.md`
|
||||
- Modify: `docs/configuration.md`
|
||||
- Modify: `AGENTS.md`
|
||||
|
||||
- [ ] **Step 1: Update README feature summary**
|
||||
|
||||
Describe Memory V2 as:
|
||||
|
||||
- workspace-scoped long-term memory
|
||||
- hot session state
|
||||
- no default agent-visible memory tools
|
||||
- no raw tool-output cache
|
||||
- compaction boundary extraction with no extra LLM call
|
||||
|
||||
- [ ] **Step 2: Update architecture doc**
|
||||
|
||||
Replace four-tier architecture with:
|
||||
|
||||
```text
|
||||
Layer 1: Stable Workspace Memory
|
||||
Layer 2: Hot Session State
|
||||
Layer 3: Native OpenCode State
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update configuration doc**
|
||||
|
||||
Document:
|
||||
|
||||
- `LONG_TERM_LIMITS`
|
||||
- `HOT_STATE_LIMITS`
|
||||
- storage root under `XDG_DATA_HOME` or `~/.local/share`
|
||||
- optional future `/memory import`
|
||||
|
||||
- [ ] **Step 4: Update AGENTS.md**
|
||||
|
||||
Update commands:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Update storage and testing guidance to match Memory V2.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Remove obsolete implementation paths
|
||||
|
||||
**Files:**
|
||||
- Modify: `index.ts` if old code remains
|
||||
- Modify: docs references if any still mention old APIs
|
||||
|
||||
- [ ] **Step 1: Remove obsolete references**
|
||||
|
||||
Ensure repo no longer advertises default tools:
|
||||
|
||||
- `core_memory_update`
|
||||
- `core_memory_read`
|
||||
- `working_memory_add`
|
||||
- `working_memory_clear`
|
||||
- `working_memory_clear_slot`
|
||||
- `working_memory_remove`
|
||||
|
||||
Unless a debug-only compatibility layer is explicitly retained, these names must not appear in README or architecture docs.
|
||||
|
||||
- [ ] **Step 2: Remove obsolete concepts from docs**
|
||||
|
||||
Remove or mark deprecated:
|
||||
|
||||
- slots/pool/decay
|
||||
- pressure monitor as core feature
|
||||
- raw tool-output cache
|
||||
- smart pruning replacing old tool outputs
|
||||
|
||||
- [ ] **Step 3: Run docs grep**
|
||||
|
||||
Run: `grep -R "core_memory_update\|working_memory_add\|pressure monitor\|tool-output cache" README.md docs AGENTS.md`
|
||||
|
||||
Expected: no matches, or matches only under a clearly marked migration note.
|
||||
|
||||
---
|
||||
|
||||
### Wave 4 verification checkpoint
|
||||
|
||||
- [ ] **Step 1: Run all checks**
|
||||
|
||||
Run: `npm test && npm run typecheck`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Verify docs match code**
|
||||
|
||||
Confirm: README, architecture, configuration, and AGENTS describe Memory V2 and do not promise old tools or old four-tier behavior.
|
||||
|
||||
- [ ] **Step 3: Commit wave**
|
||||
|
||||
```bash
|
||||
git add README.md docs AGENTS.md index.ts src tests package.json
|
||||
git commit -m "docs: document memory v2 design"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Automated
|
||||
|
||||
- `npm test` validates extractors, long-term merge/render, and hot session lifecycle.
|
||||
- `npm run typecheck` validates TypeScript imports and plugin entrypoint.
|
||||
|
||||
### Manual OpenCode smoke tests
|
||||
|
||||
1. Start a session with the plugin enabled.
|
||||
2. Send: `请记住:这个 workspace 的 memory 功能要默认无感`.
|
||||
3. Confirm `workspace-memory.json` is written under `~/.local/share/opencode-working-memory/workspaces/<hash>/`.
|
||||
4. Read and edit a file.
|
||||
5. Confirm session state active files update.
|
||||
6. Run a failing typecheck command.
|
||||
7. Confirm open error appears in hot state.
|
||||
8. Run a passing typecheck command.
|
||||
9. Confirm typecheck error clears.
|
||||
10. Trigger or simulate compaction.
|
||||
11. Confirm compaction context includes memory candidate instruction and parsed candidates merge after compaction.
|
||||
|
||||
---
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- **False memory extraction:** explicit regex only matches strong remember/from-now-on phrasing; compaction extraction uses explicit “what not to save” boundaries.
|
||||
- **Token overhead:** no background LLM agent; compaction extraction piggybacks existing compaction call; hot state capped at 1200 chars.
|
||||
- **Stale memory:** decision/project/reference entries have stale markers during render.
|
||||
- **Privacy:** storage lives in user data directory, not repo, and writes with `0600` mode.
|
||||
- **Duplicate todo state:** todos are not stored by the plugin; OpenCode remains source of truth.
|
||||
- **Error staleness:** errors clear only after successful validation commands and become `maybe_fixed` after related edits.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: plan implements workspace-scoped cross-session memory, bounded long-term memory, compaction-boundary update, fully automatic hot session memory, and no extra LLM calls.
|
||||
- Placeholder scan: plan contains no TBD/TODO placeholders; Tasks 8-10 reference exact expected behavior and code boundaries.
|
||||
- Type consistency: `LongTermMemoryEntry`, `WorkspaceMemoryStore`, `SessionState`, `ActiveFile`, `OpenError`, and `SessionDecision` are defined once in Task 1 and reused consistently.
|
||||
- Wave coherence: each wave ends with tests/typecheck and a committable checkpoint.
|
||||
@@ -0,0 +1,815 @@
|
||||
# Memory Deduplication and Staleness Analysis
|
||||
|
||||
Date: 2026-04-26
|
||||
|
||||
## Executive recommendation
|
||||
|
||||
Fix this at storage time first, then tighten ingestion prompts.
|
||||
|
||||
Storage is the safety net. Every memory entry, whether from compaction, explicit user instruction, or future manual editing, already flows through `normalizeWorkspaceMemory()` in `src/workspace-memory.ts`. That is the right architectural choke point for deduplication, supersession, and lifecycle pruning.
|
||||
|
||||
Prompt changes are still useful, but only as a quality reducer. They cannot be the source of truth because model output will drift, multilingual phrasing will vary, and old stores already contain bad entries.
|
||||
|
||||
Do not add embeddings yet. This repo has 22 entries, a limit of 28, and all current failures are simple lexical/category problems. Embeddings would add latency, dependencies, nondeterminism, and storage shape questions for a problem that can be solved with boring code.
|
||||
|
||||
## Current data flow
|
||||
|
||||
```text
|
||||
OpenCode session.compacted event
|
||||
│
|
||||
▼
|
||||
latestCompactionSummary(client, sessionID)
|
||||
│
|
||||
▼
|
||||
parseWorkspaceMemoryCandidates(summary)
|
||||
│ src/extractors.ts
|
||||
│ - validates shape and basic quality
|
||||
│ - assigns type/source/confidence/staleAfterDays
|
||||
▼
|
||||
updateWorkspaceMemory(directory, store => {
|
||||
store.entries.push(...candidates)
|
||||
})
|
||||
│
|
||||
▼
|
||||
normalizeWorkspaceMemory(root, store)
|
||||
│ src/workspace-memory.ts
|
||||
│ - exact canonical dedupe only
|
||||
│ - maxEntries trim
|
||||
▼
|
||||
workspace-memory.json
|
||||
```
|
||||
|
||||
The broken boundary is clear: ingestion appends all candidates, and normalization only dedupes exact normalized text per type.
|
||||
|
||||
## Problem 1: near-duplicate accumulation
|
||||
|
||||
### Diagnosis
|
||||
|
||||
`canonicalMemoryText()` catches only exact matches after NFKC, lowercase, and punctuation/whitespace collapse. It does not catch:
|
||||
|
||||
- same fact with extra location detail
|
||||
- same path with slightly different label text
|
||||
- same decision revised from version 3 to version 4
|
||||
- bilingual restatements of the same project fact
|
||||
- new fix superseding an older fix for the same issue
|
||||
|
||||
This is not one dedupe problem. It is three different classes wearing the same hat.
|
||||
|
||||
```text
|
||||
Near duplicate classes
|
||||
────────────────────────────────────────────
|
||||
project/reference → entity identity problem
|
||||
feedback → topic preference/result problem
|
||||
decision → supersession/history problem
|
||||
```
|
||||
|
||||
Treating all of these with one fuzzy text threshold will either miss real duplicates or delete useful distinct decisions.
|
||||
|
||||
### Ingestion time vs storage time
|
||||
|
||||
Use both, with different jobs.
|
||||
|
||||
#### Storage time, required
|
||||
|
||||
Add deterministic memory normalization in `src/workspace-memory.ts`:
|
||||
|
||||
1. exact canonical dedupe, keep existing behavior
|
||||
2. type-specific identity keys for obvious entities
|
||||
3. simple lexical similarity for same-type candidates
|
||||
4. explicit supersession rules for versioned/solution-style decisions
|
||||
5. lifecycle pruning before `maxEntries` trim
|
||||
|
||||
Why storage first:
|
||||
|
||||
- one code path for compaction, explicit, manual, and tests
|
||||
- fixes existing stores on next load/save
|
||||
- deterministic and unit-testable
|
||||
- does not depend on model behavior
|
||||
|
||||
#### Ingestion time, useful but secondary
|
||||
|
||||
Improve `buildCompactionPrompt()` in `src/plugin.ts` so compaction receives existing memory and is told to emit only new or replacing facts.
|
||||
|
||||
The current prompt already passes rendered workspace memory as background context and says "Do not output this context verbatim." That is not strong enough. Add a small rule near `Memory candidates:`:
|
||||
|
||||
```text
|
||||
Before emitting a memory candidate, compare it to Background context.
|
||||
Do not emit a candidate that repeats an existing memory.
|
||||
If a new candidate replaces an older one, write only the newer statement.
|
||||
Prefer one canonical statement per project fact, reference path, user feedback topic, or implementation decision.
|
||||
```
|
||||
|
||||
This will reduce noise. It will not eliminate it. Models repeat themselves. Software should expect this.
|
||||
|
||||
### Recommended deduplication strategy
|
||||
|
||||
Use deterministic, type-aware dedupe. Avoid embeddings. Avoid global fuzzy dedupe as the main rule.
|
||||
|
||||
#### 1. Keep exact canonical dedupe
|
||||
|
||||
Current logic is good as the first pass.
|
||||
|
||||
```ts
|
||||
dedup key = `${entry.type}:${canonicalMemoryText(text)}`
|
||||
```
|
||||
|
||||
Keep source/confidence tie-breaking.
|
||||
|
||||
#### 2. Add type-specific identity extraction
|
||||
|
||||
For `project` and `reference`, dedupe by identifiable anchors, not prose.
|
||||
|
||||
Examples:
|
||||
|
||||
- repo/plugin system facts: normalized phrase key like `opencode-agenthub plugin system`
|
||||
- file paths: normalized path key, with backticks stripped
|
||||
- URLs/domains if they appear later
|
||||
|
||||
For the current data:
|
||||
|
||||
```text
|
||||
reference:path:.opencode-agenthub/current/xdg/opencode/opencode.json
|
||||
project:phrase:opencode-agenthub plugin system
|
||||
```
|
||||
|
||||
When two entries share the same identity key, merge them by keeping the more useful text:
|
||||
|
||||
1. explicit source beats manual beats compaction
|
||||
2. higher confidence beats lower confidence
|
||||
3. more specific text beats vague text, usually longer but cap this to avoid keeping rambles
|
||||
4. newer beats older if specificity/source/confidence tie
|
||||
|
||||
This directly fixes:
|
||||
|
||||
- `OpenCode plugin config location: ...` vs `OpenCode plugin config: ...`
|
||||
- Chinese and English variants that both mention `opencode-agenthub plugin system`
|
||||
|
||||
#### 3. Add conservative lexical similarity only inside same type
|
||||
|
||||
Use token Jaccard or Dice similarity over normalized tokens after stopword removal. No new dependencies.
|
||||
|
||||
Suggested thresholds:
|
||||
|
||||
```text
|
||||
project/reference: >= 0.72 duplicate
|
||||
feedback: >= 0.70 possible duplicate if same topic anchor exists
|
||||
decision: do not use fuzzy deletion by default
|
||||
```
|
||||
|
||||
This should be a fallback after identity keys, not the primary system.
|
||||
|
||||
Risk: fuzzy matching can delete nearby but distinct decisions. Example: "Markdown headers cause purple text" and "Plain text labels avoid special markup" are related but both useful in the history of the bug.
|
||||
|
||||
Keep fuzzy matching conservative and type-scoped.
|
||||
|
||||
#### 4. Use explicit supersession for decisions
|
||||
|
||||
Decision duplication is fundamentally different. Decisions often form a timeline. Some are still valuable context, some are obsolete.
|
||||
|
||||
The pair below is supersession, not duplication:
|
||||
|
||||
```text
|
||||
Parser supports 3 formats: HTML comment, Markdown section, legacy XML
|
||||
Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML
|
||||
```
|
||||
|
||||
The right model is: newer active decision supersedes older active decision on the same topic.
|
||||
|
||||
Keep this simple. Do not build a knowledge graph.
|
||||
|
||||
Add a small `decisionTopicKey(text)` heuristic:
|
||||
|
||||
```text
|
||||
parser supports <n> formats → decision:parser-supported-formats
|
||||
solution: use ... → decision:purple-italic-output-format, if text contains purple/italic/markup/markdown/xml/html/comment/label
|
||||
use output.prompt ... template → decision:compaction-template-replacement
|
||||
opencode plugin load/config facts → decision:plugin-loading-config
|
||||
```
|
||||
|
||||
That sounds bespoke, but that is acceptable here. The repo is small, the memory types are product-specific, and the current bad entries are product-specific. Boring beats clever.
|
||||
|
||||
When same decision topic appears:
|
||||
|
||||
- keep the newest active entry as active
|
||||
- optionally mark the older entry `status: "superseded"` if the type supports it, or drop it during normalization if old status values are not preserved
|
||||
- do not render superseded entries
|
||||
|
||||
If preserving history matters later, add `supersededBy?: string` and `supersededAt?: string` to the type. Not needed for the first fix.
|
||||
|
||||
### Type-specific policy
|
||||
|
||||
| Type | Nature | Recommended dedupe | Keep history? |
|
||||
|---|---|---|---|
|
||||
| `project` | stable facts about repo/system | identity key + conservative similarity | no, keep one canonical fact |
|
||||
| `reference` | pointer to path/URL/config | path/URL/entity key | no, keep one canonical pointer |
|
||||
| `feedback` | user preference or resolved issue | topic key + newer wins for same issue | usually no |
|
||||
| `decision` | implementation choice over time | topic supersession, not fuzzy duplicate deletion | sometimes, but render only active latest |
|
||||
|
||||
## Problem 2: stale entries never cleaned
|
||||
|
||||
### Diagnosis
|
||||
|
||||
`staleAfterDays` exists, but only `renderEntry()` uses it to append `[Xd old, verify]`. Nothing removes or demotes stale entries. As a result, the store is monotonic until `maxEntries` forces a priority trim.
|
||||
|
||||
That trim is the wrong cleanup mechanism. It sorts by type/source/confidence, not usefulness. A stale high-priority decision can beat a fresh low-priority reference.
|
||||
|
||||
### When to prune
|
||||
|
||||
Prune during storage normalization, not render.
|
||||
|
||||
`normalizeWorkspaceMemory()` is already called by `load/save/updateWorkspaceMemory()`. That gives one central place to enforce lifecycle rules.
|
||||
|
||||
```text
|
||||
load/update/save
|
||||
│
|
||||
▼
|
||||
normalizeWorkspaceMemory()
|
||||
│
|
||||
├─ drop inactive/superseded from active set
|
||||
├─ exact dedupe
|
||||
├─ identity dedupe
|
||||
├─ supersession
|
||||
├─ stale lifecycle pruning
|
||||
└─ maxEntries trim
|
||||
```
|
||||
|
||||
Do not prune only on render. Render is presentation. If render hides or labels stale entries while the JSON keeps growing, the system still rots.
|
||||
|
||||
Do not require explicit cleanup as the only path. It will not run often enough. An explicit cleanup command can be added later for manual inspection, but automatic normalization should handle the common case.
|
||||
|
||||
### Should `staleAfterDays` be enforced?
|
||||
|
||||
Yes, but not uniformly as immediate deletion for every type.
|
||||
|
||||
`staleAfterDays` means "this should be revalidated after this age." It does not always mean "delete at this age."
|
||||
|
||||
Use a two-tier lifecycle:
|
||||
|
||||
```text
|
||||
fresh age <= staleAfterDays
|
||||
stale staleAfterDays < age <= staleAfterDays + grace
|
||||
prunable age > staleAfterDays + grace
|
||||
```
|
||||
|
||||
Suggested grace periods:
|
||||
|
||||
| Type | Current staleAfterDays | Grace | Auto-prune? | Rationale |
|
||||
|---|---:|---:|---|---|
|
||||
| `feedback` | none | none | no age-based prune | User preference can remain valid indefinitely. Prune only by supersession/topic replacement. |
|
||||
| `decision` | 45 | 15 | yes if compaction/manual and not explicit | Implementation decisions age fast. Supersession should remove most earlier. |
|
||||
| `project` | 60 | 30 | yes if compaction/manual and no strong identity/path | Project facts change slower. Keep explicit project facts unless replaced. |
|
||||
| `reference` | 90 | 30 | yes if path no longer exists or prunable age exceeded | References are rediscoverable and can become stale. |
|
||||
|
||||
For the first implementation, a simpler rule is enough:
|
||||
|
||||
```text
|
||||
Never age-prune feedback.
|
||||
Never age-prune explicit entries automatically.
|
||||
Drop compaction/manual entries when age > staleAfterDays + 30 days.
|
||||
Drop superseded entries immediately from the active set.
|
||||
```
|
||||
|
||||
This keeps user-owned memory safe while preventing compaction sludge.
|
||||
|
||||
### Explicit vs implicit contradiction detection
|
||||
|
||||
Use explicit supersession for known memory shapes. Do not try general contradiction detection.
|
||||
|
||||
General contradiction detection without LLM or embeddings is brittle. With an LLM it is nondeterministic and adds another model-quality surface. The current problem does not need that.
|
||||
|
||||
Recommended model:
|
||||
|
||||
- explicit supersession for same decision topic, same reference path, same project entity, same feedback topic
|
||||
- newer entry wins inside the same topic unless older has higher source priority
|
||||
- if `source === "explicit"`, require a newer explicit entry to replace it, or keep both
|
||||
|
||||
This gives predictable behavior and avoids deleting user instructions because a compaction guessed a replacement.
|
||||
|
||||
## Concrete implementation plan
|
||||
|
||||
### P0: centralize deterministic cleanup in `src/workspace-memory.ts`
|
||||
|
||||
Add helpers near `canonicalMemoryText()`:
|
||||
|
||||
```text
|
||||
normalizedTokens(text)
|
||||
extractPathKeys(text)
|
||||
memoryIdentityKeys(entry)
|
||||
decisionTopicKey(text)
|
||||
feedbackTopicKey(text)
|
||||
isPrunableByAge(entry, now)
|
||||
chooseBetterMemory(existing, candidate)
|
||||
```
|
||||
|
||||
Then change `enforceLongTermLimits(entries)` to run in phases:
|
||||
|
||||
```text
|
||||
1. keep active entries only
|
||||
2. truncate text
|
||||
3. drop entries prunable by age, except feedback and explicit
|
||||
4. exact canonical dedupe
|
||||
5. identity-key dedupe for project/reference/feedback
|
||||
6. decision-topic supersession
|
||||
7. sort by priority with freshness as a tie-breaker
|
||||
8. slice to maxEntries
|
||||
```
|
||||
|
||||
Add freshness to `priority()` or to the final sort tie-breaker. Do not let 90-day-old compaction entries beat fresh entries just because type weight is higher.
|
||||
|
||||
Minimal version:
|
||||
|
||||
```text
|
||||
priority desc, source priority desc, freshness desc, updatedAt desc
|
||||
```
|
||||
|
||||
### P1: improve compaction prompt
|
||||
|
||||
Update `buildCompactionPrompt()` with dedupe instructions before the `Memory candidates:` examples.
|
||||
|
||||
Keep this short. Long prompts invite drift.
|
||||
|
||||
### P1: add tests before changing behavior
|
||||
|
||||
Use `tests/workspace-memory.test.ts` for normalization behavior.
|
||||
|
||||
Required regression tests:
|
||||
|
||||
```text
|
||||
CODE PATH COVERAGE
|
||||
==================
|
||||
[+] enforceLongTermLimits(entries)
|
||||
├── [GAP] exact canonical duplicate still dedupes
|
||||
├── [GAP] project opencode-agenthub bilingual/long-short variants collapse to one
|
||||
├── [GAP] reference same config path variants collapse to one
|
||||
├── [GAP] decision parser 4 formats supersedes parser 3 formats
|
||||
├── [GAP] feedback purple/italic newer fix supersedes older fix
|
||||
├── [GAP] stale compaction decision older than staleAfterDays + grace is pruned
|
||||
├── [GAP] stale explicit decision is retained
|
||||
└── [GAP] maxEntries trim runs after dedupe/prune
|
||||
|
||||
[+] renderWorkspaceMemory(store)
|
||||
└── [GAP] does not render superseded/pruned entries
|
||||
```
|
||||
|
||||
No E2E needed. These are pure functions and deterministic store normalization paths.
|
||||
|
||||
### P2: optional explicit cleanup command
|
||||
|
||||
Later, add a manual cleanup/report command that prints:
|
||||
|
||||
- duplicates removed
|
||||
- superseded decisions
|
||||
- stale entries pruned
|
||||
- entries retained because explicit
|
||||
|
||||
Not needed for the first fix. Useful for trust once memory stores grow.
|
||||
|
||||
## Why not embeddings
|
||||
|
||||
Embeddings are the wrong tool at this scale.
|
||||
|
||||
Costs:
|
||||
|
||||
- new dependency/API or local model decision
|
||||
- cache/versioning problem for embedding vectors
|
||||
- nondeterministic thresholds
|
||||
- hard-to-debug deletions
|
||||
- privacy and offline behavior questions
|
||||
|
||||
The current store has 22 entries. The failures are obvious strings, paths, topics, and versioned decisions. Use deterministic rules now. Reconsider embeddings only if stores grow into hundreds of entries and lexical/topic rules fail in real usage.
|
||||
|
||||
## Risks and tradeoffs
|
||||
|
||||
### Risk: deleting useful historical decisions
|
||||
|
||||
Mitigation: do not apply broad fuzzy dedupe to `decision`. Use topic-specific supersession only for known patterns. Keep explicit entries unless explicitly replaced.
|
||||
|
||||
### Risk: bespoke topic keys become a pile of regexes
|
||||
|
||||
Mitigation: keep the first version tiny and test-driven. Add keys only for observed failures. If this grows past roughly 10 topic rules, revisit the model.
|
||||
|
||||
### Risk: prompt-only fix gives false confidence
|
||||
|
||||
Mitigation: prompt change is P1, storage normalization is P0. The store must protect itself.
|
||||
|
||||
### Risk: stale pruning removes something still useful
|
||||
|
||||
Mitigation: no age pruning for feedback, no automatic age pruning for explicit entries, and grace periods for compaction/manual entries.
|
||||
|
||||
### Risk: normalization mutates existing stores unexpectedly
|
||||
|
||||
Mitigation: add tests with fixtures from the current store. Consider logging cleanup counts in development if a logging channel exists. The output should be deterministic.
|
||||
|
||||
## NOT in scope
|
||||
|
||||
- Embedding similarity, too much machinery for 22 entries.
|
||||
- LLM-based contradiction detection, nondeterministic and hard to test.
|
||||
- Full memory history graph with `supersededBy`, useful later but not required for current rendering quality.
|
||||
- New cleanup UI or CLI, optional P2 after deterministic normalization lands.
|
||||
- Changing `LongTermMemoryEntry` schema, avoid migration unless history preservation becomes required.
|
||||
|
||||
## Prioritized steps
|
||||
|
||||
1. **P0: Add tests in `tests/workspace-memory.test.ts` using the concrete duplicate examples from the current store.** This locks the desired behavior before touching cleanup logic.
|
||||
2. **P0: Implement storage-time cleanup in `enforceLongTermLimits()`.** Exact dedupe, identity-key dedupe, decision supersession, stale pruning, then max-entry trim.
|
||||
3. **P0: Make stale lifecycle enforceable but conservative.** No age pruning for feedback or explicit entries. Prune compaction/manual entries after `staleAfterDays + 30`.
|
||||
4. **P1: Tighten `buildCompactionPrompt()` to avoid re-emitting existing memories and emit only replacing facts.** This reduces future noise but is not trusted as the only defense.
|
||||
5. **P1: Add regression fixtures matching the real `workspace-memory.json` problem set.** Assert resulting entries are below the current 22 and contain the newer/canonical facts.
|
||||
6. **P2: Add a cleanup report command only if users need visibility.** Defer until after the automatic path proves itself.
|
||||
|
||||
## Final architecture decision
|
||||
|
||||
The memory store should be self-cleaning at its storage boundary.
|
||||
|
||||
Use prompt engineering to reduce bad candidates, but make `src/workspace-memory.ts` the authority for what persists. Use deterministic, type-aware dedupe instead of embeddings. Treat `project` and `reference` as entity identity problems, `feedback` as topic replacement, and `decision` as explicit supersession.
|
||||
|
||||
That is the smallest design that solves the real failures without turning a 28-entry JSON file into a search platform.
|
||||
|
||||
## Addendum: bracketless memory candidate format from real compaction
|
||||
|
||||
Date: 2026-04-26
|
||||
|
||||
### Summary table
|
||||
|
||||
| Issue | Severity | Fix | Priority |
|
||||
|-------|----------|-----|----------|
|
||||
| Parser silently drops `- project text` bracketless candidates | High | Accept both `- [type] text` and `- type text` | P0 |
|
||||
| Prompt examples imply brackets but do not explicitly require exact syntax | Medium | Add "Use exactly this format, including square brackets" plus a negative example | P0, same small patch |
|
||||
| No regression test for bracketless candidate lines | High | Add parser test covering all four types in bracketless form | P0 |
|
||||
| Future compactions may re-extract useful facts with changed counts or wording | Medium | Keep storage-time type-aware dedupe/staleness plan | P0, unchanged |
|
||||
|
||||
### 1. Parser fix
|
||||
|
||||
Accept `- type text` with no brackets.
|
||||
|
||||
Also strengthen the prompt. Do both.
|
||||
|
||||
The parser is the product boundary. Model output is not a contract, it is an input from an unreliable narrator with excellent vibes. If the model emits a plainly parseable, semantically valid candidate, dropping it silently is a data loss bug.
|
||||
|
||||
The prompt should still ask for the preferred bracketed format because bracketed type markers are less ambiguous. But prompt enforcement alone is not enough. The new evidence proves the model sometimes drops brackets even when examples include them.
|
||||
|
||||
Recommended parser behavior:
|
||||
|
||||
- preferred: `- [project] pathology-playground 後端健康改進計劃已完成 Phase 1-4`
|
||||
- accepted fallback: `- project pathology-playground 後端健康改進計劃已完成 Phase 1-4`
|
||||
- still reject unknown types
|
||||
- still run `shouldAcceptWorkspaceMemoryCandidate()`
|
||||
- still require body length and existing quality gates
|
||||
|
||||
### 2. Prompt format enforcement
|
||||
|
||||
Yes, add explicit syntax instructions.
|
||||
|
||||
Current prompt shows examples, but examples are not a hard enough constraint. Add one sentence before the examples:
|
||||
|
||||
```text
|
||||
Use exactly this candidate format, including square brackets around the type:
|
||||
```
|
||||
|
||||
Then keep the examples:
|
||||
|
||||
```text
|
||||
Memory candidates:
|
||||
- [feedback] content
|
||||
- [project] content
|
||||
- [decision] content
|
||||
- [reference] content
|
||||
```
|
||||
|
||||
Optionally add one short warning:
|
||||
|
||||
```text
|
||||
Do not write `- project content`; write `- [project] content`.
|
||||
```
|
||||
|
||||
Keep this short. Long formatting lectures increase prompt surface area and make the summary worse. One positive instruction plus one negative example is enough.
|
||||
|
||||
### 3. Impact on dedup plan
|
||||
|
||||
Parser robustness moves to P0, before storage dedup/staleness cleanup.
|
||||
|
||||
This changes sequencing, not the architecture.
|
||||
|
||||
Updated P0 order:
|
||||
|
||||
1. **P0a: Fix parser format tolerance and add regression tests.** Lost memory is worse than duplicate memory. A deduper cannot dedupe entries that never made it into the store.
|
||||
2. **P0b: Implement storage-time dedupe and stale pruning.** Still the main long-term quality fix.
|
||||
3. **P0c: Tighten prompt format instruction in the same small patch as parser tolerance.** Cheap and reduces fallback-parser usage.
|
||||
|
||||
The earlier recommendation still stands: storage normalization remains the authority for duplicates and staleness. This new evidence adds a more basic ingestion reliability bug in front of it.
|
||||
|
||||
### 4. Concrete implementation recommendation
|
||||
|
||||
#### Regex change
|
||||
|
||||
Replace the current parser line in `src/extractors.ts:parseWorkspaceMemoryCandidates()`:
|
||||
|
||||
```ts
|
||||
const item = line.trim().match(/^-\s*\[(feedback|project|decision|reference)\]\s*(.+)$/i);
|
||||
```
|
||||
|
||||
with a single regex that accepts bracketed and bracketless forms:
|
||||
|
||||
```ts
|
||||
const item = line.trim().match(
|
||||
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
|
||||
);
|
||||
if (!item) continue;
|
||||
|
||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||
const body = item[3].trim();
|
||||
```
|
||||
|
||||
Why this shape:
|
||||
|
||||
- `(?:[type]|type\b)` accepts both formats
|
||||
- `\b` prevents `projectile` from being parsed as `project`
|
||||
- `\s+(.+)` requires real content after the type
|
||||
- unknown types still fail
|
||||
|
||||
Even better for readability, avoid duplicate type alternation with a named group if the runtime target supports it cleanly:
|
||||
|
||||
```ts
|
||||
const item = line.trim().match(
|
||||
/^-\s*(?:\[(?<bracketed>feedback|project|decision|reference)\]|(?<plain>feedback|project|decision|reference)\b)\s+(?<body>.+)$/i,
|
||||
);
|
||||
if (!item?.groups) continue;
|
||||
|
||||
const type = (item.groups.bracketed ?? item.groups.plain).toLowerCase() as LongTermType;
|
||||
const body = item.groups.body.trim();
|
||||
```
|
||||
|
||||
Recommendation: use the non-named-group version. It is uglier, but it is maximally boring and consistent with the existing code style.
|
||||
|
||||
Add tests in `tests/extractors.test.ts`:
|
||||
|
||||
```ts
|
||||
test("parseWorkspaceMemoryCandidates accepts bracketless candidate format", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project pathology-playground 後端健康改進計劃已完成 Phase 1-4
|
||||
- reference Scrypt 參數必須是 N=16384, r=8, p=1
|
||||
- feedback 端口 9473 可能被舊進程佔用,需殺掉後重啟
|
||||
- decision Use output.prompt to replace the default compaction template
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
|
||||
assert.equal(items.length, 4);
|
||||
assert.deepEqual(items.map(item => item.type), [
|
||||
"project",
|
||||
"reference",
|
||||
"feedback",
|
||||
"decision",
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
Also add a guard test:
|
||||
|
||||
```ts
|
||||
test("parseWorkspaceMemoryCandidates rejects unknown bracketless candidate type", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- note this should not be parsed as memory
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
```
|
||||
|
||||
#### Prompt change
|
||||
|
||||
In `src/plugin.ts:buildCompactionPrompt()`, change this block:
|
||||
|
||||
```ts
|
||||
"At the end of the summary, extract durable memory entries for future",
|
||||
"sessions using these labels:",
|
||||
"",
|
||||
"Memory candidates:",
|
||||
"- [feedback] content",
|
||||
"- [project] content",
|
||||
"- [decision] content",
|
||||
"- [reference] content",
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
"At the end of the summary, extract durable memory entries for future",
|
||||
"sessions using exactly this candidate format, including square brackets around the type:",
|
||||
"",
|
||||
"Memory candidates:",
|
||||
"- [feedback] content",
|
||||
"- [project] content",
|
||||
"- [decision] content",
|
||||
"- [reference] content",
|
||||
"",
|
||||
"Do not write '- project content'; write '- [project] content'.",
|
||||
```
|
||||
|
||||
This gives the model a crisp positive format and a concrete anti-pattern. The parser still accepts the anti-pattern because users need data capture more than format purity.
|
||||
|
||||
### Final addendum decision
|
||||
|
||||
Parser tolerance is now P0.
|
||||
|
||||
The architecture stays the same: make the storage layer self-cleaning, and make ingestion defensive. But the implementation sequence changes because silent data loss beats duplicate accumulation in severity. First capture valid candidates reliably. Then dedupe and prune them.
|
||||
|
||||
## Addendum 2: content quality guidance
|
||||
|
||||
Date: 2026-04-26
|
||||
|
||||
### Summary table
|
||||
|
||||
| Issue | Severity | Fix | Priority |
|
||||
|-------|----------|-----|----------|
|
||||
| Model extracts low-durability progress snapshots as `project` memory | High | Add durable-content guidance to compaction prompt | P0 |
|
||||
| Exact counts like `1237 tests pass` and `37 files` churn across sessions | High | Add parser quality filter for obvious snapshot patterns | P0 |
|
||||
| Stable config values are useful and should still pass | Medium | Keep `reference` guidance permissive for config/crypto/PIN values | P0 |
|
||||
| Environment issues like occupied ports may be useful briefly but not long-term | Medium | Prompt says unresolved issues only; storage staleness handles aging | P1 with staleness work |
|
||||
|
||||
### 1. Architecture fit
|
||||
|
||||
This belongs in both the prompt and the parser, with different responsibilities.
|
||||
|
||||
The prompt should teach the model what "durable" means. The model is choosing what to extract, so it needs product semantics:
|
||||
|
||||
- stable configuration values are good memory
|
||||
- unresolved bugs can be useful memory
|
||||
- exact test counts, file counts, and phase progress are usually bad long-term memory
|
||||
|
||||
The parser should still reject obvious low-durability snapshots as a backstop. The parser already has `shouldAcceptWorkspaceMemoryCandidate()` in `src/extractors.ts`; this is exactly where simple content-quality gates belong.
|
||||
|
||||
Do not put subtle semantic judgment in the parser. Do put obvious anti-patterns there.
|
||||
|
||||
Recommended split:
|
||||
|
||||
```text
|
||||
Prompt
|
||||
└─ positive/negative guidance for durable memory selection
|
||||
|
||||
Parser quality gate
|
||||
└─ deterministic rejection of obvious snapshots
|
||||
- exact test counts
|
||||
- exact file counts
|
||||
- completed Phase N-M progress lines
|
||||
- temporary port/process cleanup notes when phrased as resolved/current env state
|
||||
|
||||
Storage normalization
|
||||
└─ dedupe, supersession, age-based pruning
|
||||
```
|
||||
|
||||
This is the same design principle as the bracketless parser addendum: ask the model nicely, then make the code defensive.
|
||||
|
||||
### 2. Specificity vs risk
|
||||
|
||||
The proposed guidance is specific, but not too specific.
|
||||
|
||||
It names examples from the observed failure mode, but the rule underneath is general: facts should stay true across sessions. Exact counts and phase numbers are classic snapshot smell in almost every codebase.
|
||||
|
||||
Potential risk: sometimes an exact count is genuinely durable. Example: "USB sync protocol expects exactly 37 manifest entries" could be a stable contract, not a snapshot.
|
||||
|
||||
Mitigation: word the guidance around "session-specific progress" rather than banning all numbers. Keep config values explicitly allowed.
|
||||
|
||||
Good distinction:
|
||||
|
||||
```text
|
||||
Bad: 1237 tests pass today
|
||||
Good: Test suite is expected to pass before handoff
|
||||
|
||||
Bad: USB sync currently has 37 files
|
||||
Good: USB sync covers bundles, server, frontend, tests, and docs
|
||||
|
||||
Bad: Phase 1-4 completed
|
||||
Good: Backend health work is organized into phased improvements
|
||||
|
||||
Good: Scrypt parameters are N=16384, r=8, p=1
|
||||
```
|
||||
|
||||
The first three are progress snapshots. The Scrypt value is a stable configuration contract. Numbers are not the problem. Temporary state is the problem.
|
||||
|
||||
### 3. Prompt length concern
|
||||
|
||||
Adding four lines is worth it.
|
||||
|
||||
This prompt is already making the model do extraction. Without guidance, the model optimizes for "important-looking facts," and progress snapshots look important. That creates churn, duplicates, and stale memory. Four lines preventing bad memory at the source are cheap.
|
||||
|
||||
If trimming is needed, trim redundant formatting language before removing quality guidance. Formatting mistakes lose entries or require parser tolerance. Content mistakes pollute the store. Both matter, but the durable-content guidance carries more product value than repeated Markdown formatting reminders.
|
||||
|
||||
Recommended trim posture:
|
||||
|
||||
- keep one concise formatting instruction
|
||||
- keep one concise candidate syntax instruction
|
||||
- add one concise durable-content block
|
||||
- avoid long examples or taxonomy tables in the prompt
|
||||
|
||||
The prompt should not become a memory policy document. It just needs the model to stop writing "1237 tests pass" into long-term storage. Wild that we have to say this, but we do.
|
||||
|
||||
### 4. Concrete prompt recommendation
|
||||
|
||||
In `src/plugin.ts:buildCompactionPrompt()`, replace the candidate instruction block with this final version:
|
||||
|
||||
```ts
|
||||
"At the end of the summary, extract durable memory entries for future sessions.",
|
||||
"Only extract facts that are likely to stay true across sessions.",
|
||||
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
|
||||
"For progress, extract the stable goal or durable milestone, not the current number.",
|
||||
"For references, extract configuration values that do not usually change between sessions.",
|
||||
"For feedback, extract unresolved issues or user preferences that future sessions need to know.",
|
||||
"Use exactly this candidate format, including square brackets around the type:",
|
||||
"",
|
||||
"Memory candidates:",
|
||||
"- [feedback] content",
|
||||
"- [project] content",
|
||||
"- [decision] content",
|
||||
"- [reference] content",
|
||||
"",
|
||||
"Do not write '- project content'; write '- [project] content'.",
|
||||
```
|
||||
|
||||
This is slightly longer than the lead's proposal, but it avoids an overbroad ban on numbers by saying "session-specific progress." It also gives a positive replacement behavior: stable goal or durable milestone.
|
||||
|
||||
If a shorter version is required, use this:
|
||||
|
||||
```ts
|
||||
"At the end of the summary, extract durable memory entries for future sessions.",
|
||||
"Only extract facts likely to stay true across sessions; skip exact test counts, file counts, phase numbers, and temporary environment state.",
|
||||
"References may include stable configuration values. Feedback should be unresolved issues or user preferences future sessions need.",
|
||||
"Use exactly this candidate format, including square brackets around the type:",
|
||||
```
|
||||
|
||||
Recommendation: use the longer block. The extra three lines buy clarity and reduce accidental over-filtering.
|
||||
|
||||
### Parser quality gate recommendation
|
||||
|
||||
Add deterministic snapshot rejection to `shouldAcceptWorkspaceMemoryCandidate()`.
|
||||
|
||||
Keep this conservative. Reject obvious snapshots, not every number.
|
||||
|
||||
Suggested first-pass rules:
|
||||
|
||||
```ts
|
||||
// Session-specific progress snapshots, not durable memory.
|
||||
if (entry.type === "project") {
|
||||
if (/\b\d+\s+tests?\s+pass(?:ed)?\b/i.test(text)) return false;
|
||||
if (/\b\d+\s+suites?\b/i.test(text)) return false;
|
||||
if (/\b\d+\s+(?:files?|文件)\b/i.test(text)) return false;
|
||||
if (/\bphase\s*\d+(?:\s*[-–]\s*\d+)?\s+(?:completed|done|finished)\b/i.test(text)) return false;
|
||||
if (/已完成\s*Phase\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) return false;
|
||||
}
|
||||
```
|
||||
|
||||
Do not reject stable `reference` values containing numbers. These must pass:
|
||||
|
||||
```text
|
||||
Admin PIN 是 456123
|
||||
Scrypt 參數必須是 N=16384, r=8, p=1
|
||||
```
|
||||
|
||||
For `feedback`, do not broadly reject ports yet. A port issue can be useful if it explains a recurring failure. Let staleness prune it, unless the text clearly says the issue was resolved. A future parser rule can reject resolved temporary env notes, but the current evidence is not enough to safely block all port-related feedback.
|
||||
|
||||
### 5. Integration with storage-time dedup/staleness
|
||||
|
||||
Prompt-level guidance and staleness solve different problems.
|
||||
|
||||
Staleness is cleanup after bad or aging facts are already stored. Prompt guidance prevents low-value facts from entering the store in the first place. Parser filtering catches obvious misses when the prompt fails.
|
||||
|
||||
Do not rely on staleness for exact counts.
|
||||
|
||||
Why:
|
||||
|
||||
- `maxEntries` is 28, so a few bad snapshots can evict useful facts before they age out
|
||||
- exact counts will churn every compaction and create near-duplicates
|
||||
- stale labels still consume render budget until pruning runs
|
||||
- users see noisy memory and trust the feature less
|
||||
|
||||
Storage-time dedup/staleness remains required for facts that were good when written but later become outdated. Example: a config path that moves, a decision superseded by a better decision, or an unresolved bug that later gets fixed.
|
||||
|
||||
Use this mental model:
|
||||
|
||||
```text
|
||||
Prompt guidance → prevent bad candidates
|
||||
Parser quality gate → reject obvious bad candidates
|
||||
Storage dedupe → merge repeated good candidates
|
||||
Storage staleness → retire once-good candidates that aged out
|
||||
```
|
||||
|
||||
### Updated priority
|
||||
|
||||
The new content-quality evidence adds another P0 ingestion fix.
|
||||
|
||||
Updated sequence:
|
||||
|
||||
1. **P0a: Parser accepts bracketless candidate format and tests it.** Prevent silent data loss.
|
||||
2. **P0b: Prompt durable-content guidance.** Stop obvious snapshots at the source.
|
||||
3. **P0c: Parser rejects obvious low-durability `project` snapshots.** Backstop the prompt with deterministic filters.
|
||||
4. **P0d: Storage-time dedupe and staleness.** Still required for duplicate accumulation and lifecycle cleanup.
|
||||
|
||||
### Final addendum 2 decision
|
||||
|
||||
Add the durable-content guidance to the prompt and add conservative parser filters for obvious `project` snapshots.
|
||||
|
||||
This does not replace storage-time dedupe or staleness. It reduces garbage before it reaches that layer. The store still needs to clean itself, but it should not be used as a trash compactor for facts we already know are temporary.
|
||||
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.1",
|
||||
"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,298 @@
|
||||
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 (with type-specific allowlist for stable config values)
|
||||
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
|
||||
// Stable config values can be short — allow below generic min length
|
||||
} else if (text.length < 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;
|
||||
|
||||
// Session-specific progress snapshots for project type
|
||||
if (entry.type === "project") {
|
||||
if (/\b\d+\s+tests?\s+pass(?:ed)?\b/i.test(text)) return false;
|
||||
if (/\b\d+\s+suites?\b/i.test(text)) return false;
|
||||
if (/\b\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) return false;
|
||||
// Reject "Phase N completed" using semantic window (within 20 chars either direction)
|
||||
if (/\bphase\s*\d+(?:\s*[-–]\s*\d+)?\b.{0,20}\b(?:completed|done|finished)\b/i.test(text)) return false;
|
||||
if (/\b(?:completed|done|finished)\b.{0,20}\bphase\s*\d+(?:\s*[-–]\s*\d+)?\b/i.test(text)) return false;
|
||||
if (/已完成.{0,20}Phase\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) return false;
|
||||
if (/Phase\s*\d+(?:\s*[-–]\s*\d+)?.{0,20}已完成/i.test(text)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate block from summary using multiple formats.
|
||||
* Supports: Plain text label, Markdown section, legacy XML.
|
||||
*/
|
||||
function extractCandidateBlock(summary: string): string | null {
|
||||
// 1. Plain text label (primary format, no Markdown header)
|
||||
const plainMatch = summary.match(/Memory candidates:\s*\n([\s\S]*?)(?:\n[A-Z][a-z]+ [a-z]+:|\n##\s|$)/i);
|
||||
if (plainMatch) return plainMatch[1];
|
||||
|
||||
// 2. Markdown section (legacy)
|
||||
const markdownMatch = summary.match(/##\s*Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||||
if (markdownMatch) return markdownMatch[1];
|
||||
|
||||
// 3. Legacy "Workspace Memory Candidates" section
|
||||
const legacyMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||||
if (legacyMatch) return legacyMatch[1];
|
||||
|
||||
// 4. Legacy XML block (backward compatible)
|
||||
const xmlMatch = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
|
||||
if (xmlMatch) return xmlMatch[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
|
||||
const block = extractCandidateBlock(summary);
|
||||
if (!block) return [];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
|
||||
for (const line of block.split("\n")) {
|
||||
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
||||
const item = line.trim().match(
|
||||
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
|
||||
);
|
||||
if (!item) continue;
|
||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||
const body = item[3].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`);
|
||||
}
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
/**
|
||||
* Build the complete compaction prompt.
|
||||
*
|
||||
* Replaces OpenCode's default template (which uses --- separators that trigger
|
||||
* YAML frontmatter comment scope in markdown rendering, producing purple italic text).
|
||||
* Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter,
|
||||
* horizontal rules, and delimiter lines.
|
||||
*
|
||||
* @param privateContext - Background context (workspace memory, hot session state,
|
||||
* pending todos) from our plugin and any other plugins. Shown to the model to
|
||||
* inform the summary but not copied verbatim.
|
||||
*/
|
||||
function buildCompactionPrompt(privateContext: string): string {
|
||||
return [
|
||||
"Provide a detailed summary for continuing our conversation above.",
|
||||
"Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.",
|
||||
"",
|
||||
"Do not call any tools. Respond only with the summary text.",
|
||||
"Respond in the same language as the user's messages in the conversation.",
|
||||
"",
|
||||
"Formatting rules:",
|
||||
"- Start the response with \"## Goal\".",
|
||||
"- Use Markdown headings only.",
|
||||
"- Do not output YAML frontmatter.",
|
||||
"- Do not output horizontal rules.",
|
||||
"- Do not wrap the summary in delimiter lines such as ---.",
|
||||
"- Do not use code fences around the summary.",
|
||||
"",
|
||||
"Use this structure:",
|
||||
"",
|
||||
"## Goal",
|
||||
"",
|
||||
"## Instructions",
|
||||
"",
|
||||
"## Progress",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"",
|
||||
"## Discoveries",
|
||||
"",
|
||||
"## Next Steps",
|
||||
"",
|
||||
"## Relevant Files",
|
||||
"",
|
||||
"At the end of the summary, extract durable memory entries for future sessions.",
|
||||
"Only extract facts that are likely to stay true across sessions.",
|
||||
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
|
||||
"For progress, extract the stable goal or durable milestone, not the current number.",
|
||||
"For references, extract configuration values that do not usually change between sessions.",
|
||||
"For feedback, extract unresolved issues or user preferences that future sessions need to know.",
|
||||
"Use exactly this candidate format, including square brackets around the type:",
|
||||
"",
|
||||
"Memory candidates:",
|
||||
"- [feedback] content",
|
||||
"- [project] content",
|
||||
"- [decision] content",
|
||||
"- [reference] content",
|
||||
"",
|
||||
"Do not write '- project content'; write '- [project] content'.",
|
||||
"",
|
||||
"Background context, use this to inform the summary above.",
|
||||
"Do not output this context verbatim:",
|
||||
"",
|
||||
privateContext,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render todos for compaction context (plain text format, no Markdown headers).
|
||||
*/
|
||||
function renderTodosForCompaction(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}]` : "";
|
||||
const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○";
|
||||
lines.push(`- ${status} ${todo.content}${priority}`);
|
||||
}
|
||||
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);
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the default compaction prompt with a ---free template.
|
||||
*
|
||||
* OpenCode's default template wraps sections in --- separators. When the
|
||||
* model follows the template (which our structured context encourages),
|
||||
* the TUI renders --- at position 0 as YAML frontmatter, applying the
|
||||
* "comment" syntax scope (purple italic in palenight theme).
|
||||
*
|
||||
* We set output.prompt to replace the entire prompt, removing all ---
|
||||
* and explicitly forbidding YAML frontmatter / horizontal rules.
|
||||
*/
|
||||
"experimental.session.compacting": async (hookInput, output) => {
|
||||
const { sessionID } = hookInput;
|
||||
if (!sessionID) return;
|
||||
|
||||
// Sub-agents don't need compaction support
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Preserve context injected by other plugins that ran before us.
|
||||
// Setting output.prompt bypasses the default prompt + context join,
|
||||
// so we must explicitly carry forward any existing output.context.
|
||||
const otherContext = output.context.filter(Boolean).join("\n\n");
|
||||
|
||||
// Build our private context (workspace memory, hot state, todos)
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. Frozen workspace memory
|
||||
const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID);
|
||||
const workspacePrompt = renderWorkspaceMemory(workspaceMemory);
|
||||
if (workspacePrompt) {
|
||||
contextParts.push(workspacePrompt);
|
||||
}
|
||||
|
||||
// 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 = renderTodosForCompaction(todos);
|
||||
if (todosPrompt) {
|
||||
contextParts.push(todosPrompt);
|
||||
}
|
||||
|
||||
// Combine: other plugins' context first, then our private context
|
||||
const privateContext = [otherContext, ...contextParts]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
// Replace the default prompt entirely with our ---free template
|
||||
output.prompt = buildCompactionPrompt(privateContext);
|
||||
|
||||
// Clear context array since we consumed it into output.prompt.
|
||||
// Subsequent plugins that set output.prompt will also need to check
|
||||
// output.context if they want to preserve other plugin contributions.
|
||||
output.context.length = 0;
|
||||
},
|
||||
|
||||
// Handle session events
|
||||
event: async ({ event }) => {
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (event.properties as { sessionID?: string; info?: { id?: string } })?.sessionID
|
||||
?? (event.properties as { info?: { id?: string } })?.info?.id;
|
||||
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,239 @@
|
||||
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 (current session):"];
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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,315 @@
|
||||
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();
|
||||
}
|
||||
|
||||
/** Extract entity/destination keys for project and reference dedup */
|
||||
function extractEntityKey(text: string): string | null {
|
||||
const normalized = canonicalMemoryText(text);
|
||||
// Check known key phrases (bilingual-friendly)
|
||||
// opencode + agenthub plugin system
|
||||
if (/opencode.*agenthub/i.test(normalized)) {
|
||||
return "opencode-agenthub plugin system";
|
||||
}
|
||||
// For generic config references, fall back to canonical text dedup — no entity key
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract decision topic key for supersession detection */
|
||||
function decisionTopicKey(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
// Parser format versions
|
||||
if (/parser.*formats?|supports?\s*\d+\s*format/i.test(normalized)) {
|
||||
return "parser-supported-formats";
|
||||
}
|
||||
// Compaction template replacement
|
||||
if (/compaction.*template|output\.prompt|template.*replace/i.test(normalized)) {
|
||||
return "compaction-template-replacement";
|
||||
}
|
||||
// Plugin loading
|
||||
if (/plugin.*load|npm.*cache|plugin.*config/i.test(normalized)) {
|
||||
return "plugin-loading-config";
|
||||
}
|
||||
// Output format changes (purple/italic, YAML frontmatter, etc)
|
||||
if (/purple.*italic|markup|markdown.*render|frontmatter/i.test(normalized)) {
|
||||
return "output-format-rendering";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract feedback topic key for supersession detection */
|
||||
function feedbackTopicKey(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
// Purple/italic rendering issue
|
||||
if (/purple.*italic/i.test(normalized)) {
|
||||
return "purple-italic-rendering";
|
||||
}
|
||||
// Browser login/server errors (500 internal_error)
|
||||
if (/login.*500|500.*internal|internal_error|server.*error/i.test(normalized)) {
|
||||
return "server-error";
|
||||
}
|
||||
// Port occupied / environment issues
|
||||
if (/port.*occup|9473|端口|舊進程|旧进程/i.test(normalized)) {
|
||||
return "port-occupied-environment";
|
||||
}
|
||||
// Theme preferences
|
||||
if (/theme|dark.*light|prefer.*theme/i.test(normalized)) {
|
||||
return "theme-preference";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if entry should be pruned by age (for compaction/manual entries only) */
|
||||
function isPrunableByAge(entry: LongTermMemoryEntry, now: number): boolean {
|
||||
// Never prune feedback or explicit entries
|
||||
if (entry.type === "feedback") return false;
|
||||
if (entry.source === "explicit") return false;
|
||||
if (!entry.staleAfterDays) return false;
|
||||
|
||||
const createdAt = new Date(entry.createdAt).getTime();
|
||||
const ageDays = (now - createdAt) / 86400000;
|
||||
const grace = 30; // 30-day grace period
|
||||
return ageDays > entry.staleAfterDays + grace;
|
||||
}
|
||||
|
||||
/** Choose better memory when identity/topic keys conflict */
|
||||
function chooseBetterMemory(
|
||||
a: LongTermMemoryEntry,
|
||||
b: LongTermMemoryEntry,
|
||||
mode: "entity" | "supersession" = "entity",
|
||||
): LongTermMemoryEntry {
|
||||
// Source priority: explicit > manual > compaction
|
||||
if (sourcePriority(a.source) !== sourcePriority(b.source)) {
|
||||
return sourcePriority(a.source) > sourcePriority(b.source) ? a : b;
|
||||
}
|
||||
// Higher confidence wins
|
||||
if (a.confidence !== b.confidence) {
|
||||
return a.confidence > b.confidence ? a : b;
|
||||
}
|
||||
// For entity dedup: longer (more specific) beats shorter
|
||||
// For supersession: newer beats older (and thus longer is not preferred)
|
||||
if (mode === "supersession") {
|
||||
// Newer wins for same-topic supersession
|
||||
if (new Date(a.createdAt).getTime() !== new Date(b.createdAt).getTime()) {
|
||||
return new Date(a.createdAt) > new Date(b.createdAt) ? a : b;
|
||||
}
|
||||
return a.text.length > b.text.length ? a : b;
|
||||
}
|
||||
// Entity mode: longer text means more specific
|
||||
if (Math.abs(a.text.length - b.text.length) > 10) {
|
||||
return a.text.length > b.text.length ? a : b;
|
||||
}
|
||||
// Freshness tie-breaker
|
||||
return new Date(a.createdAt) > new Date(b.createdAt) ? a : b;
|
||||
}
|
||||
|
||||
export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
const now = Date.now();
|
||||
|
||||
// Phase 1: filter active, prune by age
|
||||
const phase1 = entries
|
||||
.filter(entry => entry.status === "active")
|
||||
.filter(entry => !isPrunableByAge(entry, now))
|
||||
.map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) }));
|
||||
|
||||
// For project/reference/feedback: detect entity keys FIRST, then dedupe by entity OR canonical
|
||||
const projectRefEntries = phase1.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
||||
|
||||
// Build entity key dedup for project/reference/feedback
|
||||
const entityDeduped = new Map<string, LongTermMemoryEntry>();
|
||||
for (const entry of projectRefEntries) {
|
||||
const entityKey = entry.type === "project" || entry.type === "reference"
|
||||
? extractEntityKey(entry.text)
|
||||
: feedbackTopicKey(entry.text);
|
||||
const key = entityKey ? `${entry.type}:${entityKey}` : `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||||
|
||||
const existing = entityDeduped.get(key);
|
||||
if (!existing) {
|
||||
entityDeduped.set(key, entry);
|
||||
} else {
|
||||
// Feedback topic conflicts use supersession mode (newer beats longer)
|
||||
const mode = entry.type === "feedback" && entityKey ? "supersession" as const : "entity" as const;
|
||||
if (chooseBetterMemory(entry, existing, mode) === entry) {
|
||||
entityDeduped.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For decisions: detect topic keys for supersession, or use canonical
|
||||
const decisionEntries = phase1.filter(e => e.type === "decision");
|
||||
const decisionDeduped = new Map<string, LongTermMemoryEntry>();
|
||||
for (const entry of decisionEntries) {
|
||||
const topic = decisionTopicKey(entry.text);
|
||||
const key = topic ? `decision:${topic}` : `decision:${canonicalMemoryText(entry.text)}`;
|
||||
|
||||
const existing = decisionDeduped.get(key);
|
||||
if (!existing) {
|
||||
decisionDeduped.set(key, entry);
|
||||
} else if (chooseBetterMemory(entry, existing, "supersession") === entry) {
|
||||
decisionDeduped.set(key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge deduped entries
|
||||
const phaseFinal = new Map<string, LongTermMemoryEntry>();
|
||||
for (const entry of [...entityDeduped.values(), ...decisionDeduped.values()]) {
|
||||
phaseFinal.set(entry.id, entry);
|
||||
}
|
||||
|
||||
// Phase 6: sort and trim
|
||||
return [...phaseFinal.values()]
|
||||
.sort((a, b) => {
|
||||
const pA = priorityWithFreshness(a);
|
||||
const pB = priorityWithFreshness(b);
|
||||
if (pB !== pA) return pB - pA;
|
||||
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
|
||||
if (sourceDiff !== 0) return sourceDiff;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
})
|
||||
.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;
|
||||
}
|
||||
|
||||
/** Extended priority including freshness for tie-breaking */
|
||||
function priorityWithFreshness(entry: LongTermMemoryEntry): number {
|
||||
return priority(entry);
|
||||
}
|
||||
|
||||
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 lines: string[] = [
|
||||
"Workspace memory (cross-session, verify if stale):",
|
||||
];
|
||||
|
||||
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 ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
|
||||
sectionLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionLines.length > 1) {
|
||||
lines.push(...sectionLines);
|
||||
}
|
||||
}
|
||||
|
||||
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,363 @@
|
||||
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 = `
|
||||
## Memory Candidates
|
||||
- [decision] short text
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects git commit hash", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [project] abc123def456 is the commit hash
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects raw error", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [feedback] TypeError: Cannot read property 'x' of undefined
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [reference] at foo (bar.ts:10:5)
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects commit prefix", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [project] fix: add new feature
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [project] files at /src/a.ts /src/b.ts /src/c.ts are important
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [decision] Use pnpm instead of npm for package management
|
||||
`;
|
||||
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 = `
|
||||
## Memory Candidates
|
||||
- [project] This project uses TypeScript for all source files
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].type, "project");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts plain text label format (no Markdown)", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- [decision] Use plain text labels to avoid purple Markdown headers
|
||||
- [project] This repo uses pnpm for package management
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].type, "decision");
|
||||
assert.equal(items[1].type, "project");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts bracketless candidate format", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project Backend health improvements organized into phased milestones
|
||||
- reference Scrypt 參數必須是 N=16384, r=8, p=1
|
||||
- feedback 端口 9473 可能被舊進程佔用,需殺掉後重啟
|
||||
- decision Use output.prompt to replace the default compaction template
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
|
||||
assert.equal(items.length, 4, "Should parse all 4 bracketless candidates");
|
||||
assert.deepEqual(items.map(i => i.type), [
|
||||
"project",
|
||||
"reference",
|
||||
"feedback",
|
||||
"decision",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects unknown bracketless candidate type", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- note this should not be parsed as memory
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects bracketless very short body", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project short
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates does not match bracketless type as substring", () => {
|
||||
// "projectile" should NOT match "project"
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- projectile launcher should not be parsed as a project memory
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects exact test count snapshots", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project 1237 tests pass, 226 suites
|
||||
- project 500 tests pass today
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Exact test counts are session snapshots, not durable memory");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project USB 同步 37 個文件
|
||||
- project 42 files synced
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Exact file counts are session snapshots");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects phase progress snapshots", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project Phase 1-4 已完成
|
||||
- project Phase 3 completed
|
||||
- project Completed phase 1
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Phase progress is session snapshot, not durable milestone");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts durable project facts", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project Backend health improvements organized into phased milestones
|
||||
- project USB sync covers bundles, server, frontend, tests, and docs
|
||||
- project Test suite expected to pass before handoff
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 3, "Durable project facts should pass");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts short Admin PIN reference entry", () => {
|
||||
// Real Admin PIN is <20 chars — should pass via config value allowlist
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- reference Admin PIN 是 456123
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 1, "Short config reference should pass via allowlist");
|
||||
assert.equal(items[0].type, "reference");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts Scrypt config reference", () => {
|
||||
// Scrypt parameters with numbers should pass
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- reference Scrypt 參數必須是 N=16384, r=8, p=1
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 1, "Scrypt config values should pass");
|
||||
assert.equal(items[0].type, "reference");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects Chinese file count snapshot", () => {
|
||||
// Real Chinese file count with counter word 個
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project USB 同步:37 個文件(bundles, server, frontend, tests, docs)
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Chinese file count with 個 should be rejected");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects real phase snapshot mid-description", () => {
|
||||
// Real phase snapshot where Phase appears deep in the string
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project pathology-playground 後端健康改進計劃已完成 Phase 1-4
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Phase snapshot mid-description should still be rejected");
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
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 { parseWorkspaceMemoryCandidates } from "../src/extractors.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 });
|
||||
}
|
||||
});
|
||||
|
||||
test("compaction hook sets output.prompt with ---free template", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const client = mockRootClient();
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
|
||||
|
||||
// Create a session state with some data
|
||||
await saveSessionState(tmpDir, {
|
||||
version: 1,
|
||||
sessionID: "test-session-compaction",
|
||||
turn: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
activeFiles: [{ path: "/src/index.ts", action: "edit", count: 5, lastSeen: Date.now() }],
|
||||
openErrors: [],
|
||||
recentDecisions: [{ text: "Test decision", rationale: "Testing", source: "user", createdAt: Date.now() }],
|
||||
});
|
||||
|
||||
// Call the compaction hook
|
||||
const output = { context: [] as string[] };
|
||||
await (plugin as Record<string, Function>)["experimental.session.compacting"](
|
||||
{ sessionID: "test-session-compaction" },
|
||||
output
|
||||
);
|
||||
|
||||
// Should set output.prompt and clear output.context
|
||||
const prompt = (output as Record<string, unknown>).prompt as string | undefined;
|
||||
assert.ok(prompt, "output.prompt should be set");
|
||||
assert.equal(typeof prompt, "string", "output.prompt should be a string");
|
||||
assert.equal(output.context.length, 0, "output.context should be cleared after setting prompt");
|
||||
|
||||
// Should NOT contain YAML frontmatter separators (--- at start)
|
||||
assert.equal(prompt!.includes("\n---"), false,
|
||||
"Prompt should not contain --- separators on their own line");
|
||||
|
||||
// Should NOT contain XML-like tags
|
||||
assert.equal(prompt!.includes("<workspace_memory>"), false);
|
||||
assert.equal(prompt!.includes("</workspace_memory>"), false);
|
||||
assert.equal(prompt!.includes("<hot_session_state>"), false);
|
||||
assert.equal(prompt!.includes("<pending_todos>"), false);
|
||||
|
||||
// Should NOT contain HTML comments
|
||||
assert.equal(prompt!.includes("<!--"), false);
|
||||
|
||||
// Should contain the ---free template heading
|
||||
assert.equal(prompt!.includes("## Goal"), true,
|
||||
"Prompt should use ## Goal heading, not --- separators");
|
||||
|
||||
// Should contain formatting rules that explicitly forbid ---
|
||||
assert.equal(prompt!.includes("Do not output YAML frontmatter"), true,
|
||||
"Prompt should explicitly forbid YAML frontmatter");
|
||||
assert.equal(prompt!.includes("horizontal rules"), true,
|
||||
"Prompt should explicitly forbid horizontal rules");
|
||||
|
||||
// Should contain Memory candidates format
|
||||
assert.equal(prompt!.includes("Memory candidates:"), true,
|
||||
"Prompt should include Memory candidates: label");
|
||||
|
||||
// Should contain our context data (hot session state)
|
||||
assert.equal(prompt!.includes("Hot session state"), true,
|
||||
"Prompt should include hot session state context");
|
||||
|
||||
// Verify: prompt starts with plain text, not a markup delimiter
|
||||
assert.equal(prompt!.startsWith("---"), false,
|
||||
"Prompt should not start with --- (YAML frontmatter)");
|
||||
assert.equal(prompt!.startsWith("##"), false,
|
||||
"Prompt should start with plain instructions, not a heading");
|
||||
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("compaction hook merges existing output.context from other plugins", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
try {
|
||||
const client = mockRootClient();
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
|
||||
|
||||
// Simulate another plugin having pushed context first
|
||||
const output = { context: ["Other plugin context data"] };
|
||||
await (plugin as Record<string, Function>)["experimental.session.compacting"](
|
||||
{ sessionID: "test-merge-context" },
|
||||
output
|
||||
);
|
||||
|
||||
const prompt = (output as Record<string, unknown>).prompt as string | undefined;
|
||||
assert.ok(prompt, "output.prompt should be set");
|
||||
assert.equal(output.context.length, 0, "output.context should be cleared");
|
||||
|
||||
// Should contain the other plugin's context
|
||||
assert.equal(prompt!.includes("Other plugin context data"), true,
|
||||
"Prompt should preserve context from other plugins");
|
||||
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts Markdown section format", async () => {
|
||||
const summary = `
|
||||
## Summary
|
||||
Progress made on testing.
|
||||
|
||||
## Memory Candidates
|
||||
- [decision] Use Markdown sections for candidates
|
||||
- [project] This repo uses Markdown for docs
|
||||
|
||||
Next steps: continue development.
|
||||
`;
|
||||
|
||||
const candidates = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(candidates.length, 2, "Should parse Markdown section format");
|
||||
assert.equal(candidates[0].type, "decision");
|
||||
assert.equal(candidates[1].type, "project");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts legacy Workspace Memory Candidates section", async () => {
|
||||
const summary = `
|
||||
## Summary
|
||||
Progress made on testing.
|
||||
|
||||
## Workspace Memory Candidates
|
||||
- [reference] Check docs at README.md
|
||||
|
||||
## Next Steps
|
||||
Continue development.
|
||||
`;
|
||||
|
||||
const candidates = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(candidates.length, 1, "Should parse legacy section format");
|
||||
assert.equal(candidates[0].type, "reference");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates still accepts legacy XML format", async () => {
|
||||
const summary = `
|
||||
## Summary
|
||||
Progress made on testing.
|
||||
|
||||
<workspace_memory_candidates>
|
||||
- [feedback] Users prefer darker themes
|
||||
</workspace_memory_candidates>
|
||||
|
||||
Next steps: continue development.
|
||||
`;
|
||||
|
||||
const candidates = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(candidates.length, 1, "Should parse legacy XML format");
|
||||
assert.equal(candidates[0].type, "feedback");
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create an entry with a createdAt offset from now (negative = in the past) */
|
||||
function agedEntry(
|
||||
id: string,
|
||||
text: string,
|
||||
type: LongTermMemoryEntry["type"] = "decision",
|
||||
opts: { daysAgo: number; source?: "compaction" | "explicit" | "manual"; staleAfterDays?: number } = { daysAgo: 0, source: "compaction" },
|
||||
): LongTermMemoryEntry {
|
||||
const createdAt = new Date(Date.now() - opts.daysAgo * 86400000).toISOString();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
text,
|
||||
source: opts.source ?? "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
staleAfterDays: opts.staleAfterDays,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Task 2: renderWorkspaceMemory tests
|
||||
// ============================================
|
||||
|
||||
test("renderWorkspaceMemory respects budget and fits entries", () => {
|
||||
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.includes("<workspace_memory>"),
|
||||
"Should not contain XML tags");
|
||||
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 small budget", () => {
|
||||
// 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.includes("<workspace_memory>"),
|
||||
"Should not contain XML tags");
|
||||
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}`);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// P0d: identity-key dedup, supersession, staleness
|
||||
// ============================================
|
||||
|
||||
test("enforceLongTermLimits project: bilingual variants collapse to one", () => {
|
||||
// All three mention opencode-agenthub plugin system - should merge
|
||||
const entries = [
|
||||
agedEntry("p1", "此 repo 在開發時使用 opencode-agenthub 插件系統,目錄位於 /Users/sd_wo/work/opencode-working-memory/.opencode-agenthub/", "project", { daysAgo: 2 }),
|
||||
agedEntry("p2", "此 repo 在開發時使用 opencode-agenthub 插件系統", "project", { daysAgo: 1 }),
|
||||
agedEntry("p3", "This repo uses opencode-agenthub plugin system at /Users/sd_wo/work/opencode-working-memory/", "project", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const projectEntries = kept.filter(e => e.type === "project");
|
||||
assert.equal(projectEntries.length, 1, "All three project variants should merge to one");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits reference: same config path variants collapse to one", () => {
|
||||
const entries = [
|
||||
agedEntry("r1", "OpenCode plugin config location: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 1 }),
|
||||
agedEntry("r2", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const refEntries = kept.filter(e => e.type === "reference");
|
||||
assert.equal(refEntries.length, 1, "Both reference variants should merge to one");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits decision: newer supersedes older on same topic", () => {
|
||||
// "4 formats" supersedes "3 formats" on the same parser topic
|
||||
const entries = [
|
||||
agedEntry("d1", "Parser supports 3 formats: HTML comment, Markdown section, legacy XML", "decision", { daysAgo: 2 }),
|
||||
agedEntry("d2", "Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML", "decision", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const decisionEntries = kept.filter(e => e.text.includes("formats"));
|
||||
assert.equal(decisionEntries.length, 1, "Newer 4-formats should supersede older 3-formats");
|
||||
assert.ok(decisionEntries[0].text.includes("4 formats"), "Kept entry should be the 4-formats one");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits feedback: newer supersedes older on same issue", () => {
|
||||
const entries = [
|
||||
agedEntry("f1", "Purple/italic text issue resolved by using plain text labels instead of any special markup syntax", "feedback", { daysAgo: 2 }),
|
||||
agedEntry("f2", "Purple/italic text issue resolved by replacing default compaction template with ---free version using only Markdown headings", "feedback", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const feedbackEntries = kept.filter(e => e.type === "feedback");
|
||||
assert.equal(feedbackEntries.length, 1, "Newer purple/italic fix should supersede older");
|
||||
assert.ok(feedbackEntries[0].text.includes("replacing default compaction template"), "Kept entry should be the newer fix");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits stale: compaction entry older than staleAfterDays+grace is pruned", () => {
|
||||
// decision with staleAfterDays=45, 76 days old (> 45+30 grace=75)
|
||||
const entries = [
|
||||
agedEntry("stale", "Compaction output contract changed from XML to HTML comments to avoid Markdown rendering issues", "decision", { daysAgo: 76, staleAfterDays: 45 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 0, "Stale compaction entry should be pruned");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits stale: explicit entry is retained even if old", () => {
|
||||
// explicit entry - never auto-pruned regardless of age
|
||||
const entries = [
|
||||
agedEntry("old_explicit", "User explicitly set Admin PIN 456123 for the system", "reference", { daysAgo: 500, source: "explicit", staleAfterDays: 90 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 1, "Explicit entry should never be age-pruned");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits stale: feedback entry is retained regardless of age", () => {
|
||||
// feedback - never age-pruned (only superseded)
|
||||
const entries = [
|
||||
agedEntry("old_feedback", "Users prefer darker themes over light themes", "feedback", { daysAgo: 300, staleAfterDays: 30 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 1, "Feedback entry should never be age-pruned");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits stale: compaction entry within grace period is retained", () => {
|
||||
// decision staleAfterDays=45, 60 days old (< 45+30=75 grace) - should keep
|
||||
const entries = [
|
||||
agedEntry("within_grace", "Some compaction decision made two months ago", "decision", { daysAgo: 60, staleAfterDays: 45 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 1, "Entry within grace period should be retained");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits dedup before trim: cleanup runs before maxEntries slice", () => {
|
||||
// 30 entries that should dedupe to < 28, confirming trim doesn't run before dedupe
|
||||
const entries = [
|
||||
...Array.from({ length: 15 }, (_, i) =>
|
||||
agedEntry(`a${i}`, "opencode uses npm cache for plugin loading", "decision", { daysAgo: 0 })
|
||||
),
|
||||
...Array.from({ length: 15 }, (_, i) =>
|
||||
agedEntry(`b${i}`, "opencode uses npm cache for plugin loading", "decision", { daysAgo: 0 })
|
||||
),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 1, "All duplicates should merge to 1 entry, far below maxEntries");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits priority: freshness used as tie-breaker among same priority entries", () => {
|
||||
// Same type, same source, same confidence — newer should win
|
||||
const older = agedEntry("older", "Some durable configuration fact about the workspace", "reference", { daysAgo: 30, source: "compaction", staleAfterDays: 90 });
|
||||
const newer = agedEntry("newer", "Some durable configuration fact about the workspace", "reference", { daysAgo: 5, source: "compaction", staleAfterDays: 90 });
|
||||
|
||||
const kept = enforceLongTermLimits([older, newer]);
|
||||
assert.equal(kept.length, 1);
|
||||
assert.equal(kept[0].id, "newer", "Newer entry should win as tie-breaker");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits feedback: 500 error and port issue are NOT collapsed", () => {
|
||||
// Distinct feedback entries should remain separate
|
||||
const entries = [
|
||||
agedEntry("f1", "瀏覽器登入出現 500 internal_error,代碼邏輯正確但原因不明", "feedback", { daysAgo: 0 }),
|
||||
agedEntry("f2", "端口 9473 可能被舊進程佔用,需殺掉後重啟", "feedback", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const feedbackEntries = kept.filter(e => e.type === "feedback");
|
||||
assert.equal(feedbackEntries.length, 2, "Distinct feedback items should not collapse");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits config: unrelated plugin configs are NOT collapsed", () => {
|
||||
const entries = [
|
||||
agedEntry("c1", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 0 }),
|
||||
agedEntry("c2", "Vite plugin config location: vite.config.ts at project root", "reference", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const refEntries = kept.filter(e => e.type === "reference");
|
||||
assert.equal(refEntries.length, 2, "Unrelated plugin configs should remain separate");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits supersession: newer shorter decision beats older longer one", () => {
|
||||
// Same topic, same source, same confidence — newer wins even if shorter
|
||||
const older = agedEntry("d1", "Parser supports 3 formats: HTML comment, Markdown section, legacy XML with backward compatibility", "decision", { daysAgo: 5 });
|
||||
const newer = agedEntry("d2", "Parser supports 4 formats", "decision", { daysAgo: 0 });
|
||||
|
||||
const kept = enforceLongTermLimits([older, newer]);
|
||||
const decisions = kept.filter(e => e.type === "decision" && /parser.*format/i.test(e.text));
|
||||
assert.equal(decisions.length, 1, "Newer shorter decision should supersede older longer one");
|
||||
assert.ok(decisions[0].text.includes("4 formats"), "Kept entry should be the newer 4-formats");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits feedback: English port issue does NOT collapse with server error", () => {
|
||||
const entries = [
|
||||
agedEntry("e1", "Browser login 500 internal_error, code correct but cause unknown", "feedback", { daysAgo: 0 }),
|
||||
agedEntry("e2", "Port 9473 occupied by old process, may need to kill and restart", "feedback", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const feedbackEntries = kept.filter(e => e.type === "feedback");
|
||||
assert.equal(feedbackEntries.length, 2, "English port issue and server error should remain separate");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits config: unrelated generic plugin configs do NOT collapse", () => {
|
||||
const entries = [
|
||||
agedEntry("c1", "Vite plugin config location: vite.config.ts at project root", "reference", { daysAgo: 0 }),
|
||||
agedEntry("c2", "ESLint plugin config location: eslint.config.js at project root", "reference", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const refEntries = kept.filter(e => e.type === "reference");
|
||||
assert.equal(refEntries.length, 2, "Unrelated plugin configs without entity key should remain separate");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits feedback: supersession prefers newer shorter over older longer", () => {
|
||||
// Same purple/italic issue, newer shorter fix supersedes older verbose fix
|
||||
const older = agedEntry("f1", "Purple/italic text issue resolved by using plain text labels instead of any special markup syntax in the prompt", "feedback", { daysAgo: 5 });
|
||||
const newer = agedEntry("f2", "Purple/italic text fixed via template replacement", "feedback", { daysAgo: 0 });
|
||||
|
||||
const kept = enforceLongTermLimits([older, newer]);
|
||||
const feedbackEntries = kept.filter(e => e.type === "feedback");
|
||||
assert.equal(feedbackEntries.length, 1, "Newer shorter feedback should supersede older longer");
|
||||
assert.ok(feedbackEntries[0].text.includes("template replacement"), "Kept entry should be the newer fix");
|
||||
});
|
||||
+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