commit 32d5ddfb50d6f7b1487ee7adcb8ae3f8837860a0 Author: Ralph Chang Date: Wed Feb 18 09:49:09 2026 +0800 Initial commit: OpenCode Working Memory Plugin v1.0.0 - Four-tier memory architecture (Core, Working, Pruning, Pressure) - Phase 1: Core Memory blocks (goal/progress/context) - Phase 2: Smart Pruning with adaptive thresholds - Phase 3: Working Memory with slots + pool decay - Phase 4: Pressure monitoring with interventions - Phase 4.5: Storage governance (session cleanup + cache sweep) - Complete documentation (README, AGENTS, installation, architecture, configuration) - MIT licensed diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30e153d --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +coverage/ +.nyc_output/ + +# Environment +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log + +# Temporary files +tmp/ +temp/ +*.tmp + +# Package files +*.tgz +package-lock.json +yarn.lock +pnpm-lock.yaml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6f49b9b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,340 @@ +# AGENTS.md - OpenCode Working Memory Plugin Development Guide + +## 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 + +Written in **TypeScript** for the OpenCode agent environment. + +## Installation + +```bash +# For development +git clone https://github.com/yourusername/opencode-working-memory.git +cd opencode-working-memory +npm install + +# For usage (see README.md) +``` + +## Build & Development Commands + +### Type Checking +```bash +# TypeScript strict mode - fix all type errors before committing +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 +``` + +### 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 + ├── installation.md + ├── architecture.md + └── configuration.md +``` + +## Code Style Guidelines + +### TypeScript Strict Mode + +```typescript +// ✅ REQUIRED: Full type annotations, no implicit any +async function loadCoreMemory( + directory: string, + sessionID: string +): Promise + +// ❌ AVOID: Implicit any types +async function loadCoreMemory(directory, sessionID) { } +``` + +### Type Definitions + +```typescript +// ✅ REQUIRED: Define types at module top +type CoreMemory = { + sessionID: string; + blocks: { + goal: CoreBlock; + progress: CoreBlock; + context: CoreBlock; + }; + updatedAt: string; +}; + +// ✅ USE: Union types for variants (not enums) +type PressureLevel = "safe" | "moderate" | "high" | "critical"; + +// ✅ USE: Record<> for keyed configs +const SLOT_CONFIG: Record = { + error: 3, + decision: 5, + todo: 3, + dependency: 3, +}; +``` + +### Imports & Module Organization + +```typescript +// ✅ REQUIRED: Group and order imports +// 1. Node.js built-ins +import { existsSync } from "fs"; +import { mkdir, readFile, writeFile } from "fs/promises"; +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) +``` + +### Naming Conventions + +```typescript +// ✅ REQUIRED: camelCase for variables & functions +const maxItems = 50; +async function loadCoreMemory() { } + +// ✅ 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 }; + +// ✅ REQUIRED: PascalCase for types +type CoreMemory = { ... }; +type WorkingMemoryItem = { ... }; + +// ✅ REQUIRED: get*/set*/load*/save* naming for file operations +function getCoreMemoryPath(directory: string, sessionID: string): string { } +async function loadCoreMemory(directory: string, sessionID: string): Promise { } +async function saveCoreMemory(directory: string, memory: CoreMemory): Promise { } + +// ✅ REQUIRED: ensure*/validate* for pre-checks +async function ensureCoreMemoryDir(directory: string): Promise { } + +// ✅ REQUIRED: Prefix private/internal functions with _ +function _compressPath(filePath: string): string { } +``` + +### Function Signatures & Organization + +```typescript +// ✅ REQUIRED: Parameters on separate lines if > 80 chars +async function loadWorkingMemory( + directory: string, + sessionID: string +): Promise { + // ... +} + +// ✅ REQUIRED: Explicit return types (no inference) +function getCompactionLogPath(directory: string, sessionID: string): string { + return join(directory, ".opencode", "memory-working", `${sessionID}_compaction.json`); +} + +// ✅ REQUIRED: Async for file/network I/O +async function saveCoreMemory(directory: string, memory: CoreMemory): Promise { + // ... +} +``` + +### Error Handling + +```typescript +// ✅ REQUIRED: Try-catch with descriptive console.error +async function loadCoreMemory(directory: string, sessionID: string): Promise { + const path = getCoreMemoryPath(directory, sessionID); + if (!existsSync(path)) return null; + + try { + const content = await readFile(path, "utf-8"); + return JSON.parse(content) as CoreMemory; + } catch (error) { + console.error("Failed to load core memory:", error); + return null; // Graceful degradation + } +} + +// ✅ 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 +``` + +### Comments & Documentation + +```typescript +// ✅ REQUIRED: Section headers for major sections +// ============================================================================ +// Phase 1: Core Memory Foundation +// ============================================================================ + +// ✅ REQUIRED: Block comments for complex logic +// Migration: Convert old format (items array) to new format (slots + pool) +if (data.items && !data.slots) { + // ... migration logic +} + +// ✅ 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; +``` + +## Key Implementation Details + +### Core Memory Files +- Location: `.opencode/memory-core/.json` +- Schema: `{ sessionID, blocks: { goal, progress, context }, updatedAt }` +- Limits: goal (1000 chars), progress (2000 chars), context (1500 chars) + +### Working Memory Files +- Location: `.opencode/memory-working/.json` +- Schema: `{ sessionID, slots, pool, eventCounter, updatedAt }` +- Slot limits: error (3), decision (5), todo (3), dependency (3) +- Pool decay: γ=0.85 per event + +### Pressure Monitoring +- Triggers at: 70% (safe→moderate), 85% (moderate→high), 95% (high→critical) +- Files: `.opencode/memory-working/_pressure.json` +- Intervention: Sends `promptAsync()` with complete visible prompt + +### 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) + +## 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 + +### Common Issues +- **File not found**: Ensure `.opencode/` directory 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 `.json`, should trigger at multiples of 500 + +## 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` + +## 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: ..."` +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 + +--- + +**Last Updated**: February 2026 +**Plugin Status**: Production (Phases 1-4.5 complete) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a231a6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenCode Working Memory Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b388bd2 --- /dev/null +++ b/README.md @@ -0,0 +1,242 @@ +# OpenCode Working Memory Plugin + +[![npm version](https://img.shields.io/npm/v/opencode-working-memory.svg)](https://www.npmjs.com/package/opencode-working-memory) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +**Advanced four-tier memory architecture that keeps your AI agent sharp, focused, and never forgets what matters.** + +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. + +## 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 + +## Installation + +### For Humans + +**Option A: Let an LLM do it** + +Paste this into Claude Code, Cursor, or any AI agent: + +``` +Install opencode-working-memory plugin by following: +https://raw.githubusercontent.com/yourusername/opencode-working-memory/main/docs/installation.md +``` + +**Option B: Manual setup** + +1. Add to your `~/.config/opencode/opencode.json`: + +```json +{ + "plugin": ["opencode-working-memory"] +} +``` + +2. Restart OpenCode + +That's it! The plugin is now active. + +### For LLM Agents + +1. Read the OpenCode configuration file at `~/.config/opencode/opencode.json` +2. Add `"opencode-working-memory"` to the `plugin` array +3. Verify installation by checking the config file + +## Quick Start + +The plugin works automatically once installed. No configuration needed. + +**Use the tools:** + +```bash +# Update persistent memory +opencode run "use core_memory_update to set my goal" + +# Read current memory state +opencode run "use core_memory_read to show me what you remember" + +# Add important items to working memory +opencode run "use working_memory_add to remember this file path" +``` + +**The agent will automatically:** +- Track memory pressure and warn when approaching compaction +- Preserve important context during compaction +- Clean up old tool-output cache files every 20 tool calls +- Remove artifacts when sessions are deleted + +## Features + +### 🧠 Core Memory (Phase 1) + +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 (Phase 3) + +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 (Phase 4) + +Real-time token tracking from session database: + +- Monitors context window usage (75% → 90% → 95% thresholds) +- Proactive intervention messages when pressure is high +- Pressure-aware smart pruning (adapts compression based on pressure) + +### 🧹 Storage Governance (Phase 5) + +Prevents unbounded disk growth: + +- **Layer 1**: Auto-cleanup on session deletion (all artifacts removed) +- **Layer 2**: Active cache management (max 300 files/session, 7-day TTL) +- Triggers every 20 tool calls +- Silent background operation + +### 📊 Smart Pruning (Phase 2) + +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 + +## How It Works + +``` +┌─────────────────────────────────────────────────────────┐ +│ 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% / 90% / 95% │ +│ • 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 │ +└─────────────────────────────────────────────────────────┘ +``` + +## Why This Plugin? + +**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 + +**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 + +## Configuration (Optional) + +The plugin works great with zero configuration. But if you want to customize: + +Create `~/.config/opencode/working-memory.json`: + +```json +{ + "storage_governance": { + "tool_output_max_files": 300, + "tool_output_max_age_ms": 604800000, + "sweep_interval": 20 + }, + "memory_pressure": { + "thresholds": { + "moderate": 0.75, + "high": 0.90, + "critical": 0.95 + } + } +} +``` + +See [Configuration Guide](docs/configuration.md) for all options. + +## Requirements + +- OpenCode >= 1.0.0 +- Node.js >= 18.0.0 +- `@opencode-ai/plugin` >= 1.2.0 + +## License + +MIT License - see [LICENSE](LICENSE) file for details. + +## Contributing + +Contributions welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first. + +## Support + +- 📖 [Documentation](docs/) +- 🐛 [Report Issues](https://github.com/yourusername/opencode-working-memory/issues) +- 💬 [Discussions](https://github.com/yourusername/opencode-working-memory/discussions) + +## Credits + +Inspired by the needs of real-world OpenCode usage and built to solve actual pain points in AI-assisted development. + +--- + +**Made with ❤️ for the OpenCode community** diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..514deef --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,376 @@ +# Architecture Documentation + +## Overview + +The Working Memory Plugin implements a **four-tier memory architecture** designed to maximize context efficiency for AI agents in OpenCode sessions. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TIER 1: CORE MEMORY │ +│ Persistent blocks: goal (1000) | progress (2000) | context (1500) │ +│ Survives compaction, always visible in system prompt │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 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 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 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 → critical │ +│ Thresholds: 70% | 85% | 95% │ +│ Intervention: Sends promptAsync() with full visible prompt │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Phase 1: Core Memory Foundation + +### Purpose +Provide persistent memory blocks that survive conversation compaction and are always injected into the system prompt. + +### Storage +- **Location**: `.opencode/memory-core/.json` +- **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; + } + ``` + +### Character Limits +- **goal**: 1000 chars (ONE specific task) +- **progress**: 2000 chars (done/in-progress/blocked checklist) +- **context**: 1500 chars (current working files + key patterns) + +### Operations +- **replace**: Completely replace block content +- **append**: Add content to end (auto-adds newline) + +### Tools +- `core_memory_update`: Update or append to blocks +- `core_memory_read`: Read current state of all blocks + +### System Prompt Injection +Blocks are injected into every agent message as: +``` + +... +... +... + +``` + +## Phase 2: Smart Pruning + +### Purpose +Reduce context bloat by filtering tool outputs before they enter the conversation history. + +### Pruning Modes + +#### Normal Mode (Pressure < 85%) +- 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 (85% ≤ Pressure < 95%) +- Threshold drops to 30 lines +- More aggressive truncation (first/last 20 lines) +- Filter repetitive content + +#### Hyper-Aggressive Mode (Pressure ≥ 95%) +- 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. + +### Storage +- **Location**: `.opencode/memory-working/.json` +- **Schema**: + ```typescript + { + sessionID: string; + slots: { + error: Array; // Max 3 + decision: Array; // Max 5 + todo: Array; // Max 3 + dependency: Array; // Max 3 + }; + pool: Array; + eventCounter: number; + updatedAt: string; + } + ``` + +### Slot Types + +| 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 | + +### Memory Pool + +General-purpose storage with **exponential decay**: + +```typescript +score = exp(-γ * age) + mentionCount +``` + +Where: +- `γ = 0.85` (decay rate, 15% per event) +- `age = eventCounter - item.eventNumber` +- `mentionCount`: Number of times item mentioned in conversation + +Items with `score < 0.01` are pruned. + +### Auto-Extraction + +Working memory items are **automatically extracted** from: +- Tool outputs (file paths, errors, dependencies) +- User messages (decisions, todos) +- Assistant responses (key information) + +### Manual Management + +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 + +### System Prompt Injection + +``` + +Recent session context (auto-managed, sorted by relevance): + +⚠️ Errors: + - TypeError at line 42 in utils.ts + - Missing import in index.ts + +📁 Key Files: + - src/components/Button.tsx + - src/utils/helpers.ts + +(15 items shown, updated: 9:46:47 AM) + +``` + +## Phase 4: Pressure Monitoring + +### Purpose +Track conversation context usage and trigger interventions when approaching limits. + +### Pressure Calculation + +```typescript +pressure = (visiblePromptChars / estimatedContextLimit) * 100 +``` + +Where: +- `visiblePromptChars`: Total characters in system prompt + tool outputs +- `estimatedContextLimit`: ~180,000 chars (conservative estimate) + +### Pressure Levels + +| Level | Threshold | Behavior | +|-------|-----------|----------| +| **safe** | < 70% | Normal operation | +| **moderate** | 70-84% | Warning in system prompt | +| **high** | 85-94% | Aggressive pruning + warning | +| **critical** | ≥ 95% | Hyper-aggressive pruning + intervention | + +### Pressure Storage + +- **Location**: `.opencode/memory-working/_pressure.json` +- **Schema**: + ```typescript + { + sessionID: string; + level: "safe" | "moderate" | "high" | "critical"; + percentage: number; + visiblePromptChars: number; + estimatedLimit: 180000; + lastChecked: string; + interventionsSent: number; + } + ``` + +### Intervention Mechanism + +When pressure reaches **critical** (≥95%): +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/.json` +2. Remove `.opencode/memory-working/.json` +3. Remove `.opencode/memory-working/_pressure.json` +4. Remove `.opencode/memory-working/_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/_sweep.json` + +```typescript +{ + sessionID: string; + timestamp: string; + eventCounter: number; + results: { + filesScanned: number; + filesDeleted: number; + bytesReclaimed: number; + errors: Array; + }; +} +``` + +## 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 `_compaction.json` + +### 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 + +## 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 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: +```typescript +const PRESSURE_THRESHOLDS = { + moderate: 70, + high: 85, + critical: 95, +}; +``` + +## Migration & Compatibility + +### Old Format → New Format +Plugin automatically migrates from old format: +```typescript +// Old format (pre-Phase 3) +{ items: Array } + +// New format (Phase 3+) +{ slots: Record>, pool: Array } +``` + +Migration happens on first load of old format files. + +## File System Layout + +``` +.opencode/ +├── memory-core/ +│ └── .json # Core memory blocks +├── memory-working/ +│ ├── .json # Working memory (slots + pool) +│ ├── _pressure.json # Pressure monitoring state +│ ├── _compaction.json # Compaction event log +│ └── _sweep.json # Storage sweep log +└── cache/ + └── tool-outputs/ + └── *.json # Tool output cache (auto-swept) +``` + +## Security Considerations + +- 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 + +--- + +**Last Updated**: February 2026 +**Implementation**: `index.ts` (1700+ lines) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..4e69a5d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,376 @@ +# Configuration Guide + +## Overview + +The Working Memory Plugin works out-of-the-box with sensible defaults. Advanced users can customize behavior by modifying constants in `index.ts`. + +## Core 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 +}; +``` + +**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) + +## Working Memory Configuration + +### Slot Limits + +```typescript +const SLOT_CONFIG: Record = { + error: 3, // Recent errors needing fixes + decision: 5, // Important decisions made + todo: 3, // Current task checklist + dependency: 3, // File/package dependencies +}; +``` + +**Tuning**: +- Increase slot limits if you need more items tracked +- Decrease for stricter memory budgets +- Total overhead: ~100-200 chars per item + +### Memory Pool Decay + +```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 +``` + +**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 + +```typescript +const POOL_MAX_ITEMS = 50; // Hard limit on pool size +``` + +**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 + +```typescript +const PRESSURE_THRESHOLDS = { + moderate: 70, // Warning appears in system prompt + high: 85, // Aggressive pruning activates + critical: 95, // Intervention sent to agent +}; +``` + +**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 < 85%) +const PRUNE_THRESHOLD_NORMAL = 50; + +// Aggressive mode (85% ≤ pressure < 95%) +const PRUNE_THRESHOLD_AGGRESSIVE = 30; + +// Hyper-aggressive mode (pressure ≥ 95%) +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: + +... +... +... + +``` + +**Customization**: Modify `formatCoreMemoryForPrompt()` in `index.ts` to change format. + +### Working Memory Format + +```typescript +// Injected as: + +Recent session context (auto-managed, sorted by relevance): + +⚠️ Errors: + - item content + +📁 Key Files: + - file path + +(N items shown, updated: HH:MM:SS AM) + +``` + +**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 +``` + +## Performance Tuning + +### 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; +``` + +### 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; +``` + +### Memory-Constrained Environments + +```typescript +// Strict limits +const CORE_MEMORY_LIMITS = { + goal: 500, + progress: 1000, + context: 800, +}; + +const POOL_MAX_ITEMS = 20; + +// Aggressive pruning +const PRUNE_THRESHOLD_NORMAL = 20; +``` + +## 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); +``` + +### Inspect Memory Files + +```bash +# Core memory +cat .opencode/memory-core/.json | jq + +# Working memory +cat .opencode/memory-working/.json | jq + +# Pressure state +cat .opencode/memory-working/_pressure.json | jq + +# Sweep log +cat .opencode/memory-working/_sweep.json | jq +``` + +## Migration Notes + +### Upgrading from Pre-Phase 3 + +Old format files are automatically migrated: + +```typescript +// Old format +{ items: Array } + +// New format (auto-migrated) +{ slots: { error: [], decision: [], ... }, pool: [...] } +``` + +No manual intervention required. + +### Upgrading from Phase 3 to Phase 4.5 + +Storage governance is backward compatible. No migration needed. + +## 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` + +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`) + +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 + +--- + +**Last Updated**: February 2026 +**Configuration File**: `index.ts` (constants section) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..28c73cb --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,131 @@ +# Installation Guide + +## Prerequisites + +- **OpenCode** 1.0.0 or higher +- **Node.js** 18+ (for development only) + +## Quick Install (For Users) + +### Option 1: Install from npm (Recommended) + +```bash +npm install opencode-working-memory +``` + +Then add to your `.opencode/package.json`: + +```json +{ + "plugins": [ + "opencode-working-memory" + ] +} +``` + +### Option 2: Install from GitHub + +Add to your `.opencode/package.json`: + +```json +{ + "dependencies": { + "opencode-working-memory": "github:yourusername/opencode-working-memory" + }, + "plugins": [ + "opencode-working-memory" + ] +} +``` + +Then run: + +```bash +cd .opencode +npm install +``` + +### Option 3: Local Development Install + +Clone the repository: + +```bash +git clone https://github.com/yourusername/opencode-working-memory.git +cd opencode-working-memory +npm install +``` + +Link to your OpenCode project: + +```bash +cd /path/to/your/project/.opencode +npm link /path/to/opencode-working-memory +``` + +Add to `.opencode/package.json`: + +```json +{ + "plugins": [ + "opencode-working-memory" + ] +} +``` + +## Verification + +After installation, start an OpenCode session and run: + +``` +core_memory_update goal "Test installation" +``` + +You should see a success message. Check `.opencode/memory-core/` for the session file. + +## Configuration + +The plugin works out-of-the-box with sensible defaults. For advanced configuration, see [configuration.md](./configuration.md). + +## Troubleshooting + +### Plugin Not Loading + +**Symptom**: No `core_memory_update` tool available + +**Solution**: +1. Check `.opencode/package.json` includes plugin in `"plugins": []` array +2. Verify `npm install` completed successfully +3. Restart OpenCode session + +### Memory Files Not Created + +**Symptom**: No `.opencode/memory-core/` or `.opencode/memory-working/` directories + +**Solution**: +1. Ensure OpenCode has write permissions in project directory +2. Check plugin hooks are registered (look for "Working Memory Plugin" in session logs) +3. Trigger memory operations (e.g., use `core_memory_update` tool) + +### Type Errors During Development + +**Symptom**: TypeScript errors when modifying plugin + +**Solution**: +1. Ensure `@opencode-ai/plugin` is installed: `npm install @opencode-ai/plugin` +2. Run type checking: `npx tsc --noEmit` +3. See [AGENTS.md](../AGENTS.md) for code style guidelines + +## Uninstallation + +```bash +cd .opencode +npm uninstall opencode-working-memory +``` + +Remove from `.opencode/package.json` plugins array. Memory files in `.opencode/memory-*` will persist unless manually deleted. + +## Next Steps + +- Read [Architecture Documentation](./architecture.md) to understand how memory tiers work +- See [Configuration Guide](./configuration.md) for customization options +- Check [AGENTS.md](../AGENTS.md) for development guidelines diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..4951203 --- /dev/null +++ b/index.ts @@ -0,0 +1,2082 @@ +/** + * Working Memory Plugin for OpenCode + * + * Provides a three-tier memory system to delay/avoid compaction: + * 1. Core Memory - Persistent goal/progress/context blocks (always in-context) + * 2. Working Memory - Auto-managed session-relevant information + * 3. Smart Pruning - Content-aware tool output compression + * 4. Memory Pressure Monitoring - Context usage tracking with adaptive warnings + * + * Phase 1: Core Memory Foundation (MVP) - ✅ COMPLETED + * Phase 2: Smart Pruning System - ✅ COMPLETED + * Phase 3: Working Memory Auto-Management - ✅ COMPLETED + * Phase 4: Memory Pressure Monitoring - ✅ COMPLETED + */ + +import type { Plugin } from "@opencode-ai/plugin"; +import { tool } from "@opencode-ai/plugin"; +import { existsSync } from "fs"; +import { mkdir, readFile, writeFile, readdir, stat, unlink, rm } from "fs/promises"; +import { join } from "path"; + +// ============================================================================ +// Types & Schemas +// ============================================================================ + +type CoreMemory = { + sessionID: string; + blocks: { + goal: CoreBlock; + progress: CoreBlock; + context: CoreBlock; + }; + updatedAt: string; +}; + +type CoreBlock = { + value: string; + charLimit: number; + lastModified: string; +}; + +const CORE_MEMORY_LIMITS = { + goal: 1000, // ~250 tokens + progress: 2000, // ~500 tokens + context: 1500, // ~375 tokens +}; + +// ============================================================================ +// Phase 2: Smart Pruning Types +// ============================================================================ + +type PruningStrategy = + | "keep-all" + | "keep-ends" + | "keep-last" + | "summarize" + | "discard"; + +type PruningRule = { + strategy: PruningStrategy; + firstChars?: number; + lastChars?: number; + maxChars?: number; +}; + +type CachedToolOutput = { + callID: string; + sessionID: string; + tool: string; + fullOutput: string; + timestamp: number; +}; + +// ============================================================================ +// Phase 3: Working Memory Types (Slot-based Architecture) +// ============================================================================ + +type WorkingMemory = { + sessionID: string; + slots: { + error: WorkingMemoryItem[]; // FIFO queue, max 3 items + decision: WorkingMemoryItem[]; // FIFO queue, max 5 items + todo: WorkingMemoryItem[]; // FIFO queue, max 3 items + dependency: WorkingMemoryItem[]; // FIFO queue, max 3 items + }; + pool: WorkingMemoryItem[]; // file-path, finding, other (exponential decay) + eventCounter: number; // Increments on every add operation + updatedAt: string; +}; + +type WorkingMemoryItem = { + id: string; + type: WorkingMemoryItemType; + content: string; + source: string; // e.g., "tool:read", "tool:bash", "manual" + timestamp: number; + relevanceScore: number; // Only used for pool items (decay-based scoring) + mentions: number; // How many times referenced + lastEventCounter?: number; // For pool items: tracks when score was last updated +}; + +type WorkingMemoryItemType = + | "file-path" // Important file paths discovered (pool) + | "error" // Errors encountered (slot) + | "decision" // Key decisions made (slot) + | "other"; // Misc important info (pool) + +// Slot-based types: guaranteed retention (FIFO) +type SlotType = "error" | "decision"; + +// Pool-based types: exponential decay +type PoolType = "file-path" | "other"; + +const SLOT_CONFIG: Record = { + error: 3, // Keep last 3 errors + decision: 3, // Keep last 3 decisions (FIFO, no human approval needed) +}; + +const POOL_CONFIG = { + maxItems: 50, // Maximum pool items + gamma: 0.85, // Decay rate (15% decay per event) + minScore: 0.01, // Remove items below this score +}; + +const WORKING_MEMORY_LIMITS = { + maxCharsPerItem: 200, // Max chars for each item + systemPromptBudget: 1600, // ~400 tokens for system prompt injection (doubled to show more items) +}; + +// ============================================================================ +// Storage Governance (Layer 1 + Layer 2) +// ============================================================================ + +const STORAGE_GOVERNANCE = { + toolOutputMaxFiles: 300, // Max tool-output files per session + toolOutputMaxAgeMs: 7 * 24 * 60 * 60 * 1000, // 7 days TTL + sweepInterval: 20, // Sweep every N tool calls +}; + +// ============================================================================ +// Phase 4: Memory Pressure Monitoring +// ============================================================================ + +type PressureLevel = "safe" | "moderate" | "high"; + +type ModelPressureInfo = { + sessionID: string; + modelID: string; + providerID: string; + limits: { + context: number; + input?: number; + output: number; + }; + calculated: { + maxOutputTokens: number; // min(model.limit.output, 32000) + reserved: number; // min(20000, maxOutputTokens) + usable: number; // input - reserved OR context - maxOutputTokens + }; + current: { + totalTokens: number; // sum of all message tokens + pressure: number; // totalTokens / usable (0.0 - 1.0+) + level: PressureLevel; + }; + thresholds: { + moderate: number; // usable * 0.75 + high: number; // usable * 0.90 + }; + updatedAt: string; +}; + +// Compaction tracking (preserved from Phase 4 initial work) +type CompactionLog = { + sessionID: string; + compactionCount: number; + lastCompaction: number | null; // timestamp + preservedItems: number; // how many working memory items were preserved + updatedAt: string; +}; + +// ============================================================================ +// Storage Management +// ============================================================================ + +function getCoreMemoryPath(directory: string, sessionID: string): string { + return join(directory, ".opencode", "memory-core", `${sessionID}.json`); +} + +function getToolOutputCachePath( + directory: string, + sessionID: string, + callID: string +): string { + return join( + directory, + ".opencode", + "memory-working", + "tool-outputs", + sessionID, + `${callID}.json` + ); +} + +async function ensureToolOutputCacheDir( + directory: string, + sessionID: string +): Promise { + const dir = join( + directory, + ".opencode", + "memory-working", + "tool-outputs", + sessionID + ); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +async function ensureCoreMemoryDir(directory: string): Promise { + const dir = join(directory, ".opencode", "memory-core"); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +function getWorkingMemoryPath(directory: string, sessionID: string): string { + return join(directory, ".opencode", "memory-working", `${sessionID}.json`); +} + +async function ensureWorkingMemoryDir(directory: string): Promise { + const dir = join(directory, ".opencode", "memory-working"); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +function getCompactionLogPath(directory: string, sessionID: string): string { + return join(directory, ".opencode", "memory-working", `${sessionID}_compaction.json`); +} + +function getModelPressurePath(directory: string, sessionID: string): string { + return join(directory, ".opencode", "memory-working", `${sessionID}_pressure.json`); +} + +async function loadCoreMemory( + directory: string, + sessionID: string +): Promise { + const path = getCoreMemoryPath(directory, sessionID); + if (!existsSync(path)) { + return null; + } + + try { + const content = await readFile(path, "utf-8"); + return JSON.parse(content) as CoreMemory; + } catch (error) { + console.error("Failed to load core memory:", error); + return null; + } +} + +async function saveCoreMemory( + directory: string, + memory: CoreMemory +): Promise { + await ensureCoreMemoryDir(directory); + const path = getCoreMemoryPath(directory, memory.sessionID); + await writeFile(path, JSON.stringify(memory, null, 2), "utf-8"); +} + +function createEmptyCoreMemory(sessionID: string): CoreMemory { + const now = new Date().toISOString(); + return { + sessionID, + blocks: { + goal: { + value: "", + charLimit: CORE_MEMORY_LIMITS.goal, + lastModified: now, + }, + progress: { + value: "", + charLimit: CORE_MEMORY_LIMITS.progress, + lastModified: now, + }, + context: { + value: "", + charLimit: CORE_MEMORY_LIMITS.context, + lastModified: now, + }, + }, + updatedAt: now, + }; +} + +async function loadWorkingMemory( + directory: string, + sessionID: string +): Promise { + const path = getWorkingMemoryPath(directory, sessionID); + if (!existsSync(path)) { + return null; + } + + try { + const content = await readFile(path, "utf-8"); + const data = JSON.parse(content); + + // Migration: Convert old format (items array) to new format (slots + pool) + if (data.items && !data.slots) { + console.log("[Working Memory] Migrating from old format to slot-based architecture..."); + const migrated: WorkingMemory = { + sessionID: data.sessionID, + slots: { + error: [], + decision: [], + todo: [], + dependency: [], + }, + pool: [], + eventCounter: 0, + updatedAt: new Date().toISOString(), + }; + + // Route each item to slot or pool + for (const item of data.items) { + const slotType = item.type as SlotType; + if (slotType in SLOT_CONFIG) { + // Slot-based item: add to appropriate slot (FIFO will be applied later) + migrated.slots[slotType].push(item); + } else { + // Pool-based item: add to pool + migrated.pool.push(item); + } + } + + // Apply FIFO limits to slots + for (const slotType of Object.keys(SLOT_CONFIG) as SlotType[]) { + const limit = SLOT_CONFIG[slotType]; + if (migrated.slots[slotType].length > limit) { + // Keep only the most recent items + migrated.slots[slotType] = migrated.slots[slotType] + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit); + } + } + + // Apply pool limits + if (migrated.pool.length > POOL_CONFIG.maxItems) { + migrated.pool = migrated.pool + .sort((a, b) => b.relevanceScore - a.relevanceScore) + .slice(0, POOL_CONFIG.maxItems); + } + + console.log(`[Working Memory] Migration complete: ${data.items.length} items -> ${Object.values(migrated.slots).flat().length} slot items + ${migrated.pool.length} pool items`); + + // Save migrated version + await saveWorkingMemory(directory, migrated); + return migrated; + } + + return data as WorkingMemory; + } catch (error) { + console.error("Failed to load working memory:", error); + return null; + } +} + +async function saveWorkingMemory( + directory: string, + memory: WorkingMemory +): Promise { + await ensureWorkingMemoryDir(directory); + const path = getWorkingMemoryPath(directory, memory.sessionID); + await writeFile(path, JSON.stringify(memory, null, 2), "utf-8"); +} + +function createEmptyWorkingMemory(sessionID: string): WorkingMemory { + return { + sessionID, + slots: { + error: [], + decision: [], + todo: [], + dependency: [], + }, + pool: [], + eventCounter: 0, + updatedAt: new Date().toISOString(), + }; +} + +async function loadCompactionLog( + directory: string, + sessionID: string +): Promise { + const path = getCompactionLogPath(directory, sessionID); + if (!existsSync(path)) { + return null; + } + + try { + const content = await Bun.file(path).text(); + return JSON.parse(content) as CompactionLog; + } catch { + return null; + } +} + +async function saveCompactionLog( + directory: string, + log: CompactionLog +): Promise { + await ensureWorkingMemoryDir(directory); + const path = getCompactionLogPath(directory, log.sessionID); + await Bun.write(path, JSON.stringify(log, null, 2)); +} + +function createInitialCompactionLog(sessionID: string): CompactionLog { + return { + sessionID, + compactionCount: 0, + lastCompaction: null, + preservedItems: 0, + updatedAt: new Date().toISOString(), + }; +} + +// ============================================================================ +// Core Memory Operations +// ============================================================================ + +function validateBlockContent( + block: keyof CoreMemory["blocks"], + content: string +): { valid: boolean; error?: string; truncated?: string } { + const limit = CORE_MEMORY_LIMITS[block]; + + if (content.length === 0) { + return { valid: true }; + } + + if (content.length <= limit) { + return { valid: true }; + } + + // Auto-truncate with warning + const truncated = content.slice(0, limit); + const charsRemoved = content.length - limit; + + return { + valid: false, + error: `Content exceeds ${block} block limit (${limit} chars). Truncated ${charsRemoved} chars.`, + truncated, + }; +} + +async function updateCoreMemoryBlock( + directory: string, + sessionID: string, + block: keyof CoreMemory["blocks"], + operation: "replace" | "append", + content: string +): Promise<{ success: boolean; message: string; memory?: CoreMemory }> { + let memory = await loadCoreMemory(directory, sessionID); + + if (!memory) { + memory = createEmptyCoreMemory(sessionID); + } + + let newValue: string; + if (operation === "replace") { + newValue = content; + } else { + // append + const currentValue = memory.blocks[block].value; + newValue = currentValue + ? `${currentValue}\n${content}` + : content; + } + + const validation = validateBlockContent(block, newValue); + + if (!validation.valid && validation.truncated) { + newValue = validation.truncated; + } + + memory.blocks[block].value = newValue; + memory.blocks[block].lastModified = new Date().toISOString(); + memory.updatedAt = new Date().toISOString(); + + await saveCoreMemory(directory, memory); + + const message = validation.error + ? `⚠️ ${validation.error}\n\nUpdated ${block} block (${operation}): ${newValue.length}/${CORE_MEMORY_LIMITS[block]} chars used.` + : `✅ Updated ${block} block (${operation}): ${newValue.length}/${CORE_MEMORY_LIMITS[block]} chars used.`; + + return { success: true, message, memory }; +} + +// ============================================================================ +// Storage Governance Functions (Layer 1 + Layer 2) +// ============================================================================ + +/** + * Layer 1: Clean up all artifacts for a deleted session + * Called when session.deleted event is received + */ +async function cleanupSessionArtifacts( + directory: string, + sessionID: string +): Promise { + try { + const artifacts = [ + join(directory, ".opencode", "memory-core", `${sessionID}.json`), + join(directory, ".opencode", "memory-working", `${sessionID}.json`), + join(directory, ".opencode", "memory-working", `${sessionID}_pressure.json`), + join(directory, ".opencode", "memory-working", `${sessionID}_compaction.json`), + join(directory, ".opencode", "memory-working", "tool-outputs", sessionID), + ]; + + for (const path of artifacts) { + if (existsSync(path)) { + await rm(path, { recursive: true, force: true }); + } + } + } catch (error) { + // Silent failure - cleanup errors are non-critical + } +} + +/** + * Layer 2: Sweep tool-output cache for a session + * Remove files older than TTL and enforce max file count + * Returns number of files deleted + */ +async function sweepToolOutputCache( + directory: string, + sessionID: string +): Promise { + const cacheDir = join(directory, ".opencode", "memory-working", "tool-outputs", sessionID); + + if (!existsSync(cacheDir)) { + return 0; + } + + try { + const files = await readdir(cacheDir); + const now = Date.now(); + const { toolOutputMaxFiles, toolOutputMaxAgeMs } = STORAGE_GOVERNANCE; + + // Collect file stats + const fileStats: Array<{ name: string; mtime: number; path: string }> = []; + for (const file of files) { + const filePath = join(cacheDir, file); + try { + const stats = await stat(filePath); + if (stats.isFile()) { + fileStats.push({ + name: file, + mtime: stats.mtimeMs, + path: filePath, + }); + } + } catch (err) { + // Skip files that can't be stat'd + } + } + + // Identify files to delete + const toDelete: string[] = []; + + // 1. Delete files older than TTL + for (const file of fileStats) { + if (now - file.mtime > toolOutputMaxAgeMs) { + toDelete.push(file.path); + } + } + + // 2. If still over limit, delete oldest files + const remaining = fileStats.filter(f => !toDelete.includes(f.path)); + if (remaining.length > toolOutputMaxFiles) { + // Sort by mtime ascending (oldest first) + remaining.sort((a, b) => a.mtime - b.mtime); + const excess = remaining.length - toolOutputMaxFiles; + for (let i = 0; i < excess; i++) { + toDelete.push(remaining[i].path); + } + } + + // Delete files + for (const path of toDelete) { + try { + await unlink(path); + } catch (err) { + // Ignore unlink errors (file might already be gone) + } + } + + return toDelete.length; + } catch (error) { + return 0; + } +} + +// ============================================================================ +// Phase 2: Smart Pruning System +// ============================================================================ + +/** + * Get pruning rule for a specific tool + */ +function getPruningRule(toolName: string): PruningRule { + const rules: Record = { + // Keep all - valuable outputs + grep: { strategy: "keep-all" }, + glob: { strategy: "keep-all" }, + memory_toast_retrieve: { strategy: "keep-all" }, + skill: { strategy: "keep-all" }, + + // Keep ends - code files + read: { strategy: "keep-ends", firstChars: 500, lastChars: 300 }, + + // Keep last - command outputs + bash: { strategy: "keep-last", maxChars: 1000 }, + + // Keep first - task summaries + task: { strategy: "keep-last", maxChars: 1500 }, + + // Discard - confirmations + edit: { strategy: "discard" }, + write: { strategy: "discard" }, + }; + + return rules[toolName] || { strategy: "keep-last", maxChars: 1000 }; +} + +/** + * Apply smart pruning to tool output with pressure-aware limits + */ +function applySmartPruning( + output: string, + rule: PruningRule, + pressureConfig?: { maxLines: number; maxChars: number; aggressiveTruncation: boolean } +): string { + let result = output; + + // Apply pressure-aware hard limits FIRST (if provided) + if (pressureConfig && pressureConfig.aggressiveTruncation) { + const lines = result.split('\n'); + + // HYPER-AGGRESSIVE: Enforce hard line limit + if (lines.length > pressureConfig.maxLines) { + const omittedLines = lines.length - pressureConfig.maxLines; + result = lines.slice(0, pressureConfig.maxLines).join('\n'); + result += `\n\n[⚠️ MEMORY PRESSURE: ${omittedLines} lines truncated. Use Grep/Task tool instead of direct reads.]`; + } + + // HYPER-AGGRESSIVE: Enforce hard char limit + if (result.length > pressureConfig.maxChars) { + const omittedChars = result.length - pressureConfig.maxChars; + result = result.slice(0, pressureConfig.maxChars); + result += `\n\n[⚠️ MEMORY PRESSURE: ${omittedChars} chars truncated]`; + } + } + + // Then apply normal pruning strategy + switch (rule.strategy) { + case "keep-all": + return result; + + case "keep-ends": + return keepFirstAndLast( + result, + rule.firstChars || 500, + rule.lastChars || 300 + ); + + case "keep-last": + return keepLast(result, rule.maxChars || 1000); + + case "summarize": + return extractSummary(result, rule.maxChars || 500); + + case "discard": + return `[Tool completed successfully]`; + + default: + return result; + } +} + +/** + * Keep first N and last M chars, with omission notice + */ +function keepFirstAndLast( + text: string, + firstChars: number, + lastChars: number +): string { + if (text.length <= firstChars + lastChars) { + return text; + } + + const firstPart = text.slice(0, firstChars); + const lastPart = text.slice(-lastChars); + const omitted = text.length - firstChars - lastChars; + + return `${firstPart}\n\n[... ${omitted} chars omitted for brevity ...]\n\n${lastPart}`; +} + +/** + * Keep only last N chars + */ +function keepLast(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + + const omitted = text.length - maxChars; + return `[... ${omitted} chars omitted ...]\n\n${text.slice(-maxChars)}`; +} + +/** + * Extract summary from output (simple implementation) + */ +function extractSummary(text: string, maxChars: number): string { + // Simple: just take first maxChars as "summary" + // Could be enhanced with LLM-based summarization + if (text.length <= maxChars) { + return text; + } + + return `${text.slice(0, maxChars)}\n[... truncated at ${maxChars} chars ...]`; +} + +/** + * Store full tool output for later smart pruning + */ +async function cacheToolOutput( + directory: string, + cached: CachedToolOutput +): Promise { + await ensureToolOutputCacheDir(directory, cached.sessionID); + const path = getToolOutputCachePath( + directory, + cached.sessionID, + cached.callID + ); + await writeFile(path, JSON.stringify(cached, null, 2), "utf-8"); +} + +/** + * Retrieve cached tool output + */ +async function getCachedToolOutput( + directory: string, + sessionID: string, + callID: string +): Promise { + const path = getToolOutputCachePath(directory, sessionID, callID); + if (!existsSync(path)) { + return null; + } + + try { + const content = await readFile(path, "utf-8"); + return JSON.parse(content) as CachedToolOutput; + } catch (error) { + console.error("Failed to load cached tool output:", error); + return null; + } +} + +// ============================================================================ +// Phase 3: Working Memory Auto-Management +// ============================================================================ + +/** + * Generate unique ID for working memory item + */ +function generateItemID(): string { + return `wm_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Extract key information from tool output + */ +function extractFromToolOutput( + toolName: string, + output: string +): WorkingMemoryItem[] { + const items: WorkingMemoryItem[] = []; + const timestamp = Date.now(); + + switch (toolName) { + case "read": + case "glob": { + // Extract file paths + const pathMatches = output.match(/[\w\-\/\.]+\.(ts|js|json|md|tsx|jsx|py|java|go|rs)/g); + if (pathMatches) { + const uniquePaths = [...new Set(pathMatches)].slice(0, 5); // Top 5 unique paths + for (const path of uniquePaths) { + items.push({ + id: generateItemID(), + type: "file-path", + content: path, + source: `tool:${toolName}`, + timestamp, + relevanceScore: 0, + mentions: 1, + }); + } + } + break; + } + + case "bash": { + // Extract errors + if (output.toLowerCase().includes("error") || output.toLowerCase().includes("failed")) { + const errorLines = output + .split("\n") + .filter(line => + line.toLowerCase().includes("error") || + line.toLowerCase().includes("failed") + ) + .slice(0, 3); // Top 3 error lines + + for (const line of errorLines) { + const truncated = line.slice(0, WORKING_MEMORY_LIMITS.maxCharsPerItem); + items.push({ + id: generateItemID(), + type: "error", + content: truncated, + source: "tool:bash", + timestamp, + relevanceScore: 0, + mentions: 1, + }); + } + } + break; + } + + case "grep": { + // Extract file paths with matches (treat as file-path, not a separate "finding" type) + // OpenCode grep format: "Found N matches\n/path/to/file:\n Line X: ..." + // Match file paths that end with common extensions followed by ":" + const grepMatches = output.match(/^(\/[^\n]+\.(ts|js|md|json|tsx|jsx|py|java|go|rs|txt|yml|yaml|toml)):/gm); + if (grepMatches) { + const uniqueFiles = [...new Set(grepMatches.map(m => m.replace(/:$/, "")))].slice(0, 5); + for (const file of uniqueFiles) { + items.push({ + id: generateItemID(), + type: "file-path", + content: file, + source: "tool:grep", + timestamp, + relevanceScore: 0, + mentions: 1, + }); + } + } + break; + } + + case "edit": + case "write": { + // Extract file paths being modified + const filePathMatch = output.match(/([\w\-\/\.]+\.(ts|js|json|md|tsx|jsx|py|java|go|rs))/); + if (filePathMatch) { + items.push({ + id: generateItemID(), + type: "file-path", + content: `Modified: ${filePathMatch[1]}`, + source: `tool:${toolName}`, + timestamp, + relevanceScore: 0, + mentions: 1, + }); + } + break; + } + } + + return items; +} + +/** + * Calculate relevance score for pool items (exponential decay) + * + * Formula: S_i^(t) = S_i^(t-1) × γ^(events_elapsed) + W_i + * Where: + * - γ = decay rate (0.85) + * - events_elapsed = current eventCounter - item's lastEventCounter + * - W_i = mention boost (mentions × 1.0) + */ +function calculatePoolScore( + item: WorkingMemoryItem, + currentEventCounter: number +): number { + // Calculate events elapsed since last update + const lastEvent = item.lastEventCounter ?? 0; + const eventsElapsed = currentEventCounter - lastEvent; + + // Apply exponential decay to existing score + const decayedScore = item.relevanceScore * Math.pow(POOL_CONFIG.gamma, eventsElapsed); + + // Mention boost (not subject to decay) + const mentionBoost = item.mentions * 0.5; + + return Math.max(0, decayedScore + mentionBoost); +} + +/** + * Helper: Check if item type is slot-based + */ +function isSlotType(type: WorkingMemoryItemType): type is SlotType { + return type in SLOT_CONFIG; +} + +/** + * Add item to working memory (slot-based architecture with auto-cleanup) + */ +async function addToWorkingMemory( + directory: string, + sessionID: string, + item: Omit +): Promise { + let memory = await loadWorkingMemory(directory, sessionID); + if (!memory) { + memory = createEmptyWorkingMemory(sessionID); + } + + // Increment event counter for pool decay + memory.eventCounter += 1; + + if (isSlotType(item.type)) { + // ===== Slot-based item: FIFO queue ===== + const slotType = item.type as SlotType; + const slot = memory.slots[slotType]; + + // Check for duplicates (same content) + const existing = slot.find(i => i.content === item.content); + if (existing) { + // Increment mentions, update timestamp (refresh item) + existing.mentions += 1; + existing.timestamp = item.timestamp; + } else { + // Add new item + const newItem: WorkingMemoryItem = { + ...item, + id: generateItemID(), + relevanceScore: 0, // Not used for slots + }; + slot.push(newItem); + + // Apply FIFO limit: keep only most recent N items + const limit = SLOT_CONFIG[slotType]; + if (slot.length > limit) { + // Sort by timestamp descending, keep top N + slot.sort((a, b) => b.timestamp - a.timestamp); + memory.slots[slotType] = slot.slice(0, limit); + } + } + } else { + // ===== Pool-based item: Exponential decay ===== + // Check for duplicates (same content) + const existing = memory.pool.find(i => i.content === item.content); + if (existing) { + // Recalculate score with decay first + existing.relevanceScore = calculatePoolScore(existing, memory.eventCounter); + + // Then increment mentions and update timestamp + existing.mentions += 1; + existing.timestamp = item.timestamp; + existing.lastEventCounter = memory.eventCounter; + + // Add mention boost to score + existing.relevanceScore += 0.5; + } else { + // Add new item with initial score + const newItem: WorkingMemoryItem = { + ...item, + id: generateItemID(), + relevanceScore: 1.0, // Initial score + lastEventCounter: memory.eventCounter, + }; + memory.pool.push(newItem); + } + + // Apply decay to all OTHER pool items (not the one we just added/updated) + for (const poolItem of memory.pool) { + if (poolItem.content !== item.content) { + poolItem.relevanceScore = calculatePoolScore(poolItem, memory.eventCounter); + poolItem.lastEventCounter = memory.eventCounter; + } + } + + // Remove items below min score + memory.pool = memory.pool.filter(i => i.relevanceScore >= POOL_CONFIG.minScore); + + // Sort by relevance score (descending) + memory.pool.sort((a, b) => b.relevanceScore - a.relevanceScore); + + // Limit to max items + if (memory.pool.length > POOL_CONFIG.maxItems) { + memory.pool = memory.pool.slice(0, POOL_CONFIG.maxItems); + } + } + + memory.updatedAt = new Date().toISOString(); + await saveWorkingMemory(directory, memory); + + return memory; +} + +/** + * Get top items for system prompt injection (slots first, then pool) + */ +function getTopItemsForPrompt( + memory: WorkingMemory, + maxChars: number = WORKING_MEMORY_LIMITS.systemPromptBudget +): { slotItems: WorkingMemoryItem[], poolItems: WorkingMemoryItem[] } { + const slotItems: WorkingMemoryItem[] = []; + const poolItems: WorkingMemoryItem[] = []; + let usedChars = 0; + + // Priority 1: Add all slot items (they're guaranteed) + for (const slotType of Object.keys(SLOT_CONFIG) as SlotType[]) { + const items = memory.slots[slotType]; + // Sort by timestamp descending (most recent first) + const sorted = [...items].sort((a, b) => b.timestamp - a.timestamp); + for (const item of sorted) { + const itemChars = item.content.length + 20; // +20 for formatting + if (usedChars + itemChars > maxChars) { + break; + } + slotItems.push(item); + usedChars += itemChars; + } + } + + // Priority 2: Add pool items (sorted by relevance score) + for (const item of memory.pool) { + const itemChars = item.content.length + 20; // +20 for formatting + if (usedChars + itemChars > maxChars) { + break; + } + poolItems.push(item); + usedChars += itemChars; + } + + return { slotItems, poolItems }; +} + +/** + * Compress file paths to save space in system prompt + * /Users/sd_wo/opencode/packages/opencode/src/foo.ts → ~/opencode/pkg/opencode/src/foo.ts + * /Users/sd_wo/work/opencode-plugins/.opencode/plugins/foo.ts → ~/work/oc-plugins/.opencode/plugins/foo.ts + */ +function compressPath(content: string): string { + const homeDir = process.env.HOME || '/Users/' + (process.env.USER || 'user'); + + return content + // Replace home directory with ~ + .replace(new RegExp(`^${homeDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), '~') + // Shorten common patterns + .replace(/\/packages\//g, '/pkg/') + .replace(/\/opencode-plugins\//g, '/oc-plugins/') + .replace(/\/node_modules\//g, '/nm/') + .replace(/\/typescript\//g, '/ts/') + .replace(/\/javascript\//g, '/js/'); +} + +/** + * Render working memory items for system prompt + */ +function renderWorkingMemoryPrompt(memory: WorkingMemory): string { + const { slotItems, poolItems } = getTopItemsForPrompt(memory); + + if (slotItems.length === 0 && poolItems.length === 0) { + return ""; + } + + // Group items by type + const itemsByType = new Map(); + for (const item of [...slotItems, ...poolItems]) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type)!.push(item); + } + + const sections: string[] = []; + + // Format each type section (ordered by importance) + const typeLabels: Record = { + "file-path": "📁 Key Files", + "error": "⚠️ Recent Errors", + "decision": "💡 Decisions", + "other": "📝 Notes", + }; + + const typeOrder: WorkingMemoryItemType[] = [ + "error", "decision", // Slots first + "file-path", "other" // Pool second + ]; + + for (const type of typeOrder) { + const items = itemsByType.get(type); + if (!items || items.length === 0) continue; + + const label = typeLabels[type] || "📝 Notes"; + // Compress file paths to save space + const itemList = items.map(item => { + const content = (type === "file-path") + ? compressPath(item.content) + : item.content; + return ` - ${content}`; + }).join("\n"); + sections.push(`${label}:\n${itemList}`); + } + + const totalItems = slotItems.length + poolItems.length; + return ` + +Recent session context (auto-managed, sorted by relevance): + +${sections.join("\n\n")} + +(${totalItems} items shown, updated: ${new Date(memory.updatedAt).toLocaleTimeString()}) + +`.trim(); +} + +function getWorkingMemoryItemCount(memory: WorkingMemory): number { + const slotCount = Object.values(memory.slots).reduce( + (count, slotItems) => count + slotItems.length, + 0 + ); + return slotCount + memory.pool.length; +} + +// ============================================================================ +// Phase 4: Compaction Tracking and State Preservation +// ============================================================================ + +/** + * Keep only the most relevant working memory items before compaction + * Returns number of items preserved + */ +async function preserveRelevantItems( + directory: string, + sessionID: string, + keepPercentage: number = 0.5 // Keep top 50% by default +): Promise { + const memory = await loadWorkingMemory(directory, sessionID); + if (!memory) { + return 0; + } + + // Slots are always preserved (they're guaranteed retention) + const slotCount = Object.values(memory.slots).flat().length; + + // For pool items, keep top N% + const poolSize = memory.pool.length; + if (poolSize > 0) { + const keepCount = Math.max(1, Math.ceil(poolSize * keepPercentage)); + + // Pool is already sorted by relevance score, just slice + memory.pool = memory.pool.slice(0, keepCount); + } + + memory.updatedAt = new Date().toISOString(); + await saveWorkingMemory(directory, memory); + + return slotCount + memory.pool.length; +} + +/** + * Record compaction event in log + */ +async function recordCompaction( + directory: string, + sessionID: string, + preservedItems: number +): Promise { + let log = await loadCompactionLog(directory, sessionID); + if (!log) { + log = createInitialCompactionLog(sessionID); + } + + log.compactionCount += 1; + log.lastCompaction = Date.now(); + log.preservedItems = preservedItems; + log.updatedAt = new Date().toISOString(); + + await saveCompactionLog(directory, log); + return log; +} + +// ============================================================================ +// Memory Pressure Calculation & Tracking +// ============================================================================ + +/** + * Calculate usable tokens using OpenCode's exact compaction formula + * Reference: packages/opencode/src/session/compaction.ts:32-48 + */ +function calculateUsableTokens(model: { + limit: { context: number; input?: number; output: number }; +}): number { + const OUTPUT_TOKEN_MAX = 32_000; // From transform.ts:21 + const COMPACTION_BUFFER = 20_000; // From compaction.ts:33 + + const maxOutputTokens = Math.min( + model.limit.output || OUTPUT_TOKEN_MAX, + OUTPUT_TOKEN_MAX + ); + const reserved = Math.min(COMPACTION_BUFFER, maxOutputTokens); + + // Match compaction.ts:42-47 + const usable = model.limit.input + ? model.limit.input - reserved + : model.limit.context - maxOutputTokens; + + return usable; +} + +/** + * Calculate pressure level based on current tokens and usable limit + * + * Thresholds: + * - 0.75 (75%): moderate - show reminder in prompt + * - 0.9 (90%): high - send intervention message + */ +function calculatePressureLevel( + currentTokens: number, + usable: number +): PressureLevel { + const pressure = currentTokens / usable; + + if (pressure >= 0.90) return "high"; + if (pressure >= 0.75) return "moderate"; + return "safe"; +} + +/** + * Calculate complete pressure information for a model + */ +function calculateModelPressure( + sessionID: string, + model: { id: string; provider: string; limit: { context: number; input?: number; output: number } }, + totalTokens: number +): ModelPressureInfo { + const OUTPUT_TOKEN_MAX = 32_000; + const COMPACTION_BUFFER = 20_000; + + const maxOutputTokens = Math.min( + model.limit.output || OUTPUT_TOKEN_MAX, + OUTPUT_TOKEN_MAX + ); + const reserved = Math.min(COMPACTION_BUFFER, maxOutputTokens); + const usable = calculateUsableTokens(model); + + const pressure = totalTokens / usable; + const level = calculatePressureLevel(totalTokens, usable); + + return { + sessionID, + modelID: model.id, + providerID: model.provider, + limits: { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + }, + calculated: { + maxOutputTokens, + reserved, + usable, + }, + current: { + totalTokens, + pressure, + level, + }, + thresholds: { + moderate: Math.floor(usable * 0.75), + high: Math.floor(usable * 0.90), + }, + updatedAt: new Date().toISOString(), + }; +} + +/** + * Save model pressure info to disk + */ +async function saveModelPressureInfo( + directory: string, + info: ModelPressureInfo +): Promise { + await ensureWorkingMemoryDir(directory); + const path = getModelPressurePath(directory, info.sessionID); + try { + await writeFile(path, JSON.stringify(info, null, 2), "utf-8"); + } catch (error) { + console.error("[working-memory] Failed to save pressure info:", error); + } +} + +/** + * Load model pressure info from disk + */ +async function loadModelPressureInfo( + directory: string, + sessionID: string +): Promise { + const path = getModelPressurePath(directory, sessionID); + if (!existsSync(path)) { + return null; + } + + try { + const content = await readFile(path, "utf-8"); + return JSON.parse(content) as ModelPressureInfo; + } catch (error) { + console.error("[working-memory] Failed to load pressure info:", error); + return null; + } +} + +/** + * Calculate total tokens by querying OpenCode's session database + * This is more reliable than relying on hook-provided messages + * + * Note: Only looks at last 10 messages to avoid stale data from before compaction + */ +async function calculateTotalTokensFromDB(sessionID: string): Promise { + try { + const { execSync } = await import("child_process"); + const dbPath = join(process.env.HOME || "~", ".local/share/opencode/opencode.db"); + + // Get tokens.total from most recent assistant message (last 10 to be safe) + // Use MAX to handle edge cases, but limit to recent messages to avoid stale pre-compaction data + const query = ` + SELECT json_extract(data, '$.tokens.total') as total + FROM message + WHERE session_id = '${sessionID}' + AND json_extract(data, '$.role') = 'assistant' + AND json_extract(data, '$.tokens.total') IS NOT NULL + ORDER BY time_created DESC + LIMIT 1; + `; + + const result = execSync(`sqlite3 "${dbPath}" "${query}"`, { encoding: "utf-8" }).trim(); + return parseInt(result) || 0; + } catch (error) { + console.error("[working-memory] Failed to query tokens from DB:", error); + return 0; + } +} + +/** + * Generate pressure warning text for system prompt injection + * + * Design principles: + * - MODERATE (75%): gentle nudge, no interruption + * - HIGH (90%): actionable commands, pause and persist state + */ +function generatePressureWarning(info: ModelPressureInfo): string { + const { current, calculated } = info; + const pct = (current.pressure * 100).toFixed(0); + + if (current.level === "high") { + return `\n\n⚠️ HIGH MEMORY PRESSURE: ${pct}% (${current.totalTokens.toLocaleString()}/${calculated.usable.toLocaleString()} usable tokens). Compaction approaching. REQUIRED ACTIONS: Pause current task. Use core_memory_update to write your current progress, findings, and exact next steps. Use working_memory_clear_slot to drop resolved errors and completed todos. Then use Task tool for any remaining exploration.`; + } + + if (current.level === "moderate") { + return `\n\n💡 Memory pressure: ${pct}% - Prefer Task tool for exploration. Update core_memory regularly.`; + } + + return ""; +} + +/** + * Send proactive intervention message when HIGH pressure detected (90%) + * + * This sends an independent system message to the session immediately, so the agent + * receives the task in the queue without interrupting current work. The agent will + * process it automatically when available. + * + * Design: Use promptAsync() which returns 204 immediately, non-blocking. + */ +async function sendPressureInterventionMessage( + client: any, + sessionID: string, + info: ModelPressureInfo +): Promise { + const { current, calculated } = info; + const pct = (current.pressure * 100).toFixed(0); + + if (current.level !== "high") return; + + const systemPrompt = `⚠️ HIGH MEMORY PRESSURE DETECTED: ${pct}% (${current.totalTokens.toLocaleString()}/${calculated.usable.toLocaleString()} usable tokens) + +Compaction is approaching. You must take action now to preserve your work: + +REQUIRED ACTIONS: +1. Pause your current task immediately +2. Use core_memory_update to save: + - Current progress on your task + - Key findings and discoveries + - Exact next steps to continue after compaction +3. Use working_memory_clear_slot to drop resolved errors and completed todos +4. Use Task tool for any remaining exploration work + +After completing these actions, you may resume your current task.`; + + try { + // Use promptAsync to send message without waiting for response + await client.session.promptAsync({ + path: { id: sessionID }, + body: { + parts: [{ + type: "text", + // Send actionable content directly (not log-style placeholder) + text: systemPrompt, + }], + // Keep system unset so the intervention is visible as a normal prompt + noReply: false, // We want agent to respond with actions + }, + }); + } catch (error) { + console.error("[working-memory] Failed to send pressure intervention:", error); + } +} + +/** + * Get pressure-aware pruning config based on current memory pressure + * HYPER-AGGRESSIVE MODE: pressure >= 0.90 enforces strict limits + */ +function getPressureAwarePruningConfig(pressure: number): { + maxLines: number; + maxChars: number; + aggressiveTruncation: boolean; +} { + // HIGH (>= 90%): Hyper-Aggressive Mode + if (pressure >= 0.90) { + return { + maxLines: 2000, // Hard limit: 2000 lines max + maxChars: 100_000, // ~25k tokens max per tool output + aggressiveTruncation: true, // Force truncation, no exceptions + }; + } + + // MODERATE (>= 75%): Aggressive Mode + if (pressure >= 0.75) { + return { + maxLines: 5000, + maxChars: 200_000, // ~50k tokens max + aggressiveTruncation: true, + }; + } + + // SAFE (< 75%): Normal Mode + return { + maxLines: 10_000, + maxChars: 400_000, // ~100k tokens max + aggressiveTruncation: false, + }; +} + +// ============================================================================ +// System Prompt Rendering +// ============================================================================ + +function renderCoreMemoryPrompt(memory: CoreMemory): string { + const { goal, progress, context } = memory.blocks; + + return ` + +The following persistent memory blocks track your current task state: + + +${goal.value || "[Not set - ask user for goals and update this block]"} + + + +${progress.value || "[No progress tracked yet - update as you work]"} + + + +${context.value || "[No project context set - add relevant file paths, conventions, etc.]"} + + +IMPORTANT: These blocks persist across conversation resets and compaction. +Update them regularly using core_memory_update tool when: +- Goals change or new objectives are identified +- Significant progress is made or tasks are completed +- Important project context is discovered (file structures, patterns, conventions) + +When memory blocks approach their character limits, compress or rephrase content. + +**Usage Discipline** (see Core Memory Usage Guidelines above for details): +- goal: ONE specific task, not project-wide goals +- progress: Checklist format, NO line numbers/commit hashes/API signatures +- context: ONLY files you're currently working on, NO type definitions/function signatures +- NEVER store: API docs, library types, function signatures (read source instead) + +`.trim(); +} + +// ============================================================================ +// Plugin Implementation +// ============================================================================ + +export default async function WorkingMemoryPlugin( + input: Parameters[0] +): Promise> { + const { directory, client } = input; + + return { + // ======================================================================== + // Phase 1: Inject Core Memory and Working Memory into System Prompt + // Phase 4: Inject Memory Pressure Warnings & Calculate Tokens from DB + // Phase 4.5: Proactive Pressure Intervention (NEW) + // Phase 5: Core Memory Usage Guidelines (AGENTS.md Enhancement) + // + // Dual-System Approach: + // 1. PASSIVE WARNING (existing): Injected into next turn's system prompt + // - Always present as reminder in system context + // - 1-turn delay but persistent + // + // 2. PROACTIVE INTERVENTION (new): Immediate async message sent to queue + // - No delay, sent immediately when HIGH (90%) detected + // - Agent processes when available (non-blocking) + // - Only sent when pressure level increases (avoids spam) + // + // 3. USAGE GUIDELINES (new): Injected after AGENTS.md, before core_memory + // - Teaches agent how to use core_memory blocks correctly + // - Prevents storing API docs/type definitions in memory + // - Ensures goal/progress/context stay focused on current task + // ======================================================================== + "experimental.chat.system.transform": async (hookInput, output) => { + const { sessionID, model } = hookInput; + if (!sessionID) return; + + // Phase 5: Inject Core Memory Usage Guidelines + // This enhances AGENTS.md (if exists) with plugin-specific instructions + // Inserted early so it's read before agent sees block + const coreMemoryGuidelines = ` +# Core Memory Usage Guidelines + +The Working Memory Plugin provides persistent core_memory blocks. **USE THEM CORRECTLY**: + +## goal block (1000 chars) +**Purpose**: ONE specific task you're working on RIGHT NOW + +✅ **GOOD Examples**: +- "Fix pruning bug where items with relevanceScore <0.01 are incorrectly excluded" +- "Add new tool: working_memory_search to query pool items by keyword" +- "Investigate why pressure warnings not showing in system prompt" + +❌ **BAD Examples**: +- "Complete Phase 1-4 development and testing" (too broad, likely already done) +- "Build a working memory system for OpenCode" (project-level goal, not task-level) + +## progress block (2000 chars) +**Purpose**: Checklist of done/in-progress/blocked items + key decisions + +✅ **GOOD Examples**: +- "✅ Found bug in applyDecay() line 856\\n⏳ Testing fix with gamma=0.85\\n❓ Need to verify edge case: score=0" +- "✅ Phase 1-3 complete\\n⏳ Phase 4 intervention testing\\n⚠️ BLOCKED: Need promptAsync docs" + +❌ **BAD Examples**: +- "Function sendPressureInterventionMessage() @ working-memory.ts:L1286-1354" (line numbers useless after edits) +- "Commit 2f42f1b implemented promptAsync integration" (commit hash irrelevant) +- "API: client.session.promptAsync({ path: {id}, body: {...} })" (API signature, not progress) + +## context block (1500 chars) +**Purpose**: Files you're CURRENTLY editing + key patterns/conventions + +✅ **GOOD Examples**: +- "Editing: .opencode/plugins/working-memory.ts (main plugin, 1706 lines)\\nRelated: WORKING_MEMORY.md, TEST_PHASE4.md" +- "Key paths: .opencode/memory-core/ (persistent blocks), memory-working/ (session data)" +- "Pattern: All async file ops use mkdir({recursive:true}) before writeFile" + +❌ **BAD Examples**: +- "OpenCode SDK types: TextPartInput = { type: 'text', text: string, synthetic?: boolean }" (type definition) +- "Function signature: async function loadCoreMemory(directory: string, sessionID: string): Promise" (function signature) +- "Method client.session.promptAsync() returns 204 No Content" (API behavior, read docs instead) + +## ⚠️ NEVER Store in Core Memory +- API documentation (read source/docs when needed) +- Type definitions from libraries (import them) +- Function signatures (read source code) +- Implementation details (belong in code comments) +- Completed goals (clear them immediately) + +## ✅ Update Core Memory Immediately When +- **Starting new task**: Clear old goal, set new specific goal +- **Making progress**: Update progress checklist (keep concise) +- **Switching files**: Update context with current working files +- **Task completed**: Clear goal/progress, set next task +- **Approaching char limit**: Compress or remove outdated info + +**Remember**: Core Memory is your **working scratchpad**, not a reference manual. +`.trim(); + + output.system.push(coreMemoryGuidelines); + + // Phase 4: Check for memory pressure and inject warning + // Skip warning if model just changed (avoids false alarms with different limits) + const prevPressure = await loadModelPressureInfo(directory, sessionID); + const modelChanged = model && prevPressure && prevPressure.modelID !== model.id; + + if (!modelChanged && prevPressure && prevPressure.current.level !== "safe") { + const warning = generatePressureWarning(prevPressure); + if (warning) { + output.system.push(warning); + } + } + + // Phase 4: Calculate current token usage from DB and update pressure + if (model) { + const totalTokens = await calculateTotalTokensFromDB(sessionID); + + // Calculate pressure with current model + const updatedPressure = calculateModelPressure( + sessionID, + { + id: model.id, + provider: model.provider, + limit: model.limit, + }, + totalTokens + ); + + // Save for next turn's warning injection + await saveModelPressureInfo(directory, updatedPressure); + + // Phase 4.5: Proactive Intervention - Send immediate message if HIGH (90%) + // This is better than waiting for next turn's passive warning + // The message goes into the queue and agent processes it when available + if (updatedPressure.current.level === "high") { + // Only send if pressure increased from previous level (avoid spam) + const shouldSend = !prevPressure || + prevPressure.current.level === "safe" || + prevPressure.current.level === "moderate"; + + if (shouldSend) { + await sendPressureInterventionMessage(client, sessionID, updatedPressure); + } + } + } + + // Phase 1: Core memory + const coreMemory = await loadCoreMemory(directory, sessionID); + if (coreMemory) { + const hasContent = + coreMemory.blocks.goal.value || + coreMemory.blocks.progress.value || + coreMemory.blocks.context.value; + + if (hasContent) { + const corePrompt = renderCoreMemoryPrompt(coreMemory); + output.system.push(corePrompt); + } + } + + // Phase 1: Working memory + const workingMemory = await loadWorkingMemory(directory, sessionID); + if (workingMemory && getWorkingMemoryItemCount(workingMemory) > 0) { + const workingPrompt = renderWorkingMemoryPrompt(workingMemory); + if (workingPrompt) { + output.system.push(workingPrompt); + } + } + }, + + // ======================================================================== + // Phase 2 & 3: Cache Tool Outputs and Auto-Extract to Working Memory + // Storage Governance Layer 2: Tool Output Cache Sweep Trigger + // ======================================================================== + "tool.execute.after": async (hookInput, hookOutput) => { + const { sessionID, callID, tool: toolName, args } = hookInput; + const { output: toolOutput } = hookOutput; + + // Phase 2: Cache the full output for later smart pruning + await cacheToolOutput(directory, { + callID, + sessionID, + tool: toolName, + fullOutput: toolOutput, + timestamp: Date.now(), + }); + + // Phase 3: Auto-extract to working memory + const extractedItems = extractFromToolOutput(toolName, toolOutput); + for (const item of extractedItems) { + await addToWorkingMemory(directory, sessionID, item); + } + + // Storage Governance Layer 2: Sweep tool-output cache every N calls + const memory = await loadWorkingMemory(directory, sessionID); + if (memory && memory.eventCounter % STORAGE_GOVERNANCE.sweepInterval === 0) { + await sweepToolOutputCache(directory, sessionID); + } + }, + + // ======================================================================== + // Phase 2: Apply Smart Pruning to Messages (Pressure-Aware) + // ======================================================================== + "experimental.chat.messages.transform": async (hookInput, output) => { + const sessionID = output.messages[0]?.info?.sessionID || ""; + + // Load current pressure info to get pressure-aware pruning config + const currentPressure = await loadModelPressureInfo(directory, sessionID); + const pressureLevel = currentPressure?.current?.pressure || 0; + const pruningConfig = getPressureAwarePruningConfig(pressureLevel); + + // Apply smart pruning with pressure-aware limits + for (const msg of output.messages) { + for (const part of msg.parts) { + // Check if this is a tool result that was pruned by OpenCode + if ( + part.type === "tool" && + part.state?.status === "completed" && + part.state?.time?.compacted + ) { + // Retrieve cached full output + const cached = await getCachedToolOutput( + directory, + msg.info.sessionID || "", + part.callID || "" + ); + + if (cached) { + const rule = getPruningRule(part.tool || ""); + const smartPruned = applySmartPruning(cached.fullOutput, rule, pruningConfig); + + // Replace the generic "[Old tool result content cleared]" with smart summary + part.state.output = smartPruned; + + // Remove compacted marker to prevent double-pruning + delete part.state.time.compacted; + } + } + } + } + }, + + // ======================================================================== + // Storage Governance Layer 1: Session Deletion Event Handler + // ======================================================================== + event: async ({ event }) => { + // Listen for session.deleted events and cleanup all artifacts + if (event.type === "session.deleted") { + const sessionID = event.properties?.info?.id; + if (sessionID) { + await cleanupSessionArtifacts(directory, sessionID); + } + } + }, + + // ======================================================================== + // Phase 4: Preserve State Before Compaction + // ======================================================================== + "experimental.session.compacting": async (hookInput, output) => { + const { sessionID } = hookInput; + + // Preserve only the most relevant working memory items + const preservedItems = await preserveRelevantItems(directory, sessionID, 0.5); + + // Record this compaction event + const log = await recordCompaction(directory, sessionID, preservedItems); + + // Add context to compaction prompt to help preserve key info + const coreMemory = await loadCoreMemory(directory, sessionID); + if (coreMemory) { + const { goal, progress, context } = coreMemory.blocks; + + let contextParts: string[] = []; + + if (goal.value) { + contextParts.push(`Current goal: ${goal.value}`); + } + + if (progress.value) { + // Extract just the "next steps" portion if it exists + const progressLines = progress.value.split('\n'); + const nextStepsIdx = progressLines.findIndex(line => + line.includes('⏭️') || line.toLowerCase().includes('next') + ); + if (nextStepsIdx >= 0) { + contextParts.push(`Next steps: ${progressLines[nextStepsIdx]}`); + } + } + + if (contextParts.length > 0) { + output.context.push( + `IMPORTANT: Preserve these key details:\n${contextParts.join('\n')}` + ); + } + } + + // SSOT Bridge: Inject OpenCode native Todos from DB into compaction context + try { + const { execSync } = await import("child_process"); + const dbPath = join(process.env.HOME || "~", ".local/share/opencode/opencode.db"); + + const query = ` + SELECT content, status, priority + FROM todo + WHERE session_id = '${sessionID}' + AND status != 'completed' + ORDER BY position ASC; + `; + + const result = execSync(`sqlite3 "${dbPath}" "${query.replace(/\n/g, ' ')}"`, { + encoding: "utf-8" + }).trim(); + + if (result) { + const todos = result.split('\n').map(line => { + const [content, status, priority] = line.split('|'); + return `- [${status}] ${content} (${priority})`; + }); + + if (todos.length > 0) { + output.context.push( + `PENDING TODOS:\n${todos.join('\n')}\nIMPORTANT: Continue working on these tasks after compaction.` + ); + } + } + } catch (error) { + console.error("[working-memory] Failed to inject todos from DB:", error); + } + + // Inform about preserved working memory + if (preservedItems > 0) { + output.context.push( + `Working memory: Preserved ${preservedItems} most relevant items (compaction #${log.compactionCount})` + ); + } + }, + + // ======================================================================== + // Tools + // ======================================================================== + tool: { + core_memory_update: tool({ + description: `Update persistent core memory blocks that survive compaction. + +Available blocks: +- goal: What the user is trying to accomplish (max 1000 chars) +- progress: What's done, in-progress, and next steps (max 2000 chars) +- context: Key project context like file paths, conventions, patterns (max 1500 chars) + +Operations: +- replace: Completely replace the block content +- append: Add content to the end of the block (automatically adds newline) + +These blocks are ALWAYS visible to you in every message, even after compaction. +Update them regularly to maintain continuity across long conversations.`, + args: { + block: tool.schema.enum(["goal", "progress", "context"], { + description: + "Which memory block to update (goal/progress/context)", + }), + operation: tool.schema.enum(["replace", "append"], { + description: + "Whether to replace the entire block or append to it", + }), + content: tool.schema + .string() + .max(5000) + .describe( + "Content to write. Will be auto-truncated if exceeds block limit." + ), + }, + execute: async (args, ctx) => { + const { block, operation, content } = args; + const { sessionID, directory } = ctx; + + const result = await updateCoreMemoryBlock( + directory, + sessionID, + block, + operation, + content + ); + + return result.message; + }, + }), + + core_memory_read: tool({ + description: `Read the current state of all core memory blocks. + +Returns the current values of goal, progress, and context blocks with their usage stats. +Useful for checking what's currently stored before updating.`, + args: {}, + execute: async (args, ctx) => { + const { sessionID, directory } = ctx; + + let memory = await loadCoreMemory(directory, sessionID); + + if (!memory) { + return "📭 No core memory exists for this session yet.\n\nUse core_memory_update to create memory blocks."; + } + + const { goal, progress, context } = memory.blocks; + + const formatBlock = ( + name: string, + block: CoreBlock + ): string => ` +## ${name.toUpperCase()} +Chars: ${block.value.length}/${block.charLimit} +Last modified: ${block.lastModified} + +${block.value || "[Empty]"} +`.trim(); + + return ` +# Core Memory State + +${formatBlock("goal", goal)} + +--- + +${formatBlock("progress", progress)} + +--- + +${formatBlock("context", context)} + +--- + +Last updated: ${memory.updatedAt} + `.trim(); + }, + }), + + working_memory_add: tool({ + description: `Manually add an important item to working memory. + +Working memory auto-extracts key information from tool outputs, but you can +also manually add important decisions or notes. + +Item types: file-path, error, decision, other`, + args: { + content: tool.schema + .string() + .max(200) + .describe("The content to remember (max 200 chars)"), + type: tool.schema + .enum([ + "file-path", + "error", + "decision", + "other", + ]) + .describe("Type of information") + .optional(), + }, + execute: async (args, ctx) => { + const { sessionID, directory } = ctx; + const { content, type = "other" } = args; + + await addToWorkingMemory(directory, sessionID, { + type: type as WorkingMemoryItemType, + content, + source: "manual", + timestamp: Date.now(), + mentions: 1, + }); + + return `✅ Added to working memory: ${content}`; + }, + }), + + working_memory_clear: tool({ + description: `Clear all working memory items for this session. + +Use this to reset session context when starting a completely new task. +Core memory (goal/progress/context) is NOT affected.`, + args: {}, + execute: async (args, ctx) => { + const { sessionID, directory } = ctx; + + const emptyMemory = createEmptyWorkingMemory(sessionID); + await saveWorkingMemory(directory, emptyMemory); + + return "🗑️ Working memory cleared. Core memory remains intact."; + }, + }), + + working_memory_clear_slot: tool({ + description: `Clear a specific slot in working memory (e.g., after fixing all errors). + +Useful when you've resolved all items of a certain type: +- Clear "error" slot after fixing all bugs +- Clear "decision" slot after obsolete decisions + +Core memory and pool items are NOT affected.`, + args: { + slot: tool.schema + .enum(["error", "decision"]) + .describe("Which slot to clear"), + }, + execute: async (args, ctx) => { + const { sessionID, directory } = ctx; + const { slot } = args; + + let memory = await loadWorkingMemory(directory, sessionID); + if (!memory) { + return "⚠️ No working memory found for this session."; + } + + const slotType = slot as SlotType; + const itemCount = memory.slots[slotType].length; + memory.slots[slotType] = []; + memory.updatedAt = new Date().toISOString(); + await saveWorkingMemory(directory, memory); + + return `✅ Cleared ${itemCount} items from "${slot}" slot.`; + }, + }), + + working_memory_remove: tool({ + description: `Remove a specific item from working memory by content match. + +Use this to remove individual items that are no longer relevant. +Provide a unique substring of the content to identify the item.`, + args: { + content: tool.schema + .string() + .describe("Content or unique substring to match and remove"), + }, + execute: async (args, ctx) => { + const { sessionID, directory } = ctx; + const { content } = args; + + let memory = await loadWorkingMemory(directory, sessionID); + if (!memory) { + return "⚠️ No working memory found for this session."; + } + + // Try to find and remove from slots + let removed = false; + for (const slotType of Object.keys(SLOT_CONFIG) as SlotType[]) { + const slot = memory.slots[slotType]; + const index = slot.findIndex(item => item.content.includes(content)); + if (index !== -1) { + const removedItem = slot.splice(index, 1)[0]; + memory.updatedAt = new Date().toISOString(); + await saveWorkingMemory(directory, memory); + return `✅ Removed from "${slotType}" slot: ${removedItem.content}`; + } + } + + // Try to find and remove from pool + const poolIndex = memory.pool.findIndex(item => item.content.includes(content)); + if (poolIndex !== -1) { + const removedItem = memory.pool.splice(poolIndex, 1)[0]; + memory.updatedAt = new Date().toISOString(); + await saveWorkingMemory(directory, memory); + return `✅ Removed from pool: ${removedItem.content}`; + } + + return `⚠️ No item found matching: "${content}"`; + }, + }), + }, + }; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..de21e1b --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "opencode-working-memory", + "version": "1.0.0", + "description": "Advanced four-tier memory architecture for OpenCode with intelligent pressure monitoring and auto-storage governance", + "type": "module", + "main": "index.ts", + "keywords": [ + "opencode", + "plugin", + "memory", + "context-management", + "ai-agent", + "llm" + ], + "author": "Your Name ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/yourusername/opencode-working-memory.git" + }, + "bugs": { + "url": "https://github.com/yourusername/opencode-working-memory/issues" + }, + "homepage": "https://github.com/yourusername/opencode-working-memory#readme", + "peerDependencies": { + "@opencode-ai/plugin": "^1.2.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..977cc4f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true + }, + "include": ["index.ts"], + "exclude": ["node_modules", "dist"] +}