mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ed61d1ba2 | |||
| 2dbfb50080 | |||
| d569297c30 | |||
| 4f1c0348b4 | |||
| bc0847e3ed | |||
| 6a81fc384c | |||
| ed4590ca18 | |||
| 09cc4a2ffb | |||
| c0ebd84d7e | |||
| 20a6cfe1a6 | |||
| 36b78ea91c | |||
| 406c160c9f | |||
| 968aedd5c5 | |||
| d4053b2d35 | |||
| 85e11be2b9 | |||
| 5b0083efae | |||
| 60b9ca75c8 | |||
| 8da39c7a9d | |||
| e8c95a62ec | |||
| 56d7ef9a68 | |||
| 7427221640 | |||
| 9991c95ff6 | |||
| f7139f0844 | |||
| 465edfabf1 | |||
| 6a80f4b047 | |||
| b21347c12b | |||
| ffb0477251 | |||
| a1b9bf4fbc | |||
| a762e863d1 | |||
| 222bae28de | |||
| 53aa6d3c31 | |||
| 034dfe8d91 | |||
| e3ebdbfe9f | |||
| fdebd304f6 | |||
| 77d60abf5f | |||
| 560f63f96b | |||
| 11361abc91 | |||
| e071095422 | |||
| 909d6c7767 | |||
| c697f63c67 | |||
| 25b673fbb7 | |||
| acaa829df4 | |||
| fe6ce36e09 | |||
| 3cc6dff7ae | |||
| 1c748f3ee2 | |||
| ca68b7f55c | |||
| 023589a905 | |||
| 24f807fed0 | |||
| 097235e43b | |||
| 14bbb76cf1 | |||
| fd8d730e3b | |||
| 4309cb855f | |||
| 2437a9dc71 | |||
| 3560868f52 | |||
| e7c7a5cfb2 | |||
| 026c75a5e4 | |||
| eb74a9f03e | |||
| f6f35e87c1 | |||
| 6603fe869d | |||
| 3d44269228 | |||
| a154139b27 | |||
| 7527765207 | |||
| f9acfd6136 | |||
| ca71c20a8f | |||
| 5e9ada6859 | |||
| 721544e7a8 | |||
| 32fa2bd454 | |||
| af539a42f3 | |||
| eff0d3784c | |||
| 2354b62350 | |||
| 92e90124de | |||
| 22774c5ed2 | |||
| 9892012d8b | |||
| f988af4453 | |||
| 606dcfac12 | |||
| 802ef62636 | |||
| ff4639d153 | |||
| 1bba0511bb | |||
| 2d7cb6cdf4 | |||
| 9f9763c0e1 | |||
| df54232fb9 | |||
| 72dc919ece |
@@ -0,0 +1,32 @@
|
||||
name: compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
|
||||
jobs:
|
||||
locked:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
|
||||
opencode-latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npm install --no-save @opencode-ai/plugin@latest
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
+15
@@ -40,3 +40,18 @@ temp/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# OpenCode plugin runtime
|
||||
.opencode/
|
||||
.opencode-agenthub/
|
||||
.opencode-agenthub.user.json
|
||||
|
||||
# Superpowers local planning artifacts
|
||||
docs/superpowers/plans/
|
||||
|
||||
# Local dev/admin script inputs
|
||||
scripts/dev/run-migration-roots.local.txt
|
||||
scripts/dev/dry-run-roots.local.txt
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# AGENTS.md - OpenCode Working Memory Plugin Development Guide
|
||||
# AGENTS.md - OpenCode Working Memory 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
|
||||
**OpenCode Working Memory** provides a **three-layer memory architecture** for AI agents:
|
||||
|
||||
1. **Workspace Memory** - Long-term memory that persists across sessions (decisions, project info, references)
|
||||
2. **Hot Session State** - Automatic tracking of active files, open errors, and recent decisions
|
||||
3. **Native OpenCode State** - Delegated to OpenCode's built-in todos during compaction
|
||||
|
||||
Written in **TypeScript** for the OpenCode agent environment.
|
||||
|
||||
@@ -17,6 +17,8 @@ Written in **TypeScript** for the OpenCode agent environment.
|
||||
git clone https://github.com/sdwolf4103/opencode-working-memory.git
|
||||
cd opencode-working-memory
|
||||
npm install
|
||||
npm test
|
||||
npm run typecheck
|
||||
|
||||
# For usage (see README.md)
|
||||
```
|
||||
@@ -30,24 +32,45 @@ npx tsc --noEmit
|
||||
```
|
||||
|
||||
### Testing
|
||||
Tests are manually verified through OpenCode sessions:
|
||||
```bash
|
||||
# 1. Load plugin in OpenCode session
|
||||
# 2. Run commands that trigger hooks (e.g., tool execution, compaction)
|
||||
# 3. Inspect .opencode/memory-core/ and .opencode/memory-working/
|
||||
# 4. Verify memory blocks appear in system prompts
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run specific test file
|
||||
npx node --test --experimental-strip-types tests/plugin.test.ts
|
||||
```
|
||||
|
||||
Tests verify:
|
||||
- Error extraction false positive guards
|
||||
- Explicit memory trigger patterns
|
||||
- Negative memory request filtering
|
||||
- Compaction candidate quality gate
|
||||
- Workspace memory rendering
|
||||
- Session state tracking
|
||||
|
||||
### File Structure
|
||||
```
|
||||
opencode-working-memory/
|
||||
├── index.ts # Main plugin (1700+ lines)
|
||||
├── package.json # Plugin manifest
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── LICENSE # MIT license
|
||||
├── README.md # User documentation
|
||||
├── AGENTS.md # This file (developer guide)
|
||||
└── docs/ # Detailed documentation
|
||||
├── index.ts # Plugin entry point (exports PluginModule)
|
||||
├── src/
|
||||
│ ├── plugin.ts # Main plugin implementation
|
||||
│ ├── extractors.ts # Memory extraction logic
|
||||
│ ├── workspace-memory.ts # Workspace memory management
|
||||
│ ├── session-state.ts # Session state tracking
|
||||
│ ├── storage.ts # File storage utilities
|
||||
│ ├── paths.ts # Path utilities
|
||||
│ ├── opencode.ts # OpenCode SDK types
|
||||
│ └── types.ts # Type definitions
|
||||
├── tests/
|
||||
│ ├── plugin.test.ts # Plugin hook tests
|
||||
│ ├── extractors.test.ts # Extractor tests
|
||||
│ └── workspace-memory.test.ts # Workspace memory tests
|
||||
├── package.json # Plugin manifest
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── LICENSE # MIT license
|
||||
├── README.md # User documentation
|
||||
├── AGENTS.md # This file (developer guide)
|
||||
└── docs/ # Detailed documentation
|
||||
├── installation.md
|
||||
├── architecture.md
|
||||
└── configuration.md
|
||||
@@ -59,39 +82,38 @@ opencode-working-memory/
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Full type annotations, no implicit any
|
||||
async function loadCoreMemory(
|
||||
directory: string,
|
||||
sessionID: string
|
||||
): Promise<CoreMemory | null>
|
||||
async function loadWorkspaceMemory(
|
||||
workspaceKey: string
|
||||
): Promise<WorkspaceMemoryStore | null>
|
||||
|
||||
// ❌ AVOID: Implicit any types
|
||||
async function loadCoreMemory(directory, sessionID) { }
|
||||
async function loadWorkspaceMemory(workspaceKey) { }
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Define types at module top
|
||||
type CoreMemory = {
|
||||
sessionID: string;
|
||||
blocks: {
|
||||
goal: CoreBlock;
|
||||
progress: CoreBlock;
|
||||
context: CoreBlock;
|
||||
};
|
||||
export type LongTermMemoryEntry = {
|
||||
id: string;
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
source: LongTermSource;
|
||||
confidence: number;
|
||||
status: "active" | "superseded";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
// ✅ USE: Union types for variants (not enums)
|
||||
type PressureLevel = "safe" | "moderate" | "high" | "critical";
|
||||
export type LongTermType = "feedback" | "project" | "decision" | "reference";
|
||||
export type LongTermSource = "explicit" | "compaction" | "manual";
|
||||
|
||||
// ✅ USE: Record<> for keyed configs
|
||||
const SLOT_CONFIG: Record<SlotType, number> = {
|
||||
error: 3,
|
||||
decision: 5,
|
||||
todo: 3,
|
||||
dependency: 3,
|
||||
};
|
||||
// ✅ USE: const assertions for limits
|
||||
export const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 3600,
|
||||
maxEntries: 28,
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Imports & Module Organization
|
||||
@@ -105,57 +127,52 @@ import { join } from "path";
|
||||
|
||||
// 2. Third-party (OpenCode SDK)
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
// 3. Local modules (if any)
|
||||
// (none currently)
|
||||
// 3. Local modules
|
||||
import { loadWorkspaceMemory } from "./storage.js";
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: camelCase for variables & functions
|
||||
const maxItems = 50;
|
||||
async function loadCoreMemory() { }
|
||||
const maxEntries = 28;
|
||||
async function loadWorkspaceMemory() { }
|
||||
|
||||
// ✅ REQUIRED: SCREAMING_SNAKE_CASE for constants
|
||||
const CORE_MEMORY_LIMITS = { goal: 1000, progress: 2000, context: 1500 };
|
||||
const SLOT_CONFIG = { error: 3, decision: 5, todo: 3, dependency: 3 };
|
||||
const LONG_TERM_LIMITS = { maxRenderedChars: 3600, maxEntries: 28 };
|
||||
const HOT_STATE_LIMITS = { maxRenderedChars: 700 };
|
||||
|
||||
// ✅ REQUIRED: PascalCase for types
|
||||
type CoreMemory = { ... };
|
||||
type WorkingMemoryItem = { ... };
|
||||
type WorkspaceMemoryStore = { ... };
|
||||
type SessionState = { ... };
|
||||
|
||||
// ✅ REQUIRED: get*/set*/load*/save* naming for file operations
|
||||
function getCoreMemoryPath(directory: string, sessionID: string): string { }
|
||||
async function loadCoreMemory(directory: string, sessionID: string): Promise<CoreMemory | null> { }
|
||||
async function saveCoreMemory(directory: string, memory: CoreMemory): Promise<void> { }
|
||||
|
||||
// ✅ REQUIRED: ensure*/validate* for pre-checks
|
||||
async function ensureCoreMemoryDir(directory: string): Promise<void> { }
|
||||
|
||||
// ✅ REQUIRED: Prefix private/internal functions with _
|
||||
function _compressPath(filePath: string): string { }
|
||||
// ✅ REQUIRED: get*/load*/save* naming for file operations
|
||||
function getWorkspaceMemoryPath(workspaceKey: string): string { }
|
||||
async function loadWorkspaceMemory(workspaceKey: string): Promise<WorkspaceMemoryStore | null> { }
|
||||
async function saveWorkspaceMemory(memory: WorkspaceMemoryStore): Promise<void> { }
|
||||
```
|
||||
|
||||
### Function Signatures & Organization
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Parameters on separate lines if > 80 chars
|
||||
async function loadWorkingMemory(
|
||||
directory: string,
|
||||
sessionID: string
|
||||
): Promise<WorkingMemory | null> {
|
||||
async function extractWorkspaceMemoryCandidates(
|
||||
content: string
|
||||
): Promise<WorkspaceMemoryCandidate[]> {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ REQUIRED: Explicit return types (no inference)
|
||||
function getCompactionLogPath(directory: string, sessionID: string): string {
|
||||
return join(directory, ".opencode", "memory-working", `${sessionID}_compaction.json`);
|
||||
function renderWorkspaceMemory(
|
||||
entries: LongTermMemoryEntry[],
|
||||
maxChars: number
|
||||
): string {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ REQUIRED: Async for file/network I/O
|
||||
async function saveCoreMemory(directory: string, memory: CoreMemory): Promise<void> {
|
||||
async function saveWorkspaceMemory(memory: WorkspaceMemoryStore): Promise<void> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -163,28 +180,23 @@ async function saveCoreMemory(directory: string, memory: CoreMemory): Promise<vo
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Try-catch with descriptive console.error
|
||||
async function loadCoreMemory(directory: string, sessionID: string): Promise<CoreMemory | null> {
|
||||
const path = getCoreMemoryPath(directory, sessionID);
|
||||
// ✅ REQUIRED: Try-catch with graceful degradation
|
||||
async function loadWorkspaceMemory(workspaceKey: string): Promise<WorkspaceMemoryStore | null> {
|
||||
const path = getWorkspaceMemoryPath(workspaceKey);
|
||||
if (!existsSync(path)) return null;
|
||||
|
||||
try {
|
||||
const content = await readFile(path, "utf-8");
|
||||
return JSON.parse(content) as CoreMemory;
|
||||
return JSON.parse(content) as WorkspaceMemoryStore;
|
||||
} catch (error) {
|
||||
console.error("Failed to load core memory:", error);
|
||||
return null; // Graceful degradation
|
||||
// Plugin should never block agent - return null and continue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ REQUIRED: Type guards for runtime safety
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ✅ REQUIRED: Validate JSON before use
|
||||
const data = JSON.parse(content);
|
||||
const typedData = data as CoreMemory; // Explicit cast after validation
|
||||
const typedData = data as WorkspaceMemoryStore; // Explicit cast after validation
|
||||
```
|
||||
|
||||
### Comments & Documentation
|
||||
@@ -192,149 +204,125 @@ const typedData = data as CoreMemory; // Explicit cast after validation
|
||||
```typescript
|
||||
// ✅ REQUIRED: Section headers for major sections
|
||||
// ============================================================================
|
||||
// Phase 1: Core Memory Foundation
|
||||
// Workspace Memory: Long-term cross-session storage
|
||||
// ============================================================================
|
||||
|
||||
// ✅ REQUIRED: Block comments for complex logic
|
||||
// Migration: Convert old format (items array) to new format (slots + pool)
|
||||
if (data.items && !data.slots) {
|
||||
// ... migration logic
|
||||
// Quality gate: Reject candidates that are git hashes, errors, or path-heavy
|
||||
function shouldAcceptWorkspaceMemoryCandidate(candidate: string): boolean {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ USE: Inline comments sparingly
|
||||
const gamma = 0.85; // Exponential decay rate (15% per event)
|
||||
|
||||
// ✅ AVOID: Over-commenting obvious code
|
||||
const name = "test"; // Set name to test ❌ (obvious)
|
||||
```
|
||||
|
||||
### Code Organization
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Organize plugin file by phase/feature
|
||||
// 1. Header & module documentation
|
||||
// 2. Imports
|
||||
// 3. Types & schemas (grouped by phase)
|
||||
// 4. Constants & configs
|
||||
// 5. Helper functions (private first, public after)
|
||||
// 6. Main plugin export
|
||||
// 7. Hook implementations
|
||||
|
||||
export default {
|
||||
// Plugin definition
|
||||
} as Plugin;
|
||||
```
|
||||
|
||||
### Working with OpenCode Plugin SDK
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Use proper hook signatures
|
||||
import { tool, type Plugin } from "@opencode-ai/plugin";
|
||||
|
||||
export default {
|
||||
id: "working-memory",
|
||||
name: "Working Memory Plugin",
|
||||
|
||||
// ✅ Core hooks
|
||||
hooks: {
|
||||
"tool.execute.after": async (ctx) => {
|
||||
// Tool just executed
|
||||
},
|
||||
"experimental.chat.system.transform": async (ctx) => {
|
||||
// Transform system prompt before sending
|
||||
},
|
||||
"experimental.session.compacting": async (ctx) => {
|
||||
// Session is being compacted (clearing old messages)
|
||||
},
|
||||
},
|
||||
|
||||
// ✅ Exposed tools
|
||||
tools: [
|
||||
tool({
|
||||
id: "core_memory_update",
|
||||
name: "Update Core Memory",
|
||||
description: "Update goal/progress/context blocks",
|
||||
// ... schema & execute
|
||||
}),
|
||||
],
|
||||
} as Plugin;
|
||||
// ✅ USE: Inline comments sparingly for non-obvious logic
|
||||
const canonical = normalizeText(text); // Lowercase, strip punctuation, collapse whitespace
|
||||
```
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Core Memory Files
|
||||
- Location: `.opencode/memory-core/<sessionID>.json`
|
||||
- Schema: `{ sessionID, blocks: { goal, progress, context }, updatedAt }`
|
||||
- Limits: goal (1000 chars), progress (2000 chars), context (1500 chars)
|
||||
### Plugin Entry Point
|
||||
|
||||
### Working Memory Files
|
||||
- Location: `.opencode/memory-working/<sessionID>.json`
|
||||
- Schema: `{ sessionID, slots, pool, eventCounter, updatedAt }`
|
||||
- Slot limits: error (3), decision (5), todo (3), dependency (3)
|
||||
- Pool decay: γ=0.85 per event
|
||||
```typescript
|
||||
// index.ts
|
||||
import { MemoryV2Plugin } from "./src/plugin.ts";
|
||||
|
||||
### Pressure Monitoring
|
||||
- Triggers at: 70% (safe→moderate), 85% (moderate→high), 95% (high→critical)
|
||||
- Files: `.opencode/memory-working/<sessionID>_pressure.json`
|
||||
- Intervention: Sends `promptAsync()` with complete visible prompt
|
||||
export default {
|
||||
id: "working-memory",
|
||||
server: MemoryV2Plugin,
|
||||
};
|
||||
```
|
||||
|
||||
### Storage Governance (Layer 1 & 2)
|
||||
- **Layer 1**: Session deletion cleanup - removes orphaned memory files
|
||||
- **Layer 2**: Tool output cache sweep - maintains 300 most recent files, 7-day TTL
|
||||
- Triggered at `eventCounter % 500 === 0` (automatic maintenance)
|
||||
### Workspace Memory Files
|
||||
|
||||
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
|
||||
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
|
||||
- **Schema**: See `src/types.ts:WorkspaceMemoryStore`
|
||||
- **Limits**: 3600 chars, 28 entries max
|
||||
|
||||
### Session State Files
|
||||
|
||||
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/sessions/{sessionID}.json`
|
||||
- **Session ID**: Hash of session ID from OpenCode
|
||||
- **Schema**: See `src/types.ts:SessionState`
|
||||
|
||||
### Memory Types
|
||||
|
||||
| Type | Purpose | Stale After |
|
||||
|------|---------|-------------|
|
||||
| `feedback` | User preferences | 90 days |
|
||||
| `project` | Project info | 60 days |
|
||||
| `decision` | Important decisions | 45 days |
|
||||
| `reference` | Key references | 90 days |
|
||||
|
||||
### Quality Guards
|
||||
|
||||
1. **False Positive Error Prevention**: Commands with "error" in output but `exitCode === undefined` are not tracked as errors
|
||||
2. **Negative Memory Filtering**: "don't remember" patterns are correctly interpreted
|
||||
3. **Compaction Quality Gate**: Rejects git hashes, stack traces, path-heavy facts
|
||||
|
||||
## Plugin Hooks
|
||||
|
||||
### `experimental.chat.system.transform`
|
||||
|
||||
Injects workspace memory and hot session state into system prompt.
|
||||
|
||||
### `tool.execute.after`
|
||||
|
||||
- Tracks active files (read, grep, edit, write actions)
|
||||
- Tracks open errors from failed commands
|
||||
- Clears errors when commands succeed
|
||||
- Ignores `exitCode === undefined`
|
||||
|
||||
### `experimental.session.compacting`
|
||||
|
||||
Extracts workspace memory candidates from conversation, applies quality gate and deduplication.
|
||||
|
||||
### `event`
|
||||
|
||||
- `session.compacted`: Promote session decisions to workspace memory
|
||||
- `session.deleted`: Clean up session state files
|
||||
|
||||
## Debugging & Testing
|
||||
|
||||
### Manual Testing Steps
|
||||
1. **Phase 1 (Core Memory)**: Check `.opencode/memory-core/` after `core_memory_update`
|
||||
2. **Phase 2 (Smart Pruning)**: Verify tool outputs are filtered before context injection
|
||||
3. **Phase 3 (Working Memory)**: Check `.opencode/memory-working/` for slot/pool items
|
||||
4. **Phase 4 (Pressure Monitoring)**: Monitor pressure % in system prompts, verify interventions
|
||||
5. **Phase 4.5 (Storage Governance)**: Run 500+ events, check sweep logs
|
||||
|
||||
1. **Workspace Memory**: Check `~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json` after compaction
|
||||
2. **Session State**: Check `~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json` after tool usage
|
||||
3. **Error Tracking**: Run failing commands, verify errors appear in session state
|
||||
4. **Error Clearing**: Run successful commands, verify errors are cleared
|
||||
|
||||
### Common Issues
|
||||
- **File not found**: Ensure `.opencode/` directory exists and is writable
|
||||
|
||||
- **File not found**: Ensure `~/.local/share/opencode-working-memory/` exists and is writable
|
||||
- **Type errors**: Check all imports use `import type { ... }` for types
|
||||
- **Lost memory**: Verify `.opencode/memory-*/` is in `.gitignore` (not committed)
|
||||
- **Sweep not running**: Check `eventCounter` in `<sessionID>.json`, should trigger at multiples of 500
|
||||
- **Memory not persisting**: Verify workspace key is consistent (same workspace = same key)
|
||||
- **False positive errors**: Check `exitCode` handling in `plugin.ts`
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Memory budgets**: Core (5.5k chars total), Working (1.6k chars for system prompt)
|
||||
- **Pruning**: Hyper-aggressive mode activates at ≥85% pressure
|
||||
- **Compaction**: Preserves most recent 10 items when space-constrained
|
||||
- **Decay**: Pool items scored by exponential decay (γ=0.85) + mention count
|
||||
- **Storage sweep**: Limits cache to 300 files, removes files older than 7 days
|
||||
|
||||
## File Path References
|
||||
|
||||
When referencing code locations in documentation/comments, use:
|
||||
```
|
||||
path/to/file.ts:L123 or path/to/file.ts:Line 123
|
||||
```
|
||||
|
||||
Example: `Function sendPressureInterventionMessage() @ index.ts:L1286`
|
||||
- **Workspace memory budget**: 3600 chars injected into system prompt
|
||||
- **Session state budget**: 700 chars injected into system prompt
|
||||
- **Total overhead**: typically well below configured maximums
|
||||
- **Storage footprint**: ~2-5 KB per workspace for memory, ~1-3 KB per session
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feature/my-feature`
|
||||
3. Make changes following the code style guidelines above
|
||||
4. Test manually in OpenCode session
|
||||
5. Commit with descriptive message: `git commit -m "Add feature: ..."`
|
||||
4. Run tests: `npm test && npm run typecheck`
|
||||
5. Commit with descriptive message: `git commit -m "feat: add ..."`
|
||||
6. Push to your fork: `git push origin feature/my-feature`
|
||||
7. Open a pull request
|
||||
|
||||
## Architecture Documentation
|
||||
|
||||
See `docs/architecture.md` for detailed technical documentation including:
|
||||
- Memory tier hierarchy
|
||||
- Pruning algorithms
|
||||
- Decay formulas
|
||||
- Pressure monitoring logic
|
||||
- Storage governance policies
|
||||
- Three-layer memory architecture
|
||||
- Memory extraction and quality gates
|
||||
- Error fingerprinting
|
||||
- Deduplication strategies
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: February 2026
|
||||
**Plugin Status**: Production (Phases 1-4.5 complete)
|
||||
**Last Updated**: April 2026
|
||||
**Plugin Status**: Production (Memory V2 architecture)
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.5.1] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
- Per-workspace evidence log for extraction, promotion, reinforcement, render, storage, and hook lifecycle events.
|
||||
- `memory-diag health --json` for machine-readable diagnostics.
|
||||
- `memory-diag explain` for per-memory render status, strength, reasons, and evidence event IDs.
|
||||
- `memory-diag trace --memory <id>` for memory lifecycle history.
|
||||
- UTC calendar-day reinforcement gate so repeated matches cannot inflate a memory multiple times in the same day.
|
||||
|
||||
### Changed
|
||||
|
||||
- Retention constants and calculations moved to `src/retention.ts`.
|
||||
- `safetyCritical` is now fully inert: no retention multiplier and no type-cap bypass, while remaining JSON-compatible.
|
||||
|
||||
## [1.5.0] - 2026-04-29
|
||||
|
||||
### Added
|
||||
|
||||
- Strength-based workspace memory retention using exponential decay instead of additive priority scoring.
|
||||
- Per-type rendered caps for workspace memory candidates: feedback 10, decision 10, project 8, and reference 6.
|
||||
- Dormant-workspace effective age: after 14 days without activity, additional dormant time counts at 0.25x for retention decay.
|
||||
- Reinforcement tracking for repeated memories, with same-session and one-hour guards to prevent accidental reinforcement spam.
|
||||
- Memory health diagnostics for stored vs rendered counts, type caps, global cap overflow, dormancy, retention monitoring, and strength-ranked top/weakest entries.
|
||||
- CLI smoke tests and regression fixtures covering retention decay, stale-prune removal, type caps, reinforcement, invalid timestamps, and diagnostics.
|
||||
|
||||
### Changed
|
||||
|
||||
- Workspace memory rendering now ranks entries by retention strength, not the previous priority/penalty model.
|
||||
- Confidence is retained for compatibility but no longer affects retention scoring.
|
||||
- Deprecated `safetyCritical` is retained for JSON compatibility but no longer affects retention strength or type-cap behavior.
|
||||
- Old or stale-marked memories are no longer hard-pruned; they remain stored and only fall out of rendered context through strength and cap competition.
|
||||
- Existing duplicate promotion and dedupe paths now reinforce the surviving memory instead of only absorbing the duplicate.
|
||||
- Health output now separates stored active memories from rendered candidates to make cap behavior easier to understand.
|
||||
- Default prompt budgets are lower after calibration against observed rendered output: workspace memory is 3600 characters and hot session state is 700 characters.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Invalid `updatedAt` or `retentionClock` values no longer produce `NaN` retention strength or unstable sorting.
|
||||
- Dormant age calculation only discounts the dormant overlap since an entry was created, so new memories do not inherit old workspace dormancy.
|
||||
- Type max totals above the global cap are handled correctly: the global rendered limit still wins.
|
||||
|
||||
### Not Included Yet
|
||||
|
||||
- Delete tombstones and explicit `supersedes` chain enforcement remain deferred follow-up work.
|
||||
- Hot/warm/cold tiered storage remains a future v1.6 direction.
|
||||
|
||||
## [1.4.0] - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- Local migration audit log for the `2026-04-28-quality-cleanup` migration:
|
||||
`~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`.
|
||||
- Local extraction rejection log for rejected compaction memory candidates:
|
||||
`~/.local/share/opencode-working-memory/extraction-rejections.jsonl`.
|
||||
- Sanitized real-workspace regression fixtures for memory cleanup migration behavior.
|
||||
- Safe workspace residue cleanup tooling that dry-runs by default and quarantines definite temp/test workspace stores instead of deleting them.
|
||||
|
||||
### Changed
|
||||
|
||||
- Unified memory quality rules in a shared quality gate for compaction memory candidates and cleanup checks.
|
||||
- Rewritten compaction memory prompt to reduce over-production of low-quality memories.
|
||||
- Changed quality cleanup migration to be conservative: it supersedes only high-confidence garbage patterns, including progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries.
|
||||
- Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, user workflow preferences, and architecture decisions.
|
||||
- Isolated test runs under a temporary `XDG_DATA_HOME` so test workspaces no longer pollute real local workspace memory data.
|
||||
|
||||
### Recovery note
|
||||
|
||||
The cleanup migration changes matching entries to `status: "superseded"`; it does not delete the entry. If a useful memory is superseded, inspect the migration audit log and restore by changing that entry back to `status: "active"` in the workspace's `workspace-memory.json`. The migration runs once per workspace.
|
||||
|
||||
## [1.3.3] - 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added atomic cross-process storage writes with stale-lock recovery and heartbeat refresh to prevent concurrent memory-file corruption.
|
||||
- Scoped pending-memory promotion by owner/session so global unowned cleanup no longer removes active owned entries.
|
||||
- Retained source-aware pending memories until they are actually promoted, absorbed, superseded, or rejected.
|
||||
- Persisted load-time security redaction and expanded Bearer-token redaction to reduce secret retention risk.
|
||||
- Hardened workspace normalization, cache bounds, rejected-entry retention, and session cleanup behavior.
|
||||
|
||||
## [1.3.2] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Compatibility CI now installs dependencies with `npm install` so it works in this no-lockfile repository.
|
||||
- Compatibility CI now runs on Node 24, matching the test command's `--experimental-strip-types` requirement.
|
||||
|
||||
## [1.3.1] - 2026-04-27
|
||||
|
||||
### Added
|
||||
|
||||
- Pending journal retention: max 50 entries, 30-day TTL, automatic pruning on save.
|
||||
- Plugin capability test to catch missing OpenCode hooks before release.
|
||||
- CI workflow for weekly OpenCode plugin API compatibility testing.
|
||||
- Indirect prompt-injection filtering for workspace memory candidates.
|
||||
- Expanded credential redaction for common API key, token, secret, credential, auth, and private-key labels.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pending memory journal entries are now bounded and pruned instead of growing indefinitely.
|
||||
- Adversarial memory candidates that try to override system instructions are rejected before storage.
|
||||
- Broader credential-like labels are redacted from workspace memory text.
|
||||
|
||||
### Changed
|
||||
|
||||
- Memory dedupe is now repo-agnostic: project/reference entries use exact canonical text plus generic URL/path identity, while decision/feedback entries no longer use repository-specific topic heuristics.
|
||||
- OpenCode plugin compatibility is documented and declared as `>=1.2.0 <2.0.0`.
|
||||
- README limitations now concisely document compatibility, secret handling, semantic-memory scope, plugin ordering, and multi-process write boundaries.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Compatibility is tested against OpenCode plugin API `>=1.2.0 <2.0.0`.
|
||||
- Credential redaction is best-effort; do not store secrets.
|
||||
- This is working memory, not semantic search.
|
||||
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||
- Multi-process writes to the same workspace are not fully serialized.
|
||||
|
||||
## [1.3.0] - 2026-04-27
|
||||
|
||||
### Added
|
||||
|
||||
- P0 consolidation accounting for workspace memory promotion.
|
||||
- Accounting-aware deduplication (`dedupeLongTermEntriesWithAccounting`).
|
||||
- Accounting-aware normalization (`normalizeWorkspaceMemoryWithAccounting`).
|
||||
- Promotion classification: promoted, absorbed, superseded, rejected.
|
||||
- Remove absorbed/superseded keys from rejected set to avoid duplicate rejection tracking.
|
||||
- Memory quality evaluation fixtures covering accepted durable facts and rejected noisy facts.
|
||||
- Sharper compaction memory extraction prompt with concrete good/bad memory examples.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Promotion accounting now clears only pending memories that survive workspace normalization/cap limits.
|
||||
- `session.deleted` now uses shared session ID extraction, matching `session.compacted` behavior.
|
||||
- Absorbed duplicate pending memories are accounted for instead of retrying forever.
|
||||
- Active vs superseded boundary when promoting pending memories (superseded entries no longer block promotion of same-key active memories).
|
||||
- Removed unused `rejected_duplicate_lower_quality` type.
|
||||
|
||||
### Changed
|
||||
|
||||
- Deferred pending journal safety cap implementation (see TODO in `src/pending-journal.ts`).
|
||||
- Clarified superseded accounting semantics: P0 emits events only, does not archive newly superseded records.
|
||||
- README structure was streamlined around the automatic memory flow and ongoing memory-quality work.
|
||||
- Architecture docs now describe `Memory candidates:` as the primary extraction format and XML candidate blocks as legacy.
|
||||
- Superpowers implementation plans are no longer tracked in git.
|
||||
|
||||
## [1.2.3] - 2026-04-26
|
||||
|
||||
### Added
|
||||
|
||||
- Frozen workspace memory snapshot in `system[1]` for better OpenCode prompt-cache stability.
|
||||
- Ephemeral hot session state and pending memories in later system messages.
|
||||
- Durable pending journal so explicit memories survive until promotion.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Explicit memories no longer mutate the frozen workspace snapshot mid-session.
|
||||
- Pending memories are promoted at safe cache-epoch boundaries.
|
||||
|
||||
## [1.2.0] - 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
- Memory V2 three-layer architecture.
|
||||
- Workspace memory for durable cross-session decisions, preferences, project facts, and references.
|
||||
- Hot session state for active files, open errors, and recent context.
|
||||
- Hook-based memory extraction during OpenCode compaction.
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed manual memory tools in favor of automatic prompt injection.
|
||||
- Moved storage to `~/.local/share/opencode-working-memory/`.
|
||||
|
||||
## [1.1.0] - 2026-04-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved pre-V2 memory documentation and installation flow.
|
||||
|
||||
## [1.0.0] - 2026-04-23
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release with three-layer memory architecture.
|
||||
- Initial OpenCode memory integration.
|
||||
- Basic memory extraction and prompt injection.
|
||||
@@ -1,35 +1,41 @@
|
||||
# OpenCode Working Memory Plugin
|
||||
# OpenCode Working Memory
|
||||
|
||||
[](https://www.npmjs.com/package/opencode-working-memory)
|
||||
[](LICENSE)
|
||||
|
||||
**Advanced four-tier memory architecture that keeps your AI agent sharp, focused, and never forgets what matters.**
|
||||
Automatic memory for OpenCode agents.
|
||||
|
||||
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.
|
||||
Working memory is context that **remembers what matters, fades what changes, and stays out of the way.**
|
||||
|
||||
## What You Get
|
||||
OpenCode Working Memory preserves project decisions, preferences, and references across compactions and sessions, while keeping active files and unresolved errors fresh for the current session — with no manual tools or extra LLM/API calls.
|
||||
|
||||
- 🧠 **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
|
||||
## Why This Exists
|
||||
|
||||
OpenCode compaction keeps conversations manageable, but important context can still get lost over time.
|
||||
|
||||
It adds a workspace-aware memory layer so your agent can remember durable facts while keeping short-term session state fresh and lightweight.
|
||||
|
||||
Use it when you want your agent to remember things like:
|
||||
|
||||
- Project conventions
|
||||
- User preferences
|
||||
- Architecture decisions
|
||||
- Important file paths or references
|
||||
- Current active files and unresolved errors
|
||||
|
||||
## Features
|
||||
|
||||
- **Workspace memory** — durable project facts, preferences, decisions, and references across sessions.
|
||||
- **Hot session state** — active files, open errors, and current working context for the current session.
|
||||
- **Explicit memory triggers** — users can say “remember this”, “記住”, “覚えて”, or “기억해” to save durable facts.
|
||||
- **Compaction-based extraction** — memory extraction piggybacks on OpenCode’s existing compaction flow.
|
||||
- **No manual tools** — memory is injected automatically into the system prompt.
|
||||
- **Quality guards** — filters noisy memories, temporary progress snapshots, stack traces, raw errors, and credentials.
|
||||
- **Retention decay** — keeps the strongest memories in prompt context while older or weaker memories fade out naturally; important and reinforced memories decay more slowly.
|
||||
|
||||
## Installation
|
||||
|
||||
**Option A: Let an AI agent do it**
|
||||
|
||||
Paste this into any AI agent:
|
||||
|
||||
```
|
||||
Install opencode-working-memory plugin by following:
|
||||
https://raw.githubusercontent.com/sdwolf4103/opencode-working-memory/main/docs/installation.md
|
||||
```
|
||||
|
||||
**Option B: Manual setup**
|
||||
|
||||
Add to your `~/.config/opencode/opencode.json`:
|
||||
Add OpenCode Working Memory to your OpenCode config:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -37,183 +43,233 @@ Add to your `~/.config/opencode/opencode.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Restart OpenCode. The plugin is downloaded and installed automatically.
|
||||
|
||||
### For LLM Agents
|
||||
|
||||
1. Edit `~/.config/opencode/opencode.json`
|
||||
2. Add `"opencode-working-memory"` to the `plugin` array
|
||||
3. Verify the config file was saved correctly
|
||||
|
||||
## Quick Start
|
||||
|
||||
The plugin works automatically once installed. No configuration needed.
|
||||
|
||||
**Try telling your agent:**
|
||||
|
||||
```
|
||||
Use core_memory_update to set my current goal
|
||||
```
|
||||
```
|
||||
Use core_memory_read to show me what you remember
|
||||
```
|
||||
```
|
||||
Use working_memory_add to remember this file path
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 🧠 Core Memory
|
||||
|
||||
Persistent blocks that survive conversation resets:
|
||||
|
||||
- **goal** (1000 chars) - Current task/objective
|
||||
- **progress** (2000 chars) - What's done, in-progress, next steps
|
||||
- **context** (1500 chars) - Key file paths, conventions, patterns
|
||||
|
||||
### 💡 Working Memory
|
||||
|
||||
Auto-extracts and ranks important information:
|
||||
|
||||
- **Slots** (guaranteed visibility): errors, decisions, todos, dependencies
|
||||
- **Pool** (ranked by relevance): file paths, recent activity
|
||||
- Exponential decay keeps memory fresh
|
||||
- FIFO limits prevent bloat
|
||||
|
||||
### 🎯 Memory Pressure Monitoring
|
||||
|
||||
Real-time token tracking from session database:
|
||||
|
||||
- Monitors context window usage (75% moderate → 90% high)
|
||||
- Proactive intervention messages when pressure is high
|
||||
- Pressure-aware smart pruning (adapts compression based on pressure)
|
||||
|
||||
### 🧹 Storage Governance
|
||||
|
||||
Prevents unbounded disk growth:
|
||||
|
||||
- Auto-cleanup on session deletion (all artifacts removed)
|
||||
- Active cache management (max 300 files/session, 7-day TTL)
|
||||
- Silent background operation
|
||||
|
||||
### 📊 Smart Pruning
|
||||
|
||||
Intelligent tool output compression:
|
||||
|
||||
- Per-tool strategies (keep-all, keep-ends, keep-last, discard)
|
||||
- Pressure-aware limits (2k/5k/10k lines based on memory pressure)
|
||||
- Preserves important context while reducing noise
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Installation Guide](docs/installation.md) - Detailed setup instructions
|
||||
- [Architecture Overview](docs/architecture.md) - How it works under the hood
|
||||
- [Configuration](docs/configuration.md) - Customization options
|
||||
- [Agent Developer Guide](AGENTS.md) - For plugin developers
|
||||
|
||||
## Tools Provided
|
||||
|
||||
The plugin exposes these tools to your OpenCode agent:
|
||||
|
||||
- `core_memory_update` - Update goal/progress/context blocks
|
||||
- `core_memory_read` - Read current memory state
|
||||
- `working_memory_add` - Manually add important items
|
||||
- `working_memory_clear` - Clear all working memory
|
||||
- `working_memory_clear_slot` - Clear specific slot (errors/decisions)
|
||||
- `working_memory_remove` - Remove specific item by content
|
||||
Then restart OpenCode. It activates automatically.
|
||||
|
||||
## 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% (moderate) / 90% (high) │
|
||||
│ • Sends proactive interventions │
|
||||
│ • Adjusts pruning aggressiveness │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Storage Governance │
|
||||
│ • Session deletion → cleanup all artifacts │
|
||||
│ • Every 20 calls → sweep old cache (300 max, 7d TTL) │
|
||||
│ • Silent background operation │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
OpenCode Working Memory adds durable memory without making extra LLM/API calls.
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────┐
|
||||
│ 🧭 Conversation Events │
|
||||
│ edits, commands, errors, remembers │
|
||||
└──────────────────┬───────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ 🔥 Hot Session State │
|
||||
│ active files, open errors, pending │
|
||||
│ │
|
||||
│ ~/.local/share/opencode-working- │
|
||||
│ memory/workspaces/{hash}/sessions/ │
|
||||
│ {sessionID}.json │
|
||||
└──────────────────┬───────────────────┘
|
||||
│ when OpenCode compacts
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ 🧠 OpenCode Compaction │
|
||||
│ existing LLM/API call │
|
||||
│ + memory extraction instructions │
|
||||
│ │
|
||||
│ zero extra API calls │
|
||||
└──────────────────┬───────────────────┘
|
||||
│ filter, redact, dedupe
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ 📦 Workspace Memory │
|
||||
│ decisions, preferences, refs │
|
||||
│ │
|
||||
│ ~/.local/share/opencode-working- │
|
||||
│ memory/workspaces/{hash}/ │
|
||||
│ workspace-memory.json │
|
||||
└──────────────────┬───────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ ⚡ Prompt Context │
|
||||
│ system[1]: frozen workspace memory │
|
||||
│ system[2+]: hot session state │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Why This Plugin?
|
||||
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
|
||||
|
||||
**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
|
||||
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
|
||||
|
||||
**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
|
||||
The runtime context has three layers:
|
||||
|
||||
## Does working-memory system increase token usage? It depends.
|
||||
| Layer | Purpose | Lifetime |
|
||||
|---|---|---|
|
||||
| Workspace Memory | Durable decisions, preferences, project facts, references | Cross-session |
|
||||
| Hot Session State | Active files, open errors, recent context | Current session |
|
||||
| Native OpenCode State | Todos and built-in state | OpenCode-managed |
|
||||
|
||||
It depends on your workflow.
|
||||
## Workspace Memory
|
||||
|
||||
- 🧹 **Clean Slate user** (for example, using DCP and frequently restarting sessions)
|
||||
- ⚠️ Yes, it might add slight overhead.
|
||||
- Because you keep starting fresh, automated memory persistence does not get enough time to pay off.
|
||||
Workspace memory is for durable information that should help future sessions.
|
||||
|
||||
- 🚀 **Long Haul user** (staying in one session until token limits/compaction hit)
|
||||
- ✅ This plugin is a token saver.
|
||||
- Without it, compaction can cause the agent to lose the goal, forget active files, or make wrong assumptions, which creates correction loops.
|
||||
- By preserving high-value context (Goals, Progress, Active Files), the agent inherits its previous state quickly. The small memory-prompt cost avoids the larger cost of the agent getting lost.
|
||||
Examples:
|
||||
|
||||
## Configuration (Optional)
|
||||
```md
|
||||
- [decision] Use npm cache for plugin loading, not npm link.
|
||||
- [project] This repo uses TypeScript and Node.js test runner.
|
||||
- [feedback] User prefers concise implementation summaries.
|
||||
- [reference] Storage lives under ~/.local/share/opencode-working-memory/.
|
||||
```
|
||||
|
||||
The plugin works great with zero configuration. To customize behavior, modify the constants at the top of `index.ts`. See the [Configuration Guide](docs/configuration.md) for all tunable options.
|
||||
Memory types:
|
||||
|
||||
- `feedback` — user preferences or recurring feedback
|
||||
- `project` — stable project-level facts
|
||||
- `decision` — important implementation or architecture decisions
|
||||
- `reference` — useful paths, commands, or configuration references
|
||||
|
||||
### Retention Decay
|
||||
|
||||
> **Memory should fade, so the agent can keep learning.**
|
||||
>
|
||||
> Important memories decay more slowly, but every memory must leave room for newer project reality.
|
||||
|
||||
Memories decay over time. The strongest stay visible in the prompt; weaker ones fade from context without being deleted.
|
||||
|
||||
```text
|
||||
strength
|
||||
│
|
||||
██ │╲____ reinforced: slower decline
|
||||
│ ╲______
|
||||
▒▒ │ ╲__ ordinary memory
|
||||
│ ╲
|
||||
├ ─ ─ ─ ─ ─ ─ ─ ─╲─ dynamic cap competition zone
|
||||
░░ │ ╲ easier for new memories to replace
|
||||
│ ↑ still stored, not deleted
|
||||
└──────────────────────────────→ time / sessions
|
||||
```
|
||||
|
||||
## Explicit Memory Triggers
|
||||
|
||||
You can explicitly ask the agent to remember durable facts.
|
||||
|
||||
Examples:
|
||||
|
||||
```md
|
||||
Remember this: we prefer Vitest for new frontend tests.
|
||||
記住:這個 repo 發 release 前要先跑 npm test。
|
||||
覚えておいて: API clients should use the shared retry helper.
|
||||
기억해줘: this project uses pnpm, not npm.
|
||||
```
|
||||
|
||||
Supported trigger languages include:
|
||||
|
||||
| Language | Examples |
|
||||
|---|---|
|
||||
| English | `remember this`, `save to memory`, `from now on`, `my preference` |
|
||||
| Chinese | `記住`, `记住`, `記得`, `请帮我记住` |
|
||||
| Japanese | `覚えて`, `覚えておいて`, `メモして` |
|
||||
| Korean | `기억해`, `기억해줘`, `메모해줘` |
|
||||
|
||||
Negative requests are respected too:
|
||||
|
||||
```md
|
||||
Don't remember this.
|
||||
不要記住這個。
|
||||
覚えないで。
|
||||
기억하지 마.
|
||||
```
|
||||
|
||||
Avoid saving:
|
||||
|
||||
- Secrets, passwords, tokens, or credentials
|
||||
- Temporary progress updates
|
||||
- Raw command output
|
||||
- Short-lived session details
|
||||
|
||||
## Quality Guards
|
||||
|
||||
OpenCode Working Memory tries to keep memory useful and low-noise.
|
||||
|
||||
It includes guards for:
|
||||
|
||||
- Credential redaction
|
||||
- Duplicate memory cleanup
|
||||
- Accounting for promoted, absorbed, superseded, and rejected memories
|
||||
- Strength-based retention so useful memories stay visible without hard age pruning
|
||||
- Filtering stack traces, git hashes, raw errors, and noisy path-heavy facts
|
||||
- Rejecting temporary project progress snapshots
|
||||
|
||||
The goal is to remember durable facts, not every detail.
|
||||
|
||||
**Good memory is selective memory.**
|
||||
|
||||
Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y".
|
||||
|
||||
For local development cleanup, use:
|
||||
|
||||
```bash
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
npm run cleanup:workspaces -- --quarantine
|
||||
```
|
||||
|
||||
The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
|
||||
|
||||
## Configuration
|
||||
|
||||
OpenCode Working Memory works out of the box.
|
||||
|
||||
Default behavior:
|
||||
|
||||
- Workspace memory budget: 3600 characters (~900 tokens)
|
||||
- Workspace memory limit: 28 entries
|
||||
- Hot session state budget: 700 characters (~175 tokens)
|
||||
- Active files shown: 8
|
||||
- Open errors shown: 3
|
||||
|
||||
See [Configuration](docs/configuration.md) for customization options.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Current focus:
|
||||
|
||||
- Add explicit delete tombstones so removed memories do not get re-extracted.
|
||||
- Enforce explicit `supersedes` chains for safer replacement of obsolete memories.
|
||||
- Explore tiered hot/warm/cold storage after the retention model has more real-world data.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture Overview](docs/architecture.md)
|
||||
- [Configuration](docs/configuration.md)
|
||||
- [Installation Guide](docs/installation.md)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sdwolf4103/opencode-working-memory.git
|
||||
cd opencode-working-memory
|
||||
npm install
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenCode >= 1.0.0
|
||||
- OpenCode plugin API `>=1.2.0 <2.0.0`
|
||||
- Node.js >= 18.0.0
|
||||
- `@opencode-ai/plugin` >= 1.2.0
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires OpenCode plugin API `>=1.2.0 <2.0.0`; OpenCode hook changes may break compatibility.
|
||||
- Not a secret manager. Credential redaction is best-effort. Do not store secrets.
|
||||
- Working memory only. No semantic search, embeddings, or vector knowledge base.
|
||||
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||
- Multiple OpenCode processes on the same workspace may race on local files.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
MIT License. See [LICENSE](LICENSE) for details.
|
||||
|
||||
## Support
|
||||
|
||||
- 📖 [Documentation](docs/)
|
||||
- 🐛 [Report Issues](https://github.com/sdwolf4103/opencode-working-memory/issues)
|
||||
|
||||
## Credits
|
||||
|
||||
Inspired by the needs of real-world OpenCode usage and built to solve actual pain points in AI-assisted development.
|
||||
|
||||
> This project is not affiliated with or endorsed by the OpenCode team.
|
||||
- [Documentation](docs/)
|
||||
- [Report Issues](https://github.com/sdwolf4103/opencode-working-memory/issues)
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for the OpenCode community**
|
||||
Made with ❤️ for the OpenCode community.
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.5.1 (2026-04-30)
|
||||
|
||||
### Evidence Loop and Explainability
|
||||
|
||||
This release adds an evidence-based audit trail for memory lifecycle events and user-facing diagnostics for understanding why memories are rendered, promoted, or rejected.
|
||||
|
||||
> **Evidence before sublimation.** Every memory decision can be traced.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Evidence log**: extraction, promotion, reinforcement, render, and storage events are now recorded in a per-workspace `events.jsonl` with 90-day retention and 5000-event cap.
|
||||
- **User explainability**: `memory-diag explain` shows per-memory render status with strength, reasons, and evidence. `memory-diag trace --memory <id>` shows the full lifecycle history.
|
||||
- **Machine-readable diagnostics**: `memory-diag health --json` outputs structured `MemoryDiagJSON` for scripting.
|
||||
- **Calendar-day reinforcement gate**: reinforcement now requires distinct UTC calendar days, preventing repetitive-task gaming that could inflate a memory's strength within a single day.
|
||||
- **SafetyCritical deprecation complete**: the `safetyCritical` field no longer affects retention strength or type-cap bypass. All memories fade by the same rules.
|
||||
- **Retention module extraction**: retention constants and calculations moved to `src/retention.ts` for cleaner separation.
|
||||
|
||||
### Privacy
|
||||
|
||||
- Evidence text previews are credential-redacted. Memory content is stored as truncated hashes, never in full.
|
||||
- Diagnostics default to redacted output. `--raw` is available for maintainers.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes required.
|
||||
- Existing workspace memory files remain compatible.
|
||||
- Evidence logs are created automatically; no migration needed.
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm test` — 271 tests passing
|
||||
|
||||
---
|
||||
|
||||
## 1.5.0 (2026-04-29)
|
||||
|
||||
### Retention Decay Model
|
||||
|
||||
This release changes workspace memory retention from hard stale pruning and additive priority scoring to a strength-based decay model.
|
||||
|
||||
Think of it like a forgetting curve: memories fade over time, but important and reinforced memories decay slower. Weak entries fall out of rendered prompt context by cap competition, not hard deletion.
|
||||
|
||||
> **Memory should fade, so the agent can keep learning.**
|
||||
> Important memories decay slower, but every memory must leave room for newer project reality and avoid long-term memory pollution.
|
||||
|
||||
```text
|
||||
strength
|
||||
│
|
||||
██ │╲____ reinforced: slower decline
|
||||
│ ╲______
|
||||
▒▒ │ ╲__ ordinary memory
|
||||
│ ╲
|
||||
├ ─ ─ ─ ─ ─ ─ ─ ─╲─ dynamic cap competition zone
|
||||
░░ │ ╲ easier for new memories to replace
|
||||
│ ↑ still stored, not deleted
|
||||
└──────────────────────────────→ time / sessions
|
||||
```
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Strength-based retention**: workspace memory now uses exponential decay: initial strength × age decay.
|
||||
- **Better initial strength**: type, source, and user importance now determine how strong a memory starts.
|
||||
- **No confidence scoring**: confidence remains in stored data for compatibility, but it no longer affects retention ranking.
|
||||
- **Type caps**: rendered workspace memory now caps feedback, decisions, project facts, and references separately so one type cannot monopolize all 28 slots.
|
||||
- **Deprecation:** `safetyCritical` field no longer affects retention strength or type-cap bypass. All system memories now fade according to the same rules. Safety rules belong in user-controlled `agent.md` files, not in system memory.
|
||||
- **Dormant-aware age**: after 14 inactive days, additional dormant workspace time counts at 0.25x so paused projects do not forget too aggressively.
|
||||
- **Reinforcement**: repeated matching memories reinforce the survivor and slow future decay, with same-session and one-hour guards to avoid accidental spam.
|
||||
- **No hard stale pruning**: old or stale-marked memories are no longer automatically dropped by age; they lose rendered space only through cap competition.
|
||||
- **Calibrated prompt budgets**: observed rendered output was typically under ~2000 characters for workspace memory and ~500 characters for hot session state, so defaults were reduced to 3600 and 700 characters to keep overhead lower while retaining buffer.
|
||||
- **Clearer health output**: `memory-diag health` now reports stored vs rendered counts, type caps, global cap overflow, dormancy, retention monitoring, and strength-ranked top/weakest entries.
|
||||
|
||||
### Why This Helps
|
||||
|
||||
- User preferences and explicit memories are less likely to disappear just because inferred project facts are newer.
|
||||
- Feedback, decisions, project facts, and references share prompt space more fairly.
|
||||
- Returning to an old workspace is less punishing because dormant time decays more slowly.
|
||||
- Maintainers can see why memories are rendered or capped instead of guessing from a single active-memory count.
|
||||
- Stale entries can fade out of prompt context without destructive cleanup.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Maintainers can inspect retention behavior with:
|
||||
|
||||
```bash
|
||||
bun scripts/memory-diag.ts health
|
||||
```
|
||||
|
||||
The health output now includes sections like:
|
||||
|
||||
```txt
|
||||
Stored active memories: 28
|
||||
Rendered candidates: 20
|
||||
|
||||
By type:
|
||||
feedback stored=17 rendered=10 typeCap=10
|
||||
decision stored=11 rendered=10 typeCap=10
|
||||
|
||||
Retention caps:
|
||||
type-capped entries: 8
|
||||
global-cap overflow: 0
|
||||
|
||||
Dormancy:
|
||||
dormant discount active: no
|
||||
|
||||
Retention monitoring:
|
||||
high_importance_ratio: 0.0% (alert > 30%)
|
||||
```
|
||||
|
||||
### Not Included Yet
|
||||
|
||||
- Delete tombstones are not implemented in this release.
|
||||
- Explicit `supersedes` chain enforcement is still deferred.
|
||||
- Hot/warm/cold tiered storage remains future work.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes required.
|
||||
- Existing workspace memory files remain compatible.
|
||||
- Existing entries without a `retentionClock` fall back safely to existing timestamps.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm test` — 242 tests passing
|
||||
- `bun scripts/memory-diag.ts health`
|
||||
|
||||
---
|
||||
|
||||
## 1.4.0 (2026-04-28)
|
||||
|
||||
### Memory Quality Cleanup
|
||||
|
||||
This release improves automatic workspace memory quality without risking broad cleanup of useful existing memories.
|
||||
|
||||
The quality gate is now shared across compaction extraction and migration checks, the compaction prompt is stricter about what should become durable memory, and the one-time migration is intentionally conservative.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Unified quality rules**: memory quality checks now live in one shared module and apply consistently across feedback, decisions, project facts, and references.
|
||||
- **Stricter compaction output**: the compaction prompt now tells the model to save fewer memories and prefer durable facts, user preferences, architecture decisions, and hard-to-rediscover references.
|
||||
- **Conservative migration cleanup**: the `2026-04-28-quality-cleanup` migration only supersedes high-confidence garbage patterns, not every rejected memory.
|
||||
- **Audit logs**: automatic migration cleanup writes local JSONL audit records so superseded entries can be inspected and restored.
|
||||
- **Extraction rejection logs**: newly rejected compaction candidates are logged locally to help calibrate future quality rules.
|
||||
- **Regression coverage**: migration behavior is tested against sanitized real-workspace patterns to prevent mass false positives from coming back.
|
||||
- **Workspace cleanup tooling**: a dev/admin cleanup command can dry-run or quarantine definite temp/test workspace residues without deleting unknown missing-root workspaces.
|
||||
- **Test storage isolation**: test runs now use a temporary `XDG_DATA_HOME`, preventing fixture workspaces from polluting real local memory data.
|
||||
|
||||
### What Gets Cleaned Up
|
||||
|
||||
The migration may supersede existing `source: "compaction"` memories only when they match hard garbage patterns:
|
||||
|
||||
- Empty entries
|
||||
- Progress snapshots, such as "Wave 1 completed successfully"
|
||||
- Test or suite count snapshots, such as "180 tests passed"
|
||||
- Raw errors and stack traces
|
||||
- Commit or CI snapshots
|
||||
- Temporary status notes, such as "Currently running npm test"
|
||||
- Active file snapshots
|
||||
- Code or API signatures
|
||||
- Path-heavy entries that are just rediscoverable file lists
|
||||
|
||||
### What Is Protected
|
||||
|
||||
The migration does not supersede entries whose only issue is a soft heuristic failure, such as:
|
||||
|
||||
- `bad_feedback`
|
||||
- `bad_decision`
|
||||
|
||||
This protects useful declarative memories like:
|
||||
|
||||
- Product branding rules
|
||||
- API facts
|
||||
- Release rules
|
||||
- Architecture decisions
|
||||
- User workflow preferences
|
||||
|
||||
Explicit and manual memories are also protected.
|
||||
|
||||
### Migration Behavior
|
||||
|
||||
- Runs once per workspace.
|
||||
- Only affects active `source: "compaction"` entries.
|
||||
- Marks matching entries as `status: "superseded"` instead of deleting them.
|
||||
- Adds `quality_cleanup` and `quality:<reason>` tags to superseded entries.
|
||||
- Writes audit logs to:
|
||||
`~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`
|
||||
- Writes extraction rejection logs to:
|
||||
`~/.local/share/opencode-working-memory/extraction-rejections.jsonl`
|
||||
|
||||
### Recovery
|
||||
|
||||
If a useful memory is superseded, inspect the migration audit log and restore the entry by changing its status back to `"active"` in the workspace's `workspace-memory.json`.
|
||||
|
||||
### Workspace Residue Cleanup
|
||||
|
||||
If old test/temp workspace stores already exist locally, inspect them first:
|
||||
|
||||
```bash
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
```
|
||||
|
||||
To move definite temp/test residues into a local quarantine folder instead of deleting them:
|
||||
|
||||
```bash
|
||||
npm run cleanup:workspaces -- --quarantine
|
||||
```
|
||||
|
||||
The cleanup command skips existing workspace roots and unknown missing-root workspaces by default.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No configuration changes required.
|
||||
- Existing workspace memory files remain compatible.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm test`
|
||||
- `npm run typecheck`
|
||||
|
||||
---
|
||||
|
||||
## 1.3.2 (2026-04-27)
|
||||
|
||||
### CI Compatibility Patch
|
||||
|
||||
- Fixed the compatibility workflow so dependency installation works without a committed lockfile.
|
||||
- Moved compatibility CI to Node 24 so TypeScript-stripping tests run correctly.
|
||||
- No runtime or storage changes.
|
||||
|
||||
---
|
||||
|
||||
## 1.3.1 (2026-04-27)
|
||||
|
||||
### Security and Reliability Patch
|
||||
|
||||
This patch release keeps the v1.3 memory-consolidation model intact while tightening storage safety, compatibility checks, and repository-agnostic dedupe behavior.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Bounded pending journal**: pending memories are capped at 50 entries and pruned after 30 days.
|
||||
- **Security hardening**: workspace memory candidates now reject indirect prompt-injection attempts, and redaction covers broader token, secret, credential, auth, and private-key labels.
|
||||
- **Compatibility coverage**: plugin capability tests and weekly OpenCode plugin API compatibility CI help catch hook drift before release.
|
||||
- **Repo-agnostic dedupe**: long-term memory dedupe no longer depends on hardcoded project-specific topic rules; project/reference memories use generic URL/path identity plus exact canonical matching.
|
||||
- **Clearer limitations**: README and changelog now document compatibility, best-effort secret redaction, working-memory scope, plugin ordering, and multi-process write boundaries.
|
||||
|
||||
### Thanks
|
||||
|
||||
- Thanks @StevenChoo for the security hardening contribution in #3.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No user migration is required.
|
||||
- Existing workspace memory and pending journal files remain compatible.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm test`
|
||||
- `npm run typecheck`
|
||||
|
||||
---
|
||||
|
||||
## 1.3.0 (2026-04-27)
|
||||
|
||||
### Better Memory Consolidation
|
||||
|
||||
This release makes OpenCode Working Memory smarter about what happens to saved memories after compaction. Instead of treating every pending memory as simply "kept" or "not kept", it now understands four outcomes:
|
||||
|
||||
- **Promoted** — a new memory was saved to workspace memory.
|
||||
- **Absorbed** — the memory was a duplicate of something already remembered.
|
||||
- **Superseded** — a newer same-topic decision or preference replaced an older one.
|
||||
- **Rejected** — the memory was stale, noisy, or over the workspace memory limit.
|
||||
|
||||
### What This Improves
|
||||
|
||||
- **Fewer repeated pending memories**: duplicate or superseded memories no longer keep coming back for promotion.
|
||||
- **Cleaner long-term memory**: old same-topic decisions are replaced more predictably.
|
||||
- **Safer promotion accounting**: pending memories are only cleared when the final normalized workspace memory confirms what happened to them.
|
||||
- **More useful compaction output**: the compaction prompt now includes clearer examples of what should and should not become durable memory.
|
||||
|
||||
### Also Included
|
||||
|
||||
- Memory quality regression fixtures: 5 examples that should be kept and 7 noisy examples that should be rejected.
|
||||
- Fix for `session.deleted` session ID extraction so cleanup and promotion use the same event parsing path.
|
||||
- Fix for active-vs-superseded promotion behavior: archived superseded entries no longer block a fresh active memory.
|
||||
- README and architecture documentation updates.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No user migration is required.
|
||||
- Existing workspace memory files remain compatible.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
- **135 tests pass**.
|
||||
|
||||
---
|
||||
|
||||
## 1.2.3 (2026-04-27)
|
||||
|
||||
### Prompt Cache Optimization — Frozen Snapshot + Ephemeral Delta
|
||||
|
||||
This release optimizes OpenCode Working Memory's impact on OpenCode's prompt cache, following Hermes-style architecture patterns.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Frozen workspace snapshot**: Workspace memory is now rendered once at session start and cached as immutable `system[1]`. No mid-session re-render that could invalidate the cache.
|
||||
- **Ephemeral hot state**: Hot session state (active files, errors) is rendered in `system[2+]`, which is excluded from the first-two-system cache control.
|
||||
- **Durable pending journal**: Explicit memories are written to both session state and a durable workspace-level pending journal, ensuring no data loss between compactions.
|
||||
- **Safe promotion**: Explicit memories are promoted from pending to workspace memory at:
|
||||
- Next session start (before frozen snapshot)
|
||||
- `session.compacted`
|
||||
- `session.deleted` (before cleanup)
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
system[0] → OpenCode / agent header (stable cached)
|
||||
system[1] → Frozen workspace memory snapshot (stable cached)
|
||||
system[2+] → Hot session state + pending memories (dynamic, not cached)
|
||||
```
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Hot state invalidating cache**: Active files / errors updating every tool call previously caused the entire workspace memory block to be re-hashed, killing cache efficiency.
|
||||
- **Explicit memory loss**: Without compaction, explicit memories could be lost when sessions ended without promotion.
|
||||
- **Mid-session mutation**: Explicit memories no longer mutate the running frozen snapshot; they appear as pending and are promoted safely.
|
||||
|
||||
### Migration
|
||||
|
||||
- One-time migration: `2026-04-27-p0-cleanup` removes stale pending journal entries older than 60 days.
|
||||
|
||||
### Tests
|
||||
|
||||
- **91 tests pass** (24 workspace-memory, 34 extractors, 14 plugin, 19 pending-journal)
|
||||
|
||||
---
|
||||
|
||||
## 1.2.2 (2026-04-27)
|
||||
|
||||
### Safer Multilingual Memory Capture
|
||||
|
||||
This release strengthens explicit memory handling across languages while keeping sensitive credentials out of stored workspace memory.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Always-on credential redaction**: Credentials are redacted both when memory is loaded and when it is saved
|
||||
- **Multilingual memory triggers**: Added Japanese and Korean explicit-memory phrases, plus expanded Chinese coverage
|
||||
- **Expanded snapshot filtering**: Rejects Wave/Sprint/Milestone/Task progress snapshots that should not become durable memory
|
||||
- **Higher memory quality bar**: Extraction now focuses on durable facts that will change future behavior
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Credential leakage risk**: Password/PIN-style values are now redacted with delimiter-preserving patterns, including multilingual labels such as `パスワード`, `비밀번호`, `contraseña`, `mot de passe`, and `Passwort`.
|
||||
- **Missing non-English explicit memory requests**: Japanese (`覚えて`, `メモして`), Korean (`기억해`, `메모해줘`), and additional Chinese triggers are now recognized.
|
||||
- **Progress snapshots polluting memory**: Wave/Sprint/Milestone/Task status updates are filtered from long-term memory unless they contain durable facts.
|
||||
|
||||
### Migration
|
||||
|
||||
- Runs one-time cleanup for legacy snapshot entries: `2026-04-26-p0-cleanup`
|
||||
|
||||
---
|
||||
|
||||
## 1.2.1 (2026-04-26)
|
||||
|
||||
### Compaction Memory Quality — Four-Layer Defense
|
||||
|
||||
This release addresses systemic quality issues in workspace memory: duplicates, stale entries, and silently lost memory candidates. A four-layer defense is now in place:
|
||||
|
||||
```
|
||||
Prompt → Durable-content guidance keeps LLM on factual memories
|
||||
Parser → Accepts bracketless format, filters session snapshots
|
||||
Storage → Entity-key dedup + topic supersession + source priority
|
||||
Staleness → Age-based pruning of obsolete compaction/manual entries
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Self-cleaning memory**: Entity-key deduplication, topic supersession, and age-based staleness pruning automatically maintain memory quality
|
||||
- **Robust parser**: Accepts both bracketless (`- type text`) and bracketed (`- [type] text`) formats — no more silently lost memories
|
||||
- **Durable-content prompt**: Compaction template now guides LLM toward factual, long-lived memories while explicitly discouraging session ephemera
|
||||
- **Smart snapshot filtering**: Automatically rejects project-type snapshots (file counts, test counts, Phase progress) that don't belong in long-term memory
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bracketless format bug**: Parser regex only matched `- [type]` pattern; real LLM output often uses `- type` (no brackets). Both formats now accepted. (P0a)
|
||||
- **Purple/italic text in OpenCode UI**: Replaced XML/HTML comment templates with clean Markdown headings. Further hardened with negative instructions to forbid YAML frontmatter. (P0b β)
|
||||
- **Session snapshots polluting memory**: Project entries like "37 個文件", "26 tests pass", "Phase 2 completed" now rejected by parser filter. (P0c)
|
||||
- **Duplicate entries**: Entities deduped by key (e.g., `opencode-agenthub plugin system`). Topic conflicts resolved via supersession: newer shorter facts beat older verbose ones for decisions/feedback. (P0d)
|
||||
- **Stale entries never cleaned**: Compaction/manual entries with `staleAfterDays` now auto-pruned after 30-day grace period.
|
||||
- **Short reference entries rejected**: Admin PIN (`456123`) and config values (`Scrypt n=32768`) now allowed through config value allowlist despite being under 20 chars.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chooseBetterMemory`**: Now accepts `"entity"` mode (length preferred, for project/reference) and `"supersession"` mode (freshness preferred, for decision/feedback).
|
||||
- **Source priority in sort**: Manual/source priority now included as secondary sort tie-breaker after entry priority.
|
||||
|
||||
### Technical Details
|
||||
|
||||
- **Parser formats**: 4 accepted (plain text label primary, plus Markdown section, legacy section, legacy XML)
|
||||
- **Chinese counter words**: Regex matches `個`/`个` between numbers and nouns (e.g., `37 個文件`)
|
||||
- **Entity keys cautious**: Only known product keys extracted (`opencode-agenthub`); generic config references fall back to canonical text dedup
|
||||
|
||||
### Tests
|
||||
|
||||
- **70/70 tests pass** (24 workspace-memory, 34 extractors, 12 plugin)
|
||||
|
||||
---
|
||||
|
||||
## 1.2.0 (2026-04-26)
|
||||
|
||||
### Memory V2 Architecture
|
||||
|
||||
This release introduces a complete architectural redesign from the previous four-tier memory system to a streamlined three-layer architecture:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LAYER 1: WORKSPACE MEMORY │
|
||||
│ Persists across sessions in same workspace (survives restart) │
|
||||
│ Extracted during compaction - NO EXTRA API CALL │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LAYER 2: HOT SESSION STATE │
|
||||
│ Per-session, auto-tracked, resets on new session │
|
||||
│ Tracks: Active files, Open errors, Recent decisions │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LAYER 3: NATIVE OPENCODE STATE │
|
||||
│ Uses OpenCode's built-in todos - No plugin storage needed │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Cross-session memory**: Workspace memory persists between sessions in the same workspace
|
||||
- **No extra API calls**: Memory extraction piggybacks on OpenCode's existing compaction summary
|
||||
- **Zero configuration**: Works out of the box with sensible defaults
|
||||
- **Zero tools**: No manual memory management needed - fully automatic
|
||||
|
||||
### Added
|
||||
|
||||
- **Workspace Memory**: Long-term memory that survives restarts and compactions
|
||||
- Types: `feedback`, `project`, `decision`, `reference`
|
||||
- Sources: `explicit` (user), `compaction`, `manual`
|
||||
- Limits: 5200 chars / 28 entries
|
||||
|
||||
- **Hot Session State**: Automatic tracking for current session
|
||||
- Active files with action-based ranking
|
||||
- Open errors with fingerprinting
|
||||
- Recent decisions for compaction promotion
|
||||
|
||||
- **Quality Gates**:
|
||||
- Canonical deduplication of workspace memories
|
||||
- Negative memory request filtering ("don't remember")
|
||||
- Compaction quality gate (rejects git hashes, stack traces, path-heavy facts)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **False positive error tracking**: `exitCode === undefined` no longer creates spurious errors from commands like `git log` or `cat`
|
||||
- **XML truncation**: Workspace memory rendering never truncates closing `</workspace_memory>` tag
|
||||
- **Negative memory filtering**: Correctly interprets "don't remember this" and "不要記住"
|
||||
- **"always" trigger removed**: No longer treats "always" as a memory trigger keyword
|
||||
|
||||
### Changed
|
||||
|
||||
- **Storage location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/`
|
||||
- **No manual tools**: Removed `core_memory_update`, `core_memory_read` - memory is fully automatic
|
||||
- **Hook-based extraction**: Memory is extracted during `experimental.session.compacting` hook
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Previous four-tier architecture is replaced with three-layer architecture
|
||||
- Core Memory blocks (goal/progress/context) removed in favor of typed entries
|
||||
- Working Memory slots and pool replaced with Hot Session State
|
||||
- Pressure monitoring and smart pruning removed (not needed with new architecture)
|
||||
|
||||
### Migration
|
||||
|
||||
- Old memory files (`.opencode/memory-core/`, `.opencode/memory-working/`) are not migrated
|
||||
- New storage location is used (`~/.local/share/opencode-working-memory/`)
|
||||
- No action required - plugin starts fresh with new architecture
|
||||
|
||||
### Technical Details
|
||||
|
||||
- **Plugin entry**: `index.ts` exports `{ id: "working-memory", server: MemoryV2Plugin }`
|
||||
- **Hooks implemented**:
|
||||
- `experimental.chat.system.transform` - Inject workspace memory and hot session state
|
||||
- `tool.execute.after` - Track active files and open errors
|
||||
- `experimental.session.compacting` - Extract workspace memory candidates
|
||||
- `event` - Handle session lifecycle events
|
||||
|
||||
### Files in Package
|
||||
|
||||
```
|
||||
index.ts
|
||||
src/extractors.ts
|
||||
src/opencode.ts
|
||||
src/paths.ts
|
||||
src/plugin.ts
|
||||
src/session-state.ts
|
||||
src/storage.ts
|
||||
src/types.ts
|
||||
src/workspace-memory.ts
|
||||
README.md
|
||||
LICENSE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1.1.2 (Previous Version)
|
||||
|
||||
- Four-tier memory architecture
|
||||
- Core Memory blocks (goal/progress/context)
|
||||
- Working Memory with slots and pool
|
||||
- Pressure monitoring with interventions
|
||||
- Smart pruning of tool outputs
|
||||
+312
-283
@@ -2,374 +2,403 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Working Memory Plugin implements a **four-tier memory architecture** designed to maximize context efficiency for AI agents in OpenCode sessions.
|
||||
OpenCode Working Memory implements a **three-layer memory architecture** designed to preserve context across OpenCode session compactions.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TIER 1: CORE MEMORY │
|
||||
│ Persistent blocks: goal (1000) | progress (2000) | context (1500) │
|
||||
│ Survives compaction, always visible in system prompt │
|
||||
│ LAYER 1: WORKSPACE MEMORY (Long-term, cross-session) │
|
||||
│ • Persistent storage: ~/.local/share/opencode-working-... │
|
||||
│ • Types: feedback | project | decision | reference │
|
||||
│ • Sources: explicit | compaction | manual │
|
||||
│ • Render limits: 3600 chars / 28 entries │
|
||||
│ • Survives: session reset, compaction (same workspace) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TIER 2: WORKING MEMORY │
|
||||
│ Session-scoped slots + memory pool │
|
||||
│ Slots: error(3) | decision(5) | todo(3) | dependency(3) │
|
||||
│ Pool: Exponential decay (γ=0.85) + mention tracking │
|
||||
│ LAYER 2: HOT SESSION STATE (Short-term, per-session) │
|
||||
│ • Session-scoped tracking: active files, open errors │
|
||||
│ • Storage: sessions/{sessionID}.json │
|
||||
│ • Auto-extracted from tool usage patterns │
|
||||
│ • Cleared: on new session start │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TIER 3: SMART PRUNING │
|
||||
│ Filters tool outputs before adding to conversation │
|
||||
│ Removes: file lists, verbose logs, repetitive content │
|
||||
│ Modes: normal → aggressive → hyper-aggressive │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TIER 4: PRESSURE MONITORING │
|
||||
│ Tracks context usage: safe → moderate → high │
|
||||
│ Thresholds: 75% (moderate) | 90% (high) │
|
||||
│ Intervention: Sends promptAsync() with full visible prompt │
|
||||
│ LAYER 3: NATIVE OPENCODE STATE │
|
||||
│ • Uses OpenCode's built-in todos during compaction │
|
||||
│ • No additional storage required │
|
||||
│ • Delegated to OpenCode's native features │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Phase 1: Core Memory Foundation
|
||||
## Layer 1: Workspace Memory
|
||||
|
||||
### Purpose
|
||||
Provide persistent memory blocks that survive conversation compaction and are always injected into the system prompt.
|
||||
|
||||
Long-term memory that persists across sessions within the same workspace. Perfect for:
|
||||
- Project conventions and patterns
|
||||
- Important decisions that span sessions
|
||||
- User preferences for this codebase
|
||||
|
||||
### Storage
|
||||
- **Location**: `.opencode/memory-core/<sessionID>.json`
|
||||
|
||||
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
|
||||
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
|
||||
- **Schema**:
|
||||
```typescript
|
||||
{
|
||||
sessionID: string;
|
||||
blocks: {
|
||||
goal: { content: string; chars: number; maxChars: 1000; updatedAt: string };
|
||||
progress: { content: string; chars: number; maxChars: 2000; updatedAt: string };
|
||||
context: { content: string; chars: number; maxChars: 1500; updatedAt: string };
|
||||
};
|
||||
updatedAt: string;
|
||||
version: 1,
|
||||
workspace: { root: string, key: string },
|
||||
limits: { maxRenderedChars: 3600, maxEntries: 28 },
|
||||
entries: LongTermMemoryEntry[],
|
||||
lastActivityAt?: 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)
|
||||
### Entry Types
|
||||
|
||||
### Operations
|
||||
- **replace**: Completely replace block content
|
||||
- **append**: Add content to end (auto-adds newline)
|
||||
| Type | Purpose | Example |
|
||||
|------|---------|---------|
|
||||
| `feedback` | User preferences | "User prefers functional React components" |
|
||||
| `project` | Project-level info | "This monorepo uses turborepo" |
|
||||
| `decision` | Important decisions | "Use PostgreSQL for primary database" |
|
||||
| `reference` | Key references | "API endpoints defined in `src/api/`" |
|
||||
|
||||
### Tools
|
||||
- `core_memory_update`: Update or append to blocks
|
||||
- `core_memory_read`: Read current state of all blocks
|
||||
### Entry Sources
|
||||
|
||||
| Source | Confidence | How Added |
|
||||
|--------|------------|-----------|
|
||||
| `explicit` | 1.0 | User said "remember this" |
|
||||
| `compaction` | 0.75 | Extracted during compaction |
|
||||
| `manual` | varies | Programmatically added |
|
||||
|
||||
### Memory Extraction
|
||||
|
||||
During compaction, OpenCode Working Memory scans for `Memory candidates:` sections:
|
||||
|
||||
```
|
||||
Memory candidates:
|
||||
- [decision] Use npm cache for plugin loading
|
||||
- [project] This repo uses TypeScript with strict mode
|
||||
```
|
||||
|
||||
**Legacy Format**: OpenCode Working Memory also accepts `<workspace_memory_candidates>` XML blocks for backward compatibility, but this format is deprecated.
|
||||
|
||||
**Quality Gate**: Not all candidates become memories. OpenCode Working Memory rejects:
|
||||
- Git commit hashes (e.g., `abc1234`)
|
||||
- Raw errors (e.g., `Error: something failed`)
|
||||
- Stack traces
|
||||
- Path-heavy facts (>50% paths)
|
||||
- Very short text (<20 chars)
|
||||
|
||||
### Consolidation, Deduplication, and Retention
|
||||
|
||||
Memories are deduplicated and consolidated with accounting:
|
||||
|
||||
1. Normalize exact text: lowercase, strip punctuation, collapse whitespace.
|
||||
2. Group project/reference entries by identity where possible.
|
||||
3. Keep decision and feedback entries on exact canonical matching to avoid broad semantic merges.
|
||||
4. Keep the best surviving entry by source, confidence, specificity, and freshness tie-breakers.
|
||||
5. Emit accounting events so pending memories can be classified as promoted, absorbed, superseded, or rejected.
|
||||
|
||||
This prevents absorbed or superseded pending memories from retrying forever while still preserving the active surviving memory.
|
||||
|
||||
Retention then decides which active memories are rendered into prompt context. It does not hard-delete old memories by age.
|
||||
|
||||
```typescript
|
||||
strength = initialStrength * 2 ** (-effectiveAgeDays / effectiveHalfLifeDays)
|
||||
```
|
||||
|
||||
Initial strength is based on memory type, source, and optional user importance. Confidence remains stored for compatibility but is not part of retention scoring.
|
||||
|
||||
Rendered candidates are selected in this order:
|
||||
|
||||
1. Exclude `status: "superseded"` entries.
|
||||
2. Compute current retention strength.
|
||||
3. Sort by strength descending.
|
||||
4. Apply per-type caps.
|
||||
5. Keep the top 28 rendered entries under the workspace memory character budget.
|
||||
|
||||
Default type caps:
|
||||
|
||||
| Type | Rendered cap |
|
||||
|------|--------------|
|
||||
| `feedback` | 10 |
|
||||
| `decision` | 10 |
|
||||
| `project` | 8 |
|
||||
| `reference` | 6 |
|
||||
|
||||
The type-cap total is 34, intentionally above the global 28-entry cap. These are maximums, not quotas.
|
||||
|
||||
Dormant workspaces age more slowly: after 14 inactive days, additional dormant time counts at 0.25x for retention decay. Repeated duplicate memories reinforce the surviving entry and slow future decay, but same-session and under-one-hour repeats do not stack reinforcement.
|
||||
|
||||
### Safety-Critical Deprecation
|
||||
|
||||
The `safetyCritical` field on `LongTermMemoryEntry` is deprecated as of the retention v1.5.1 model update. It no longer affects retention strength or type-cap bypass. The field is preserved in the type definition for backward compatibility with existing workspace memory JSON files, but has no active behavior. Safety rules should be maintained in user-controlled files such as `agent.md` rather than in system memory.
|
||||
|
||||
### System Prompt Injection
|
||||
Blocks are injected into every agent message as:
|
||||
|
||||
Workspace memory is injected at the top of every message:
|
||||
|
||||
```
|
||||
<core_memory>
|
||||
<goal chars="87/1000">...</goal>
|
||||
<progress chars="560/2000">...</progress>
|
||||
<context chars="479/1500">...</context>
|
||||
</core_memory>
|
||||
Workspace memory (cross-session, verify if stale):
|
||||
decision:
|
||||
- Use npm cache for plugin loading, not npm link
|
||||
project:
|
||||
- This repo uses the opencode-agenthub plugin system
|
||||
reference:
|
||||
- Storage: ~/.local/share/opencode-working-memory/...
|
||||
```
|
||||
|
||||
## Phase 2: Smart Pruning
|
||||
## Layer 2: Hot Session State
|
||||
|
||||
### Purpose
|
||||
Reduce context bloat by filtering tool outputs before they enter the conversation history.
|
||||
|
||||
### Pruning Modes
|
||||
|
||||
#### Normal Mode (Pressure < 75%)
|
||||
- Remove file/directory listings > 50 lines
|
||||
- Truncate verbose tool outputs
|
||||
- Keep first/last 30 lines of long outputs
|
||||
- Preserve error messages and key information
|
||||
|
||||
#### Aggressive Mode (75% ≤ Pressure < 90%)
|
||||
- Threshold drops to 30 lines
|
||||
- More aggressive truncation (first/last 20 lines)
|
||||
- Filter repetitive content
|
||||
|
||||
#### Hyper-Aggressive Mode (Pressure ≥ 90%)
|
||||
- Threshold drops to 15 lines
|
||||
- Keep only first/last 10 lines
|
||||
- Maximum compression
|
||||
|
||||
### Pruning Heuristics
|
||||
|
||||
1. **File Listings**: Detect `ls`, `find`, `glob` outputs
|
||||
2. **Directory Trees**: Detect tree-like structures with `/`
|
||||
3. **Log Files**: Detect timestamp patterns, stack traces
|
||||
4. **Repetitive Content**: Detect similar consecutive lines
|
||||
5. **Synthetic Content**: Preserve `synthetic: true` markers
|
||||
|
||||
### Implementation
|
||||
Pruning happens in `tool.execute.after` hook before tool output enters conversation.
|
||||
|
||||
## Phase 3: Working Memory
|
||||
|
||||
### Purpose
|
||||
Provide session-scoped memory with structured slots and a general-purpose pool with intelligent decay.
|
||||
Track current session context automatically:
|
||||
- What files are you working on?
|
||||
- What errors are currently open?
|
||||
- What decisions were made recently?
|
||||
|
||||
### Storage
|
||||
- **Location**: `.opencode/memory-working/<sessionID>.json`
|
||||
|
||||
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/sessions/{hashedSessionID}.json`
|
||||
- **Schema**:
|
||||
```typescript
|
||||
{
|
||||
sessionID: string;
|
||||
slots: {
|
||||
error: Array<WorkingMemoryItem>; // Max 3
|
||||
decision: Array<WorkingMemoryItem>; // Max 5
|
||||
todo: Array<WorkingMemoryItem>; // Max 3
|
||||
dependency: Array<WorkingMemoryItem>; // Max 3
|
||||
};
|
||||
pool: Array<WorkingMemoryItem>;
|
||||
eventCounter: number;
|
||||
updatedAt: string;
|
||||
version: 1,
|
||||
sessionID: string,
|
||||
turn: number,
|
||||
updatedAt: string,
|
||||
activeFiles: ActiveFile[],
|
||||
openErrors: OpenError[],
|
||||
recentDecisions: SessionDecision[]
|
||||
}
|
||||
```
|
||||
|
||||
### Slot Types
|
||||
### Active Files
|
||||
|
||||
| Slot | Max Items | Purpose |
|
||||
|------|-----------|---------|
|
||||
| **error** | 3 | Recent errors that need fixing |
|
||||
| **decision** | 5 | Important decisions made |
|
||||
| **todo** | 3 | Current task checklist |
|
||||
| **dependency** | 3 | File/package dependencies |
|
||||
Automatically tracked from `tool.execute.after` events:
|
||||
|
||||
### Memory Pool
|
||||
| Action | Weight |
|
||||
|--------|--------|
|
||||
| `edit` | 50 |
|
||||
| `write` | 45 |
|
||||
| `grep` | 30 |
|
||||
| `read` | 20 |
|
||||
|
||||
General-purpose storage with **exponential decay**:
|
||||
Files are ranked by: `ACTION_WEIGHT[action] + count * 3`
|
||||
|
||||
```typescript
|
||||
score = exp(-γ * age) + mentionCount
|
||||
```
|
||||
### Open Errors
|
||||
|
||||
Where:
|
||||
- `γ = 0.85` (decay rate, 15% per event)
|
||||
- `age = eventCounter - item.eventNumber`
|
||||
- `mentionCount`: Number of times item mentioned in conversation
|
||||
Tracked from `tool.execute.after` events when `exitCode !== 0`:
|
||||
|
||||
Items with `score < 0.01` are pruned.
|
||||
| Category | Trigger Pattern |
|
||||
|----------|-----------------|
|
||||
| `typecheck` | `TS####:` or TypeScript errors |
|
||||
| `test` | Test failures |
|
||||
| `lint` | ESLint warnings/errors |
|
||||
| `build` | Build failures |
|
||||
| `runtime` | `Error:`, `TypeError:`, etc. |
|
||||
|
||||
### Auto-Extraction
|
||||
**False Positive Guards**:
|
||||
- Commands like `git log`, `cat` with "error" in output are ignored
|
||||
- Only actual command failures (`exitCode !== 0`) trigger errors
|
||||
- `exitCode === undefined` is ignored (no error created, no error cleared)
|
||||
|
||||
Working memory items are **automatically extracted** from:
|
||||
- Tool outputs (file paths, errors, dependencies)
|
||||
- User messages (decisions, todos)
|
||||
- Assistant responses (key information)
|
||||
### Error Fingerprinting
|
||||
|
||||
### Manual Management
|
||||
Errors are fingerprinted by:
|
||||
1. Extract error message summary
|
||||
2. Generate fingerprint: `first 12 chars of sha256(summary)`
|
||||
3. Group similar errors by fingerprint
|
||||
|
||||
Tools:
|
||||
- `working_memory_add`: Manually add item
|
||||
- `working_memory_clear`: Clear all items
|
||||
- `working_memory_clear_slot`: Clear specific slot (e.g., after fixing all errors)
|
||||
- `working_memory_remove`: Remove specific item by content match
|
||||
### recentDecisions
|
||||
|
||||
Short-term decisions made this session. Candidates for promotion to workspace memory during compaction.
|
||||
|
||||
### System Prompt Injection
|
||||
|
||||
Hot session state is injected after workspace memory:
|
||||
|
||||
```
|
||||
<working_memory>
|
||||
Recent session context (auto-managed, sorted by relevance):
|
||||
---
|
||||
|
||||
⚠️ Errors:
|
||||
- TypeError at line 42 in utils.ts
|
||||
- Missing import in index.ts
|
||||
Hot session state (current session):
|
||||
|
||||
📁 Key Files:
|
||||
- src/components/Button.tsx
|
||||
- src/utils/helpers.ts
|
||||
active_files:
|
||||
- src/plugin.ts (edit, 18x)
|
||||
- tests/plugin.test.ts (edit, 5x)
|
||||
|
||||
(15 items shown, updated: 9:46:47 AM)
|
||||
</working_memory>
|
||||
open_errors: (none)
|
||||
|
||||
recent_decisions:
|
||||
- Use frozen workspace memory snapshots for cache stability
|
||||
|
||||
pending_memories:
|
||||
- [decision] Parser supports 3 candidate formats
|
||||
```
|
||||
|
||||
## Phase 4: Pressure Monitoring
|
||||
## Layer 3: Native OpenCode State
|
||||
|
||||
### Purpose
|
||||
Track conversation context usage and trigger interventions when approaching limits.
|
||||
|
||||
### Pressure Calculation
|
||||
Delegate task tracking to OpenCode's native features.
|
||||
|
||||
### Behavior
|
||||
|
||||
- Uses OpenCode's built-in `todos` during compaction
|
||||
- No additional storage or injection required
|
||||
- Allows the agent to manage task lists natively
|
||||
|
||||
## Plugin Hooks
|
||||
|
||||
OpenCode Working Memory hooks into OpenCode lifecycle events:
|
||||
|
||||
### `experimental.chat.system.transform`
|
||||
|
||||
Injects workspace memory and hot session state into system prompt.
|
||||
|
||||
### `tool.execute.after`
|
||||
|
||||
- Tracks active files (read, grep, edit, write actions)
|
||||
- Tracks open errors from failed commands
|
||||
- Clears errors when commands succeed
|
||||
- Ignores `exitCode === undefined` (successful commands without explicit exit codes)
|
||||
|
||||
### `experimental.session.compacting`
|
||||
|
||||
Extracts workspace memory candidates from conversation.
|
||||
Applies quality gate, redaction, migration, consolidation accounting, deduplication, and source priority.
|
||||
|
||||
### `event` (session.compacted, session.deleted)
|
||||
|
||||
- `session.compacted`: Promote session decisions to workspace memory
|
||||
- `session.deleted`: Clean up session state files
|
||||
|
||||
Promotion uses accounting results from workspace memory normalization. Pending memories that are kept are promoted; duplicate memories are absorbed; exact decision replacements can be superseded; over-capacity compaction memories are rejected. Stale-marked memories are not hard-pruned by age; they lose rendered space through retention strength and cap competition.
|
||||
|
||||
## Quality Guarantees
|
||||
|
||||
### No False Positive Errors
|
||||
|
||||
```typescript
|
||||
pressure = (visiblePromptChars / estimatedContextLimit) * 100
|
||||
// Bad: Would create false positive
|
||||
"Error: something failed" in output
|
||||
|
||||
// Good: Actually failed
|
||||
exitCode === 1 && output.includes("Error")
|
||||
|
||||
// Good: Actually succeeded
|
||||
exitCode === 0 (clears errors for that category)
|
||||
|
||||
// Good: Ignore ambiguous cases
|
||||
exitCode === undefined → skip error tracking
|
||||
```
|
||||
|
||||
Where:
|
||||
- `visiblePromptChars`: Total characters in system prompt + tool outputs
|
||||
- `estimatedContextLimit`: ~180,000 chars (conservative estimate)
|
||||
|
||||
### Pressure Levels
|
||||
|
||||
| Level | Threshold | Behavior |
|
||||
|-------|-----------|----------|
|
||||
| **safe** | < 75% | Normal operation |
|
||||
| **moderate** | 75-89% | Warning in system prompt + aggressive pruning |
|
||||
| **high** | ≥ 90% | Hyper-aggressive pruning + intervention |
|
||||
|
||||
### Pressure Storage
|
||||
|
||||
- **Location**: `.opencode/memory-working/<sessionID>_pressure.json`
|
||||
- **Schema**:
|
||||
```typescript
|
||||
{
|
||||
sessionID: string;
|
||||
level: "safe" | "moderate" | "high";
|
||||
percentage: number;
|
||||
visiblePromptChars: number;
|
||||
estimatedLimit: 180000;
|
||||
lastChecked: string;
|
||||
interventionsSent: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Intervention Mechanism
|
||||
|
||||
When pressure reaches **high** (≥90%):
|
||||
1. Plugin sends `promptAsync()` message to agent
|
||||
2. Message includes full visible prompt for review
|
||||
3. Agent can compress core memory, clear working memory, or continue
|
||||
4. Intervention tracked in `interventionsSent` counter
|
||||
|
||||
### System Prompt Injection
|
||||
|
||||
```
|
||||
[Memory Pressure: 87% (high) - 156,600/180,000 chars]
|
||||
⚠️ High memory pressure detected. Consider:
|
||||
- Compressing core_memory blocks (use core_memory_update)
|
||||
- Clearing resolved errors (use working_memory_clear_slot)
|
||||
- Removing old pool items (auto-pruned at score < 0.01)
|
||||
```
|
||||
|
||||
## Phase 4.5: Storage Governance
|
||||
|
||||
### Purpose
|
||||
Prevent `.opencode/` directory bloat from accumulating tool output caches and orphaned memory files.
|
||||
|
||||
### Layer 1: Session Deletion Cleanup
|
||||
|
||||
**Trigger**: `experimental.session.deleted` hook
|
||||
|
||||
**Actions**:
|
||||
1. Remove `.opencode/memory-core/<sessionID>.json`
|
||||
2. Remove `.opencode/memory-working/<sessionID>.json`
|
||||
3. Remove `.opencode/memory-working/<sessionID>_pressure.json`
|
||||
4. Remove `.opencode/memory-working/<sessionID>_compaction.json`
|
||||
|
||||
### Layer 2: Tool Output Cache Sweep
|
||||
|
||||
**Trigger**: Every 500 events (`eventCounter % 500 === 0`)
|
||||
|
||||
**Target**: `.opencode/cache/tool-outputs/` directory
|
||||
|
||||
**Policy**:
|
||||
- Keep most recent **300 files** (sorted by mtime)
|
||||
- Delete files older than **7 days** (TTL policy)
|
||||
|
||||
**Logging**: Write sweep results to `.opencode/memory-working/<sessionID>_sweep.json`
|
||||
### Negative Memory Filtering
|
||||
|
||||
```typescript
|
||||
{
|
||||
sessionID: string;
|
||||
timestamp: string;
|
||||
eventCounter: number;
|
||||
results: {
|
||||
filesScanned: number;
|
||||
filesDeleted: number;
|
||||
bytesReclaimed: number;
|
||||
errors: Array<string>;
|
||||
};
|
||||
}
|
||||
// Correctly interpreted
|
||||
"don't remember this" → NOT added to memory
|
||||
"不要記住這個" → NOT added to memory
|
||||
"remember this" → added to memory candidates
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
### Canonical Deduplication
|
||||
|
||||
### Memory Budgets
|
||||
- **Core Memory**: 4,500 chars (injected every message)
|
||||
- **Working Memory**: ~1,600 chars (injected every message)
|
||||
- **Total Overhead**: ~6,100 chars per message
|
||||
|
||||
### Compaction Behavior
|
||||
When OpenCode compacts conversation (clears old messages):
|
||||
- Core memory: **Preserved** (persistent across compactions)
|
||||
- Working memory: **Preserved** (session-scoped, cleared on session end)
|
||||
- Pressure state: **Preserved** (tracks across compaction)
|
||||
- Compaction log: Saved to `<sessionID>_compaction.json`
|
||||
|
||||
### 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,
|
||||
};
|
||||
// Same memory (after normalization)
|
||||
"Use npm cache for plugins"
|
||||
"USE NPM CACHE for plugins!!"
|
||||
"use npm cache for plugins."
|
||||
|
||||
// All map to same canonical key
|
||||
canonical("Use npm cache for plugins") === "use npm cache for plugins"
|
||||
```
|
||||
|
||||
## Migration & Compatibility
|
||||
### Compaction Quality Gate
|
||||
|
||||
### Old Format → New Format
|
||||
Plugin automatically migrates from old format:
|
||||
```typescript
|
||||
// Old format (pre-Phase 3)
|
||||
{ items: Array<Item> }
|
||||
// Rejected (not valuable as long-term memory)
|
||||
"4832b38 fix: something" // git hash
|
||||
"Error: something failed" // raw error
|
||||
"at Object.method (file.ts:42)" // stack trace
|
||||
"/Users/x/project/file.ts /Users/x/project/other.ts" // path-heavy
|
||||
|
||||
// New format (Phase 3+)
|
||||
{ slots: Record<SlotType, Array<Item>>, pool: Array<Item> }
|
||||
// Accepted
|
||||
"[decision] Use npm cache for plugin loading" // good pattern
|
||||
```
|
||||
|
||||
Migration happens on first load of old format files.
|
||||
|
||||
## File System Layout
|
||||
|
||||
```
|
||||
.opencode/
|
||||
├── memory-core/
|
||||
│ └── <sessionID>.json # Core memory blocks
|
||||
├── memory-working/
|
||||
│ ├── <sessionID>.json # Working memory (slots + pool)
|
||||
│ ├── <sessionID>_pressure.json # Pressure monitoring state
|
||||
│ ├── <sessionID>_compaction.json # Compaction event log
|
||||
│ └── <sessionID>_sweep.json # Storage sweep log
|
||||
└── cache/
|
||||
└── tool-outputs/
|
||||
└── *.json # Tool output cache (auto-swept)
|
||||
~/.local/share/opencode-working-memory/
|
||||
└── workspaces/
|
||||
└── {workspaceKey}/
|
||||
├── workspace-memory.json # Long-term memory
|
||||
└── sessions/
|
||||
└── {hashedSessionID}.json # Session state
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
### Workspace Key
|
||||
|
||||
- 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
|
||||
```typescript
|
||||
// First 16 chars of SHA-256 hash of workspace root realpath
|
||||
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
|
||||
```
|
||||
|
||||
### Storage Safety
|
||||
|
||||
All read-modify-write updates go through `updateJSON()`, which combines an in-process promise queue with an on-disk `.lock` file. The lock file uses exclusive creation, heartbeat refreshes while held, stale-lock recovery after 30 seconds, and a 5 second wait timeout for live contention. Direct `readJSON()` is fallback-oriented and does not mutate data except when corrupt JSON is quarantined.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Memory Budgets
|
||||
|
||||
| Layer | Max Chars | Max Entries |
|
||||
|-------|-----------|-------------|
|
||||
| Workspace Memory | 3600 | 28 |
|
||||
| Hot Session State | 700 | 8 files, 3 errors |
|
||||
|
||||
### Injection Overhead
|
||||
|
||||
- Workspace memory: usually under ~2000 chars in observed rendered output
|
||||
- Hot session state: usually under ~500 chars in observed rendered output
|
||||
- Total: typically well below the configured maximums
|
||||
|
||||
### Storage Footprint
|
||||
|
||||
- Workspace memory: ~2-5 KB per workspace
|
||||
- Session state: ~1-3 KB per session
|
||||
- Auto-cleanup on workspace/session deletion
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Custom Memory Types
|
||||
|
||||
Add new types in `src/types.ts`:
|
||||
```typescript
|
||||
export type LongTermType = "feedback" | "project" | "decision" | "reference" | "custom";
|
||||
```
|
||||
|
||||
### Custom Error Categories
|
||||
|
||||
Add new categories in `src/types.ts`:
|
||||
```typescript
|
||||
export type ErrorCategory = "typecheck" | "test" | "lint" | "build" | "runtime" | "custom";
|
||||
```
|
||||
|
||||
### Custom Extraction Patterns
|
||||
|
||||
Modify `src/extractors.ts` to add new extraction patterns.
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Memory V1 to V2
|
||||
|
||||
OpenCode Working Memory automatically migrates old format files to the new three-layer architecture. No manual intervention needed.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: February 2026
|
||||
**Implementation**: `index.ts` (1700+ lines)
|
||||
**Last Updated**: April 2026
|
||||
**Implementation**: `src/plugin.ts`, `src/extractors.ts`, `src/workspace-memory.ts`, `src/session-state.ts`
|
||||
|
||||
+174
-300
@@ -2,250 +2,154 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Working Memory Plugin works out-of-the-box with sensible defaults. Advanced users can customize behavior by modifying constants in `index.ts`.
|
||||
OpenCode Working Memory works out-of-the-box with sensible defaults. Configuration is defined in `src/types.ts` as constants.
|
||||
|
||||
## Core Memory Limits
|
||||
## Workspace Memory Limits
|
||||
|
||||
```typescript
|
||||
const CORE_MEMORY_LIMITS = {
|
||||
goal: 1000, // ONE specific task (not project-wide goals)
|
||||
progress: 2000, // Checklist format (✅ done, ⏳ in-progress, ❌ blocked)
|
||||
context: 1500, // Current working files + key patterns
|
||||
const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 3600, // Maximum characters in system prompt
|
||||
targetRenderedChars: 3000, // Target characters (leave buffer)
|
||||
maxEntries: 28, // Maximum number of entries
|
||||
maxEntryTextChars: 260, // Maximum characters per entry text
|
||||
maxRationaleChars: 180, // Maximum characters per entry rationale
|
||||
};
|
||||
```
|
||||
|
||||
**Recommendations**:
|
||||
- Keep **goal** focused on current task (clear when completed)
|
||||
- Use **progress** for checklists (avoid line numbers, commit hashes, API signatures)
|
||||
- Use **context** for files you're actively editing (avoid type definitions, function signatures)
|
||||
- Keep `maxRenderedChars` under 5500 to avoid context bloat
|
||||
- Defaults are calibrated from observed rendered usage that was typically under ~2000 characters
|
||||
- `maxEntries` of 28 provides good coverage without overwhelming
|
||||
- Entry text limits ensure entries stay concise
|
||||
|
||||
## Working Memory Configuration
|
||||
## Retention Model Defaults
|
||||
|
||||
### Slot Limits
|
||||
Workspace memory retention uses strength-based decay. These constants live in `src/workspace-memory.ts`:
|
||||
|
||||
```typescript
|
||||
const SLOT_CONFIG: Record<SlotType, number> = {
|
||||
error: 3, // Recent errors needing fixes
|
||||
decision: 5, // Important decisions made
|
||||
todo: 3, // Current task checklist
|
||||
dependency: 3, // File/package dependencies
|
||||
const BASE_HALF_LIFE_DAYS = 45;
|
||||
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
||||
const REINFORCEMENT_MAX_COUNT = 6;
|
||||
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
||||
const DORMANT_DECAY_MULTIPLIER = 0.25;
|
||||
```
|
||||
|
||||
Initial strength uses type, source, and user importance factors. Confidence is stored for compatibility but is not used for retention scoring.
|
||||
|
||||
Rendered type caps prevent one type from filling all workspace memory slots:
|
||||
|
||||
| Type | Rendered cap |
|
||||
|------|--------------|
|
||||
| `feedback` | 10 |
|
||||
| `decision` | 10 |
|
||||
| `project` | 8 |
|
||||
| `reference` | 6 |
|
||||
|
||||
Old or stale-marked memories are not hard-pruned by age; they lose rendered space through strength and cap competition. The deprecated `safetyCritical` field is preserved for compatibility but no longer affects strength or type caps.
|
||||
|
||||
## Hot Session State Limits
|
||||
|
||||
```typescript
|
||||
const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 700, // Maximum characters in system prompt
|
||||
maxActiveFilesStored: 20, // Maximum files tracked in state
|
||||
maxActiveFilesRendered: 8, // Maximum files shown in prompt
|
||||
maxOpenErrorsStored: 5, // Maximum errors tracked
|
||||
maxOpenErrorsRendered: 3, // Maximum errors shown in prompt
|
||||
maxRecentDecisionsStored: 8, // Maximum decisions tracked
|
||||
};
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase slot limits if you need more items tracked
|
||||
- Decrease for stricter memory budgets
|
||||
- Total overhead: ~100-200 chars per item
|
||||
**Recommendations**:
|
||||
- Keep `maxRenderedChars` under 1500 for fast prompts
|
||||
- Defaults are calibrated from observed rendered usage around ~500 characters or less
|
||||
- `maxActiveFilesRendered` of 8 provides good context coverage
|
||||
- `maxOpenErrorsRendered` of 3 avoids overwhelming error lists
|
||||
|
||||
### Memory Pool Decay
|
||||
## Memory Types
|
||||
|
||||
```typescript
|
||||
const POOL_DECAY_GAMMA = 0.85; // Exponential decay rate (15% per event)
|
||||
const POOL_MIN_SCORE = 0.01; // Items below this score are pruned
|
||||
### Long-Term Memory Types
|
||||
|
||||
| Type | Purpose | Rendered cap |
|
||||
|------|---------|--------------|
|
||||
| `feedback` | User preferences for workspace | 10 |
|
||||
| `project` | Project-level information | 8 |
|
||||
| `decision` | Important decisions | 10 |
|
||||
| `reference` | Key references | 6 |
|
||||
|
||||
### Memory Sources
|
||||
|
||||
| Source | Confidence | Description |
|
||||
|--------|------------|-------------|
|
||||
| `explicit` | 1.0 | User explicitly said "remember this" |
|
||||
| `compaction` | 0.75 | Extracted during conversation compaction |
|
||||
| `manual` | varies | Added programmatically |
|
||||
|
||||
## Active File Scoring
|
||||
|
||||
Files are ranked by action type:
|
||||
|
||||
| Action | Weight | Description |
|
||||
|--------|--------|-------------|
|
||||
| `write` | 4 | File created/overwritten |
|
||||
| `edit` | 3 | File modified |
|
||||
| `read` | 2 | File read |
|
||||
| `grep` | 1 | Grep searched in file |
|
||||
|
||||
Score formula: `count * action_weight * recency_decay`
|
||||
|
||||
## Error Categories
|
||||
|
||||
| Category | Recognition Pattern |
|
||||
|----------|---------------------|
|
||||
| `typecheck` | TS errors, TypeScript failures |
|
||||
| `test` | Test failures |
|
||||
| `lint` | ESLint warnings/errors |
|
||||
| `build` | Build failures |
|
||||
| `runtime` | Uncaught errors, Node exceptions |
|
||||
| `tool` | Tool execution failures |
|
||||
|
||||
## Storage Paths
|
||||
|
||||
```
|
||||
~/.local/share/opencode-working-memory/
|
||||
└── workspaces/
|
||||
└── {workspaceKey}/
|
||||
├── workspace-memory.json # Long-term memory
|
||||
└── sessions/
|
||||
└── {hashedSessionID}.json # Session state (hashed)
|
||||
```
|
||||
|
||||
**Formula**: `score = exp(-γ * age) + mentionCount`
|
||||
|
||||
**Tuning**:
|
||||
- Lower `γ` (e.g., 0.75) → faster decay, more aggressive pruning
|
||||
- Higher `γ` (e.g., 0.90) → slower decay, items stay longer
|
||||
- Lower `POOL_MIN_SCORE` (e.g., 0.005) → more items retained
|
||||
|
||||
### Pool Size Limits
|
||||
### Workspace Key
|
||||
|
||||
```typescript
|
||||
const POOL_MAX_ITEMS = 50; // Hard limit on pool size
|
||||
// First 16 characters of SHA-256 hash
|
||||
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16);
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase for longer sessions with more context
|
||||
- Decrease for stricter memory budgets
|
||||
- Each item adds ~50-150 chars to system prompt
|
||||
|
||||
## Pressure Monitoring
|
||||
|
||||
### Thresholds
|
||||
### Session ID
|
||||
|
||||
```typescript
|
||||
const PRESSURE_THRESHOLDS = {
|
||||
moderate: 75, // Warning appears in system prompt
|
||||
high: 90, // Aggressive pruning activates + intervention sent
|
||||
// Hashed session ID for privacy
|
||||
const hashedSessionID = sha256(sessionID).slice(0, 32);
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
To customize limits, edit the constants in `src/types.ts`:
|
||||
|
||||
```typescript
|
||||
// Example: Increase workspace memory limit
|
||||
export const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 6000, // Increased from 3600
|
||||
maxEntries: 35, // Increased from 28
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase thresholds for more relaxed monitoring
|
||||
- Decrease for earlier warnings and interventions
|
||||
|
||||
### Context Limit Estimate
|
||||
|
||||
```typescript
|
||||
const ESTIMATED_CONTEXT_LIMIT = 180000; // Conservative estimate (chars)
|
||||
```
|
||||
|
||||
**Note**: OpenCode actual limit varies by model. Adjust based on your observations.
|
||||
|
||||
## Smart Pruning
|
||||
|
||||
### Line Thresholds
|
||||
|
||||
```typescript
|
||||
// Normal mode (pressure < 75%)
|
||||
const PRUNE_THRESHOLD_NORMAL = 50;
|
||||
|
||||
// Aggressive mode (75% ≤ pressure < 90%)
|
||||
const PRUNE_THRESHOLD_AGGRESSIVE = 30;
|
||||
|
||||
// Hyper-aggressive mode (pressure ≥ 90%)
|
||||
const PRUNE_THRESHOLD_HYPER = 15;
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase thresholds to keep more tool output
|
||||
- Decrease for more aggressive pruning
|
||||
|
||||
### Keep Lines
|
||||
|
||||
```typescript
|
||||
// Normal mode
|
||||
const KEEP_LINES_NORMAL = 30; // Keep first/last 30 lines
|
||||
|
||||
// Aggressive mode
|
||||
const KEEP_LINES_AGGRESSIVE = 20; // Keep first/last 20 lines
|
||||
|
||||
// Hyper-aggressive mode
|
||||
const KEEP_LINES_HYPER = 10; // Keep first/last 10 lines
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase to preserve more context from tool outputs
|
||||
- Decrease for stricter truncation
|
||||
|
||||
## Storage Governance
|
||||
|
||||
### Session Cleanup
|
||||
|
||||
Automatically triggered on `experimental.session.deleted` hook. No configuration needed.
|
||||
|
||||
### Tool Output Cache Sweep
|
||||
|
||||
```typescript
|
||||
const SWEEP_INTERVAL = 500; // Trigger every N events
|
||||
const SWEEP_MAX_FILES = 300; // Keep most recent N files
|
||||
const SWEEP_TTL_DAYS = 7; // Delete files older than N days
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase `SWEEP_INTERVAL` for less frequent sweeps (lower overhead)
|
||||
- Increase `SWEEP_MAX_FILES` to cache more tool outputs (more disk usage)
|
||||
- Increase `SWEEP_TTL_DAYS` to keep older files longer
|
||||
|
||||
## Compaction Behavior
|
||||
|
||||
### Item Preservation
|
||||
|
||||
```typescript
|
||||
const COMPACTION_KEEP_ITEMS = 10; // Preserve N most recent items on compaction
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase to preserve more working memory across compactions
|
||||
- Decrease for stricter memory reset on compaction
|
||||
|
||||
## System Prompt Injection
|
||||
|
||||
### Core Memory Format
|
||||
|
||||
```typescript
|
||||
// Injected as:
|
||||
<core_memory>
|
||||
<goal chars="87/1000">...</goal>
|
||||
<progress chars="560/2000">...</progress>
|
||||
<context chars="479/1500">...</context>
|
||||
</core_memory>
|
||||
```
|
||||
|
||||
**Customization**: Modify `formatCoreMemoryForPrompt()` in `index.ts` to change format.
|
||||
|
||||
### Working Memory Format
|
||||
|
||||
```typescript
|
||||
// Injected as:
|
||||
<working_memory>
|
||||
Recent session context (auto-managed, sorted by relevance):
|
||||
|
||||
⚠️ Errors:
|
||||
- item content
|
||||
|
||||
📁 Key Files:
|
||||
- file path
|
||||
|
||||
(N items shown, updated: HH:MM:SS AM)
|
||||
</working_memory>
|
||||
```
|
||||
|
||||
**Customization**: Modify `formatWorkingMemoryForPrompt()` in `index.ts` to change:
|
||||
- Section emoji/icons
|
||||
- Display format
|
||||
- Item ordering
|
||||
|
||||
### Pressure Warning Format
|
||||
|
||||
```typescript
|
||||
// Injected as:
|
||||
[Memory Pressure: 87% (high) - 156,600/180,000 chars]
|
||||
⚠️ High memory pressure detected. Consider:
|
||||
- Action item 1
|
||||
- Action item 2
|
||||
```
|
||||
|
||||
**Customization**: Modify `formatPressureWarning()` in `index.ts`.
|
||||
|
||||
## Auto-Extraction Heuristics
|
||||
|
||||
### File Path Detection
|
||||
|
||||
```typescript
|
||||
// Detects:
|
||||
- Absolute paths: /users/name/project/file.ts
|
||||
- Relative paths: src/components/Button.tsx
|
||||
- Dot paths: ./utils/helpers.ts
|
||||
- Tilde paths: ~/project/file.ts
|
||||
```
|
||||
|
||||
**Customization**: Modify regex in `extractFilePaths()`.
|
||||
|
||||
### Error Detection
|
||||
|
||||
```typescript
|
||||
// Detects:
|
||||
- "Error:", "ERROR:", "error:"
|
||||
- Stack traces with "at " prefix
|
||||
- TypeScript errors with "TS####:"
|
||||
```
|
||||
|
||||
**Customization**: Modify `extractErrors()` heuristics.
|
||||
|
||||
### Decision Detection
|
||||
|
||||
```typescript
|
||||
// Detects:
|
||||
- "decided to...", "decision:", "chose to..."
|
||||
- "using X instead of Y"
|
||||
- "will use X approach"
|
||||
```
|
||||
|
||||
**Customization**: Modify `extractDecisions()` heuristics.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Currently, the plugin does not support environment variables. All configuration is done via constants in `index.ts`.
|
||||
|
||||
**Future Enhancement**: Consider adding `.env` support for:
|
||||
```
|
||||
OPENCODE_WM_CORE_GOAL_LIMIT=1000
|
||||
OPENCODE_WM_POOL_DECAY_GAMMA=0.85
|
||||
OPENCODE_WM_SWEEP_INTERVAL=500
|
||||
**Note**: After customization, rebuild the plugin:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
@@ -253,123 +157,93 @@ OPENCODE_WM_SWEEP_INTERVAL=500
|
||||
### High-Frequency Sessions (500+ messages)
|
||||
|
||||
```typescript
|
||||
// Aggressive pruning
|
||||
const PRUNE_THRESHOLD_NORMAL = 30;
|
||||
const PRUNE_THRESHOLD_AGGRESSIVE = 20;
|
||||
|
||||
// Faster decay
|
||||
const POOL_DECAY_GAMMA = 0.75;
|
||||
|
||||
// More frequent sweeps
|
||||
const SWEEP_INTERVAL = 250;
|
||||
// Reduce memory overhead
|
||||
const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 800, // Reduced
|
||||
maxActiveFilesRendered: 5, // Reduced
|
||||
maxOpenErrorsRendered: 2, // Reduced
|
||||
};
|
||||
```
|
||||
|
||||
### Long-Running Sessions (Multi-day)
|
||||
|
||||
```typescript
|
||||
// Preserve more context
|
||||
const POOL_MAX_ITEMS = 100;
|
||||
const COMPACTION_KEEP_ITEMS = 20;
|
||||
|
||||
// Slower decay
|
||||
const POOL_DECAY_GAMMA = 0.90;
|
||||
|
||||
// Longer TTL
|
||||
const SWEEP_TTL_DAYS = 14;
|
||||
const LONG_TERM_LIMITS = {
|
||||
maxEntries: 40, // Increased
|
||||
targetRenderedChars: 5000, // Increased
|
||||
};
|
||||
```
|
||||
|
||||
### Memory-Constrained Environments
|
||||
|
||||
```typescript
|
||||
// Strict limits
|
||||
const CORE_MEMORY_LIMITS = {
|
||||
goal: 500,
|
||||
progress: 1000,
|
||||
context: 800,
|
||||
const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 3000,
|
||||
maxEntries: 15,
|
||||
};
|
||||
|
||||
const POOL_MAX_ITEMS = 20;
|
||||
|
||||
// Aggressive pruning
|
||||
const PRUNE_THRESHOLD_NORMAL = 20;
|
||||
const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 600,
|
||||
maxActiveFilesRendered: 4,
|
||||
};
|
||||
```
|
||||
|
||||
## Debugging Configuration
|
||||
|
||||
### Enable Verbose Logging
|
||||
|
||||
Add `console.log()` statements in key functions:
|
||||
|
||||
```typescript
|
||||
// In loadCoreMemory()
|
||||
console.log("[Core Memory] Loaded:", memory);
|
||||
|
||||
// In applyDecay()
|
||||
console.log("[Pool Decay] Pruned items:", prunedCount);
|
||||
|
||||
// In sweepToolOutputCache()
|
||||
console.log("[Sweep] Deleted files:", deletedCount);
|
||||
```
|
||||
## Debugging
|
||||
|
||||
### Inspect Memory Files
|
||||
|
||||
```bash
|
||||
# Core memory
|
||||
cat .opencode/memory-core/<sessionID>.json | jq
|
||||
# Workspace memory
|
||||
cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json | jq
|
||||
|
||||
# Working memory
|
||||
cat .opencode/memory-working/<sessionID>.json | jq
|
||||
|
||||
# Pressure state
|
||||
cat .opencode/memory-working/<sessionID>_pressure.json | jq
|
||||
|
||||
# Sweep log
|
||||
cat .opencode/memory-working/<sessionID>_sweep.json | jq
|
||||
# Session state
|
||||
cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
### Inspect Retention Health
|
||||
|
||||
### Upgrading from Pre-Phase 3
|
||||
From a source checkout, maintainers can inspect stored vs rendered memory behavior:
|
||||
|
||||
Old format files are automatically migrated:
|
||||
|
||||
```typescript
|
||||
// Old format
|
||||
{ items: Array<Item> }
|
||||
|
||||
// New format (auto-migrated)
|
||||
{ slots: { error: [], decision: [], ... }, pool: [...] }
|
||||
```bash
|
||||
bun scripts/memory-diag.ts health
|
||||
```
|
||||
|
||||
No manual intervention required.
|
||||
The health output includes stored active memories, rendered candidates, type caps, global cap overflow, dormancy status, retention monitoring alerts, and strength-ranked top/weakest entries.
|
||||
|
||||
### Upgrading from Phase 3 to Phase 4.5
|
||||
### Clear Workspace Memory
|
||||
|
||||
Storage governance is backward compatible. No migration needed.
|
||||
```bash
|
||||
# Remove workspace memory (start fresh)
|
||||
rm ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json
|
||||
```
|
||||
|
||||
### Clear Session State
|
||||
|
||||
```bash
|
||||
# Remove all session states
|
||||
rm ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Core Memory Discipline**:
|
||||
- Clear `goal` immediately after task completion
|
||||
- Keep `progress` concise (use checklist format)
|
||||
- Only put actively edited files in `context`
|
||||
1. **Workspace Memory Hygiene**:
|
||||
- Let OpenCode Working Memory extract memories automatically
|
||||
- Use explicit "remember this" for important information
|
||||
- Don't manually edit memory files unless testing
|
||||
|
||||
2. **Working Memory Hygiene**:
|
||||
- Clear `error` slot after fixing all errors (`working_memory_clear_slot`)
|
||||
- Let pool decay naturally (avoid manual removal unless necessary)
|
||||
- Review working memory periodically (use `working_memory_read`)
|
||||
2. **Session State**:
|
||||
- Let OpenCode Working Memory track active files automatically
|
||||
- Errors are cleared when commands succeed
|
||||
- No manual intervention needed
|
||||
|
||||
3. **Pressure Management**:
|
||||
- Respond to "moderate" warnings proactively
|
||||
- Compress core memory at "high" pressure
|
||||
- Clear working memory at "critical" pressure
|
||||
|
||||
4. **Storage Maintenance**:
|
||||
- Let sweep run automatically (no manual intervention)
|
||||
- Delete old session files manually if needed
|
||||
- Monitor `.opencode/` directory size periodically
|
||||
3. **Memory Extraction**:
|
||||
- Use `Memory candidates:` during compaction
|
||||
- Follow the pattern: `- [type] text`
|
||||
- Quality gate rejects invalid candidates
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: February 2026
|
||||
**Configuration File**: `index.ts` (constants section)
|
||||
**Last Updated**: April 2026
|
||||
**Configuration File**: `src/types.ts`
|
||||
|
||||
+76
-12
@@ -10,7 +10,7 @@ Add to your `~/.config/opencode/opencode.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Restart OpenCode. The plugin is downloaded and installed automatically — no `npm install` needed.
|
||||
Restart OpenCode. OpenCode Working Memory activates automatically — no manual setup needed.
|
||||
|
||||
> **Note**: The correct key is `plugin` (singular), not `plugins`.
|
||||
|
||||
@@ -22,19 +22,44 @@ Restart OpenCode. The plugin is downloaded and installed automatically — no `n
|
||||
|
||||
## Verification
|
||||
|
||||
After restarting OpenCode, ask your agent:
|
||||
After restarting OpenCode, memory context appears automatically in system prompts. You'll see:
|
||||
|
||||
```
|
||||
Use core_memory_read to show me what you remember
|
||||
Workspace memory (cross-session, verify if stale):
|
||||
decision:
|
||||
- ... (if any long-term memories exist)
|
||||
|
||||
---
|
||||
Memory candidates:
|
||||
- [project] ... (candidates for long-term memory)
|
||||
|
||||
Hot session state (current session):
|
||||
active_files:
|
||||
- path/to/file.ts (action, count)
|
||||
|
||||
open_errors: (none, or listed)
|
||||
```
|
||||
|
||||
If the tool responds, the plugin is active.
|
||||
**No tools to call**. OpenCode Working Memory works automatically via hooks.
|
||||
|
||||
## How Memory Works
|
||||
|
||||
### Workspace Memory (Long-term)
|
||||
|
||||
Persists across sessions. Automatically extracted during compaction when you say "remember this" or when important decisions are made.
|
||||
|
||||
### Hot Session State (Short-term)
|
||||
|
||||
Tracks current session:
|
||||
- Active files (what you're working on)
|
||||
- Open errors (unresolved issues)
|
||||
- Recent decisions (for compaction candidate promotion)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
**Symptom**: No `core_memory_update` tool available
|
||||
**Symptom**: No memory context in system prompt
|
||||
|
||||
**Solution**:
|
||||
1. Check `~/.config/opencode/opencode.json` uses `"plugin"` (not `"plugins"`)
|
||||
@@ -43,11 +68,21 @@ If the tool responds, the plugin is active.
|
||||
|
||||
### Memory Files Not Created
|
||||
|
||||
**Symptom**: No `.opencode/memory-core/` or `.opencode/memory-working/` directories
|
||||
**Symptom**: No `~/.local/share/opencode-working-memory/` directory
|
||||
|
||||
**Solution**:
|
||||
1. Ensure OpenCode has write permissions in project directory
|
||||
2. Trigger memory operations (e.g., use `core_memory_update` tool)
|
||||
1. Ensure OpenCode has write permissions in home directory
|
||||
2. Trigger memory operations by working normally (memory files are created on-demand)
|
||||
3. Check that `opencode-working-memory` is listed in config
|
||||
|
||||
### Memory Not Persisting
|
||||
|
||||
**Symptom**: Workspace memory empty after restart
|
||||
|
||||
**Solution**:
|
||||
1. Verify you're in the same workspace (different workspace = different memory)
|
||||
2. Ensure `Memory candidates:` were captured during compaction
|
||||
3. Check `workspace-memory.json` exists
|
||||
|
||||
### Type Errors During Development
|
||||
|
||||
@@ -56,16 +91,45 @@ If the tool responds, the plugin is active.
|
||||
**Solution**:
|
||||
1. Run `npm install` to install dev dependencies
|
||||
2. Run `npm run typecheck` to check for errors
|
||||
3. See [AGENTS.md](../AGENTS.md) for code style guidelines
|
||||
3. Run `npm test` to verify functionality
|
||||
|
||||
## Uninstallation
|
||||
|
||||
Remove `"opencode-working-memory"` from the `plugin` array in `~/.config/opencode/opencode.json`.
|
||||
|
||||
Memory files in `.opencode/memory-*` will persist unless manually deleted.
|
||||
Memory files in `~/.local/share/opencode-working-memory/` persist unless manually deleted.
|
||||
|
||||
## Manual Memory Management
|
||||
|
||||
### View Workspace Memory
|
||||
|
||||
```bash
|
||||
cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json | jq
|
||||
```
|
||||
|
||||
### View Session State
|
||||
|
||||
```bash
|
||||
cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
|
||||
```
|
||||
|
||||
### Clear Workspace Memory
|
||||
|
||||
```bash
|
||||
rm ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json
|
||||
```
|
||||
|
||||
### Clear All Session States
|
||||
|
||||
```bash
|
||||
rm -rf ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read [Architecture Documentation](./architecture.md) to understand how memory tiers work
|
||||
- Read [Architecture Documentation](./architecture.md) to understand how the three layers work
|
||||
- See [Configuration Guide](./configuration.md) for customization options
|
||||
- Check [AGENTS.md](../AGENTS.md) for development guidelines
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: April 2026
|
||||
|
||||
+16
-4
@@ -1,12 +1,24 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.1.2",
|
||||
"description": "Advanced four-tier memory architecture for OpenCode with intelligent pressure monitoring and auto-storage governance",
|
||||
"version": "1.5.1",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"files": [
|
||||
"index.ts",
|
||||
"src/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts",
|
||||
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
|
||||
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"opencode",
|
||||
@@ -27,7 +39,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/sdwolf4103/opencode-working-memory#readme",
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": "^1.2.0"
|
||||
"@opencode-ai/plugin": ">=1.2.0 <2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Safely inspect or quarantine stale test/temp workspace memory stores.
|
||||
*
|
||||
* Default mode is dry-run. Quarantine moves only definite temp/test residues.
|
||||
* Unknown missing roots are reported but skipped unless --include-orphans is set.
|
||||
*/
|
||||
|
||||
import { cleanupWorkspaceResidues } from "../../src/workspace-cleanup.ts";
|
||||
|
||||
type CliOptions = {
|
||||
mode: "dry-run" | "quarantine";
|
||||
dataHome?: string;
|
||||
olderThanDays?: number;
|
||||
includeOrphans: boolean;
|
||||
};
|
||||
|
||||
function usage(): string {
|
||||
return `Usage:
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
npm run cleanup:workspaces -- --quarantine
|
||||
npm run cleanup:workspaces -- --quarantine --older-than-days 1
|
||||
|
||||
Options:
|
||||
--dry-run List candidates without moving anything (default)
|
||||
--quarantine Move definite temp/test residues to quarantine
|
||||
--data-home <path> Override XDG data home for testing/admin work
|
||||
--older-than-days <n> Only consider workspace dirs older than n days
|
||||
--include-orphans Also quarantine missing non-temp roots (off by default)
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = { mode: "dry-run", includeOrphans: false };
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--dry-run":
|
||||
options.mode = "dry-run";
|
||||
break;
|
||||
case "--quarantine":
|
||||
options.mode = "quarantine";
|
||||
break;
|
||||
case "--data-home":
|
||||
options.dataHome = argv[++i];
|
||||
if (!options.dataHome) throw new Error("--data-home requires a path");
|
||||
break;
|
||||
case "--older-than-days": {
|
||||
const value = Number(argv[++i]);
|
||||
if (!Number.isFinite(value) || value < 0) throw new Error("--older-than-days requires a non-negative number");
|
||||
options.olderThanDays = value;
|
||||
break;
|
||||
}
|
||||
case "--include-orphans":
|
||||
options.includeOrphans = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
throw new Error(`Unknown option: ${arg}\n${usage()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const result = await cleanupWorkspaceResidues({
|
||||
dataHome: options.dataHome,
|
||||
mode: options.mode,
|
||||
includeOrphans: options.includeOrphans,
|
||||
minAgeMs: options.olderThanDays === undefined ? undefined : options.olderThanDays * 24 * 60 * 60 * 1_000,
|
||||
});
|
||||
|
||||
console.log(`Mode: ${result.mode}`);
|
||||
console.log(`Scanned: ${result.results.length}`);
|
||||
console.log(`Candidates: ${result.candidates.length}`);
|
||||
|
||||
if (result.candidates.length > 0) {
|
||||
console.log("\nCandidates:");
|
||||
for (const candidate of result.candidates) {
|
||||
console.log(`- ${candidate.workspaceKey} ${candidate.classification} root=${candidate.root ?? "<missing>"}`);
|
||||
console.log(` reasons=${candidate.reasons.join(",")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.quarantined.length > 0) {
|
||||
console.log(`\nQuarantined: ${result.quarantined.length}`);
|
||||
console.log(`Quarantine dir: ${result.quarantineDir}`);
|
||||
}
|
||||
|
||||
const unknownOrphans = result.results.filter(item => item.classification === "orphan_unknown");
|
||||
if (unknownOrphans.length > 0 && !options.includeOrphans) {
|
||||
console.log(`\nUnknown missing-root workspaces skipped: ${unknownOrphans.length}`);
|
||||
console.log("Use --include-orphans only after manually confirming they are safe to quarantine.");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Local helper to trigger migration on workspace roots.
|
||||
*
|
||||
* Usage:
|
||||
* MIGRATION_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/run-migration.ts
|
||||
*
|
||||
* Or create a local file (gitignored):
|
||||
* echo "/path/to/workspace1" > scripts/dev/run-migration-roots.local.txt
|
||||
* echo "/path/to/workspace2" >> scripts/dev/run-migration-roots.local.txt
|
||||
* bun run scripts/dev/run-migration.ts
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { loadWorkspaceMemory } from "../../src/workspace-memory.ts";
|
||||
|
||||
async function getRoots(): Promise<string[]> {
|
||||
// Priority 1: environment variable
|
||||
const envRoots = process.env.MIGRATION_RUN_ROOTS;
|
||||
if (envRoots) {
|
||||
return envRoots.split(":").filter(root => root.length > 0);
|
||||
}
|
||||
|
||||
// Priority 2: local file
|
||||
const localFile = join(import.meta.dirname, "run-migration-roots.local.txt");
|
||||
if (existsSync(localFile)) {
|
||||
const content = await readFile(localFile, "utf8");
|
||||
return content.trim().split("\n").filter(root => root.length > 0);
|
||||
}
|
||||
|
||||
// No roots configured
|
||||
console.log("No workspace roots configured.");
|
||||
console.log("Set MIGRATION_RUN_ROOTS=/path/a:/path/b or create run-migration-roots.local.txt");
|
||||
return [];
|
||||
}
|
||||
|
||||
const roots = await getRoots();
|
||||
|
||||
if (roots.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
console.log(`Loading workspace memory: ${root}`);
|
||||
const store = await loadWorkspaceMemory(root);
|
||||
const active = store.entries.filter(entry => entry.status !== "superseded").length;
|
||||
const superseded = store.entries.filter(entry => entry.status === "superseded").length;
|
||||
console.log(` active=${active} superseded=${superseded} migrations=${(store.migrations ?? []).join(",")}`);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,526 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
|
||||
export type EvidenceEventType =
|
||||
| "extraction_candidate_accepted"
|
||||
| "extraction_candidate_rejected"
|
||||
| "explicit_memory_detected"
|
||||
| "explicit_memory_ignored"
|
||||
| "pending_memory_appended"
|
||||
| "pending_memory_cleared"
|
||||
| "promotion_promoted"
|
||||
| "promotion_absorbed_exact"
|
||||
| "promotion_absorbed_identity"
|
||||
| "promotion_superseded"
|
||||
| "promotion_rejected_capacity"
|
||||
| "promotion_retry_scheduled"
|
||||
| "promotion_retry_exhausted"
|
||||
| "memory_reinforced"
|
||||
| "render_selected"
|
||||
| "render_omitted"
|
||||
| "storage_corrupt_json_quarantined"
|
||||
| "storage_stale_lock_recovered"
|
||||
| "storage_lock_timeout"
|
||||
| "hook_failed";
|
||||
|
||||
export type EvidencePhase =
|
||||
| "extraction"
|
||||
| "explicit"
|
||||
| "pending_journal"
|
||||
| "promotion"
|
||||
| "reinforcement"
|
||||
| "render"
|
||||
| "storage"
|
||||
| "hook";
|
||||
|
||||
export type EvidenceOutcome =
|
||||
| "accepted"
|
||||
| "rejected"
|
||||
| "promoted"
|
||||
| "absorbed"
|
||||
| "superseded"
|
||||
| "rendered"
|
||||
| "omitted"
|
||||
| "retried"
|
||||
| "exhausted"
|
||||
| "reinforced"
|
||||
| "quarantined"
|
||||
| "failed"
|
||||
| "recovered";
|
||||
|
||||
export type MemoryEvidenceRef = {
|
||||
memoryId?: string;
|
||||
memoryKeyHash?: string;
|
||||
identityKeyHash?: string;
|
||||
type?: "feedback" | "project" | "decision" | "reference";
|
||||
source?: "explicit" | "compaction" | "manual";
|
||||
status?: "active" | "superseded";
|
||||
};
|
||||
|
||||
export type EvidenceRelation = {
|
||||
role:
|
||||
| "candidate"
|
||||
| "pending"
|
||||
| "promoted"
|
||||
| "retained"
|
||||
| "absorbed"
|
||||
| "superseded"
|
||||
| "superseded_by"
|
||||
| "reinforced"
|
||||
| "reinforced_by"
|
||||
| "rendered"
|
||||
| "omitted";
|
||||
memory?: MemoryEvidenceRef;
|
||||
};
|
||||
|
||||
export type EvidenceDetailValue = string | number | boolean | null | string[] | number[];
|
||||
|
||||
export type EvidenceEventV1 = {
|
||||
version: 1;
|
||||
eventId: string;
|
||||
createdAt: string;
|
||||
workspaceKey: string;
|
||||
workspaceRootHash: string;
|
||||
sessionHash?: string;
|
||||
messageHash?: string;
|
||||
type: EvidenceEventType;
|
||||
phase: EvidencePhase;
|
||||
outcome: EvidenceOutcome;
|
||||
memory?: MemoryEvidenceRef;
|
||||
relations?: EvidenceRelation[];
|
||||
reasonCodes: string[];
|
||||
details?: Record<string, EvidenceDetailValue>;
|
||||
textPreview?: string;
|
||||
};
|
||||
|
||||
export type EvidenceEventInput = Omit<
|
||||
EvidenceEventV1,
|
||||
"version" | "eventId" | "createdAt" | "workspaceKey" | "workspaceRootHash"
|
||||
>;
|
||||
|
||||
export type EvidenceQuery = {
|
||||
since?: string;
|
||||
until?: string;
|
||||
types?: EvidenceEventType[];
|
||||
phases?: EvidencePhase[];
|
||||
outcomes?: EvidenceOutcome[];
|
||||
memoryId?: string;
|
||||
memoryKeyHash?: string;
|
||||
identityKeyHash?: string;
|
||||
sessionHash?: string;
|
||||
limit?: number;
|
||||
newestFirst?: boolean;
|
||||
};
|
||||
|
||||
export type MemoryEvidenceSummary = {
|
||||
memoryId?: string;
|
||||
memoryKeyHash?: string;
|
||||
latestOutcome?: EvidenceOutcome;
|
||||
latestRenderStatus?: "rendered" | "omitted";
|
||||
reasonCodes: string[];
|
||||
eventIds: string[];
|
||||
lastEventAt?: string;
|
||||
};
|
||||
|
||||
export type MemoryLifecycleTrace = {
|
||||
memoryId?: string;
|
||||
memoryKeyHash?: string;
|
||||
identityKeyHash?: string;
|
||||
events: EvidenceEventV1[];
|
||||
createdBy?: EvidenceEventV1;
|
||||
acceptedBy?: EvidenceEventV1;
|
||||
promotedBy?: EvidenceEventV1;
|
||||
absorbedBy?: EvidenceEventV1;
|
||||
supersededBy?: EvidenceEventV1;
|
||||
reinforcedBy: EvidenceEventV1[];
|
||||
latestRender?: EvidenceEventV1;
|
||||
currentStatus:
|
||||
| "accepted"
|
||||
| "pending"
|
||||
| "promoted"
|
||||
| "absorbed"
|
||||
| "superseded"
|
||||
| "rendered"
|
||||
| "omitted"
|
||||
| "rejected"
|
||||
| "unknown";
|
||||
};
|
||||
|
||||
export const EVIDENCE_LOG_LIMITS = {
|
||||
maxAgeDays: 90,
|
||||
maxEventsPerWorkspace: 5000,
|
||||
maxBytesPerWorkspace: 2 * 1024 * 1024,
|
||||
pruneEveryAppendCount: 100,
|
||||
} as const;
|
||||
|
||||
const appendCounts = new Map<string, number>();
|
||||
const HASH_PATTERN = /^[a-f0-9]{16}$/i;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_DETAIL_STRING_CHARS = 240;
|
||||
const MAX_DETAIL_ARRAY_ITEMS = 25;
|
||||
|
||||
function evidenceHash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function normalizeHashValue(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
return HASH_PATTERN.test(value) ? value.toLowerCase() : evidenceHash(value);
|
||||
}
|
||||
|
||||
async function resolvedRoot(root: string): Promise<string> {
|
||||
return realpath(root).catch(() => root);
|
||||
}
|
||||
|
||||
function evidenceTextPreview(text: string, maxChars = 120): string {
|
||||
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
|
||||
}
|
||||
|
||||
function sanitizeReasonCode(reason: string): string {
|
||||
return reason.replace(/[^a-zA-Z0-9_.:-]/g, "_").slice(0, 120);
|
||||
}
|
||||
|
||||
function sanitizeMemoryRef(memory: MemoryEvidenceRef | undefined): MemoryEvidenceRef | undefined {
|
||||
if (!memory) return undefined;
|
||||
const sanitized: MemoryEvidenceRef = {};
|
||||
if (typeof memory.memoryId === "string" && memory.memoryId) sanitized.memoryId = memory.memoryId.slice(0, 160);
|
||||
if (memory.memoryKeyHash) sanitized.memoryKeyHash = normalizeHashValue(memory.memoryKeyHash);
|
||||
if (memory.identityKeyHash) sanitized.identityKeyHash = normalizeHashValue(memory.identityKeyHash);
|
||||
if (memory.type) sanitized.type = memory.type;
|
||||
if (memory.source) sanitized.source = memory.source;
|
||||
if (memory.status) sanitized.status = memory.status;
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
function sanitizeRelations(relations: EvidenceRelation[] | undefined): EvidenceRelation[] | undefined {
|
||||
if (!relations) return undefined;
|
||||
const sanitized = relations
|
||||
.map(relation => ({
|
||||
role: relation.role,
|
||||
memory: sanitizeMemoryRef(relation.memory),
|
||||
}))
|
||||
.slice(0, 25);
|
||||
return sanitized.length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
function sanitizeDetailString(value: string): string {
|
||||
return evidenceTextPreview(value, MAX_DETAIL_STRING_CHARS);
|
||||
}
|
||||
|
||||
function sanitizeDetails(details: EvidenceEventInput["details"]): EvidenceEventV1["details"] {
|
||||
if (!details) return undefined;
|
||||
const sanitized: Record<string, EvidenceDetailValue> = {};
|
||||
|
||||
for (const [rawKey, rawValue] of Object.entries(details).slice(0, 50)) {
|
||||
const key = rawKey.replace(/[^a-zA-Z0-9_.:-]/g, "_").slice(0, 80);
|
||||
if (!key) continue;
|
||||
|
||||
if (typeof rawValue === "string") {
|
||||
sanitized[key] = sanitizeDetailString(rawValue);
|
||||
} else if (typeof rawValue === "number") {
|
||||
if (Number.isFinite(rawValue)) sanitized[key] = rawValue;
|
||||
} else if (typeof rawValue === "boolean" || rawValue === null) {
|
||||
sanitized[key] = rawValue;
|
||||
} else if (Array.isArray(rawValue)) {
|
||||
if (rawValue.every(item => typeof item === "string")) {
|
||||
sanitized[key] = rawValue.slice(0, MAX_DETAIL_ARRAY_ITEMS).map(item => sanitizeDetailString(item));
|
||||
} else if (rawValue.every(item => typeof item === "number" && Number.isFinite(item))) {
|
||||
sanitized[key] = rawValue.slice(0, MAX_DETAIL_ARRAY_ITEMS) as number[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
function buildEvidenceEvent(
|
||||
input: EvidenceEventInput,
|
||||
workspaceKeyValue: string,
|
||||
workspaceRootHash: string,
|
||||
): EvidenceEventV1 {
|
||||
const textPreviewMax = input.type === "extraction_candidate_rejected" ? 80 : 120;
|
||||
const event: EvidenceEventV1 = {
|
||||
version: 1,
|
||||
eventId: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
workspaceKey: workspaceKeyValue,
|
||||
workspaceRootHash,
|
||||
type: input.type,
|
||||
phase: input.phase,
|
||||
outcome: input.outcome,
|
||||
reasonCodes: input.reasonCodes.map(sanitizeReasonCode).filter(Boolean).slice(0, 25),
|
||||
};
|
||||
|
||||
const memory = sanitizeMemoryRef(input.memory);
|
||||
const relations = sanitizeRelations(input.relations);
|
||||
const details = sanitizeDetails(input.details);
|
||||
if (input.sessionHash) event.sessionHash = normalizeHashValue(input.sessionHash);
|
||||
if (input.messageHash) event.messageHash = normalizeHashValue(input.messageHash);
|
||||
if (memory) event.memory = memory;
|
||||
if (relations) event.relations = relations;
|
||||
if (details) event.details = details;
|
||||
if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
async function safeAppendEvidenceLine(path: string, line: string): Promise<void> {
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, `${line}\n`, "utf8");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[memory] failed to write evidence event: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function maybePruneEvidenceLog(path: string): Promise<void> {
|
||||
const nextCount = (appendCounts.get(path) ?? 0) + 1;
|
||||
appendCounts.set(path, nextCount);
|
||||
if (nextCount % EVIDENCE_LOG_LIMITS.pruneEveryAppendCount !== 0) return;
|
||||
|
||||
try {
|
||||
await pruneEvidenceLogPath(path);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[memory] failed to prune evidence log: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendEvidenceEvent(root: string, event: EvidenceEventInput): Promise<EvidenceEventV1> {
|
||||
const records = await appendEvidenceEvents(root, [event]);
|
||||
return records[0];
|
||||
}
|
||||
|
||||
export async function appendEvidenceEvents(root: string, events: EvidenceEventInput[]): Promise<EvidenceEventV1[]> {
|
||||
const path = await workspaceEvidenceLogPath(root);
|
||||
const rootPath = await resolvedRoot(root);
|
||||
const workspaceRootHash = evidenceHash(rootPath);
|
||||
const workspaceKeyValue = await workspaceKey(root);
|
||||
const records = events.map(event => buildEvidenceEvent(event, workspaceKeyValue, workspaceRootHash));
|
||||
|
||||
for (const record of records) {
|
||||
await safeAppendEvidenceLine(path, JSON.stringify(record));
|
||||
await maybePruneEvidenceLog(path);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
export async function appendEvidenceEventForWorkspaceKey(
|
||||
workspaceKeyValue: string,
|
||||
event: EvidenceEventInput,
|
||||
): Promise<EvidenceEventV1> {
|
||||
const path = join(dataHome(), "opencode-working-memory", "workspaces", workspaceKeyValue, "evidence", "events.jsonl");
|
||||
const record = buildEvidenceEvent(event, workspaceKeyValue, workspaceKeyValue);
|
||||
await safeAppendEvidenceLine(path, JSON.stringify(record));
|
||||
await maybePruneEvidenceLog(path);
|
||||
return record;
|
||||
}
|
||||
|
||||
type ParsedEvidenceLine = {
|
||||
event: EvidenceEventV1;
|
||||
index: number;
|
||||
};
|
||||
|
||||
function parseEvidenceLine(line: string): EvidenceEventV1 | null {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Partial<EvidenceEventV1>;
|
||||
if (parsed.version !== 1 || !parsed.eventId || !parsed.createdAt || !parsed.type) return null;
|
||||
return parsed as EvidenceEventV1;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readEvidenceLines(path: string, warnInvalid: boolean): Promise<{ valid: ParsedEvidenceLine[]; invalid: string[] }> {
|
||||
if (!existsSync(path)) return { valid: [], invalid: [] };
|
||||
const raw = await readFile(path, "utf8");
|
||||
const valid: ParsedEvidenceLine[] = [];
|
||||
const invalid: string[] = [];
|
||||
|
||||
raw.split(/\n/).forEach((line, index) => {
|
||||
if (!line.trim()) return;
|
||||
const event = parseEvidenceLine(line);
|
||||
if (event) {
|
||||
valid.push({ event, index });
|
||||
} else {
|
||||
invalid.push(line);
|
||||
if (warnInvalid) console.warn(`[memory] skipped invalid evidence log line ${index + 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
|
||||
function eventTimeMs(event: EvidenceEventV1): number {
|
||||
const ms = new Date(event.createdAt).getTime();
|
||||
return Number.isFinite(ms) ? ms : 0;
|
||||
}
|
||||
|
||||
async function atomicWriteText(path: string, text: string): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
||||
try {
|
||||
await writeFile(tmp, text, { encoding: "utf8", mode: 0o600 });
|
||||
await rename(tmp, path);
|
||||
} catch (error) {
|
||||
await rm(tmp, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeEvents(events: EvidenceEventV1[]): string {
|
||||
return events.map(event => JSON.stringify(event)).join("\n") + (events.length > 0 ? "\n" : "");
|
||||
}
|
||||
|
||||
function trimEventsToByteLimit(events: EvidenceEventV1[]): EvidenceEventV1[] {
|
||||
let kept = [...events];
|
||||
while (kept.length > 0 && Buffer.byteLength(serializeEvents(kept), "utf8") > EVIDENCE_LOG_LIMITS.maxBytesPerWorkspace) {
|
||||
kept = kept.slice(1);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
async function pruneEvidenceLogPath(path: string): Promise<void> {
|
||||
if (!existsSync(path)) return;
|
||||
const stats = await stat(path);
|
||||
if (stats.isDirectory()) return;
|
||||
|
||||
const { valid, invalid } = await readEvidenceLines(path, false);
|
||||
if (invalid.length > 0) {
|
||||
const corruptPath = `${path}.corrupt-lines-${Date.now()}.jsonl`;
|
||||
await writeFile(corruptPath, invalid.join("\n") + "\n", { encoding: "utf8", mode: 0o600 }).catch(error => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[memory] failed to quarantine invalid evidence lines: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
const cutoff = Date.now() - EVIDENCE_LOG_LIMITS.maxAgeDays * DAY_MS;
|
||||
let events = valid
|
||||
.filter(item => eventTimeMs(item.event) >= cutoff)
|
||||
.sort((a, b) => eventTimeMs(a.event) - eventTimeMs(b.event) || a.index - b.index)
|
||||
.map(item => item.event);
|
||||
|
||||
if (events.length > EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace) {
|
||||
events = events.slice(events.length - EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace);
|
||||
}
|
||||
|
||||
events = trimEventsToByteLimit(events);
|
||||
await atomicWriteText(path, serializeEvents(events));
|
||||
}
|
||||
|
||||
function memoryRefMatches(memory: MemoryEvidenceRef | undefined, query: Pick<EvidenceQuery, "memoryId" | "memoryKeyHash" | "identityKeyHash">): boolean {
|
||||
if (!memory) return false;
|
||||
const memoryKeyHash = normalizeHashValue(query.memoryKeyHash);
|
||||
const identityKeyHash = normalizeHashValue(query.identityKeyHash);
|
||||
if (query.memoryId && memory.memoryId === query.memoryId) return true;
|
||||
if (memoryKeyHash && memory.memoryKeyHash === memoryKeyHash) return true;
|
||||
if (identityKeyHash && memory.identityKeyHash === identityKeyHash) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function eventMatchesMemory(event: EvidenceEventV1, query: Pick<EvidenceQuery, "memoryId" | "memoryKeyHash" | "identityKeyHash">): boolean {
|
||||
if (!query.memoryId && !query.memoryKeyHash && !query.identityKeyHash) return true;
|
||||
if (memoryRefMatches(event.memory, query)) return true;
|
||||
return (event.relations ?? []).some(relation => memoryRefMatches(relation.memory, query));
|
||||
}
|
||||
|
||||
export async function queryEvidenceEvents(
|
||||
root: string,
|
||||
query: EvidenceQuery = {},
|
||||
): Promise<EvidenceEventV1[]> {
|
||||
const path = await workspaceEvidenceLogPath(root);
|
||||
const { valid } = await readEvidenceLines(path, true);
|
||||
const sinceMs = query.since ? new Date(query.since).getTime() : undefined;
|
||||
const untilMs = query.until ? new Date(query.until).getTime() : undefined;
|
||||
const sessionHash = normalizeHashValue(query.sessionHash);
|
||||
|
||||
let events = valid.map(item => item.event).filter(event => {
|
||||
const createdAtMs = eventTimeMs(event);
|
||||
if (Number.isFinite(sinceMs) && createdAtMs < sinceMs) return false;
|
||||
if (Number.isFinite(untilMs) && createdAtMs > untilMs) return false;
|
||||
if (query.types && !query.types.includes(event.type)) return false;
|
||||
if (query.phases && !query.phases.includes(event.phase)) return false;
|
||||
if (query.outcomes && !query.outcomes.includes(event.outcome)) return false;
|
||||
if (sessionHash && event.sessionHash !== sessionHash) return false;
|
||||
if (!eventMatchesMemory(event, query)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (query.newestFirst) events = events.slice().reverse();
|
||||
if (typeof query.limit === "number" && query.limit >= 0) events = events.slice(0, query.limit);
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function summarizeMemoryEvidence(
|
||||
root: string,
|
||||
input: { memoryId?: string; memoryKeyHash?: string },
|
||||
): Promise<MemoryEvidenceSummary> {
|
||||
const events = await queryEvidenceEvents(root, input);
|
||||
const latest = events.at(-1);
|
||||
const latestRender = events.filter(event => event.phase === "render").at(-1);
|
||||
const reasonCodes = new Set<string>();
|
||||
for (const event of events) {
|
||||
for (const reason of event.reasonCodes) reasonCodes.add(reason);
|
||||
}
|
||||
|
||||
return {
|
||||
memoryId: input.memoryId,
|
||||
memoryKeyHash: normalizeHashValue(input.memoryKeyHash),
|
||||
latestOutcome: latest?.outcome,
|
||||
latestRenderStatus: latestRender?.outcome === "rendered" || latestRender?.outcome === "omitted"
|
||||
? latestRender.outcome
|
||||
: undefined,
|
||||
reasonCodes: [...reasonCodes],
|
||||
eventIds: events.map(event => event.eventId),
|
||||
lastEventAt: latest?.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function currentStatusFromEvent(event: EvidenceEventV1 | undefined): MemoryLifecycleTrace["currentStatus"] {
|
||||
if (!event) return "unknown";
|
||||
if (event.outcome === "accepted") return "accepted";
|
||||
if (event.outcome === "promoted") return "promoted";
|
||||
if (event.outcome === "absorbed") return "absorbed";
|
||||
if (event.outcome === "superseded") return "superseded";
|
||||
if (event.outcome === "rendered") return "rendered";
|
||||
if (event.outcome === "omitted") return "omitted";
|
||||
if (event.outcome === "rejected") return "rejected";
|
||||
if (event.type === "pending_memory_appended") return "pending";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function traceMemoryLifecycle(
|
||||
root: string,
|
||||
input: { memoryId?: string; memoryKeyHash?: string; identityKeyHash?: string },
|
||||
): Promise<MemoryLifecycleTrace> {
|
||||
const events = await queryEvidenceEvents(root, input);
|
||||
const createdBy = events.find(event => event.type === "explicit_memory_detected" || event.type === "pending_memory_appended");
|
||||
const acceptedBy = events.find(event => event.type === "extraction_candidate_accepted" || event.type === "explicit_memory_detected");
|
||||
const promotedBy = events.find(event => event.type === "promotion_promoted");
|
||||
const absorbedBy = events.find(event => event.outcome === "absorbed");
|
||||
const supersededBy = events.find(event => event.outcome === "superseded");
|
||||
const reinforcedBy = events.filter(event => event.type === "memory_reinforced" || event.outcome === "reinforced");
|
||||
const latestRender = events.filter(event => event.phase === "render").at(-1);
|
||||
const latest = events.at(-1);
|
||||
|
||||
return {
|
||||
memoryId: input.memoryId,
|
||||
memoryKeyHash: normalizeHashValue(input.memoryKeyHash),
|
||||
identityKeyHash: normalizeHashValue(input.identityKeyHash),
|
||||
events,
|
||||
createdBy,
|
||||
acceptedBy,
|
||||
promotedBy,
|
||||
absorbedBy,
|
||||
supersededBy,
|
||||
reinforcedBy,
|
||||
latestRender,
|
||||
currentStatus: currentStatusFromEvent(latest),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import { createHash } from "crypto";
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import { extractionRejectionLogPath } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import type { EvidenceEventInput } from "./evidence-log.ts";
|
||||
|
||||
function id(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function hash(value: string): string {
|
||||
return createHash("sha1").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a memory request is negated (e.g., "不要記住", "don't remember").
|
||||
* Uses structured adjacency detection to avoid false positives.
|
||||
*/
|
||||
function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
|
||||
const prefix = text.slice(Math.max(0, matchIndex - 30), matchIndex);
|
||||
|
||||
// Chinese negative: 不要/別/不用 + optional 幫我, must be adjacent to trigger
|
||||
if (/(?:不要|別|别|不用|不需要|勿)\s*(?:幫我|帮我)?\s*$/u.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// English negative: do not / don't / never / not + optional please, must be adjacent to trigger
|
||||
if (/(?:do\s+not|don't|dont|never|not)\s+(?:please\s+)?$/i.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Japanese negative
|
||||
if (/(?:覚えないで|記憶しないで|メモしないで)\s*$/u.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Korean negative
|
||||
if (/(?:기억하지\s*마|기억하지마|메모하지\s*마|메모하지마)\s*$/u.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
return extractExplicitMemoriesWithEvidence(text).entries;
|
||||
}
|
||||
|
||||
export type WorkspaceMemoryParseResult = {
|
||||
entries: LongTermMemoryEntry[];
|
||||
evidence: EvidenceEventInput[];
|
||||
};
|
||||
|
||||
function evidenceTextPreview(text: string, maxChars = 120): string {
|
||||
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
|
||||
}
|
||||
|
||||
function memoryEvidence(memory: LongTermMemoryEntry): EvidenceEventInput["memory"] {
|
||||
return {
|
||||
memoryId: memory.id,
|
||||
type: memory.type,
|
||||
source: memory.source,
|
||||
status: memory.status,
|
||||
};
|
||||
}
|
||||
|
||||
function extractionEvidence(
|
||||
input: Pick<EvidenceEventInput, "type" | "phase" | "outcome" | "reasonCodes" | "textPreview" | "memory" | "details">,
|
||||
): EvidenceEventInput {
|
||||
return input;
|
||||
}
|
||||
|
||||
export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemoryParseResult {
|
||||
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
|
||||
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
|
||||
const patterns = [
|
||||
// 中文:請/幫我 + 記住 + 可選後綴
|
||||
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/gim,
|
||||
// 日文(長詞優先):覚えておいて must come before 覚えて
|
||||
/(?:^|\n)\s*(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/gim,
|
||||
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
|
||||
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
|
||||
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
|
||||
/(?:^|\n)\s*(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/gim,
|
||||
// save/add to memory
|
||||
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/gim,
|
||||
// commit to memory
|
||||
/(?:^|\n)\s*(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/gim,
|
||||
// going forward / from now on
|
||||
/(?:从现在开始|從現在開始|从今以后|從今以後|from now on|going forward)[::,,]?\s*(.+)$/gim,
|
||||
// 偏好
|
||||
/(?:我的偏好是|我偏好|以后请|以後請|以后都|以後都)[::,,]?\s*(.+)$/gim,
|
||||
/(?:^|\n)\s*(?:my preference is|i prefer)[::,,]?\s*(.+)$/gim,
|
||||
];
|
||||
|
||||
const nowMs = Date.now();
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
const seen = new Set<string>();
|
||||
const negatedLinePattern = /(?:^|\n)\s*(?:(?:please\s+)?(?:do\s+not|don't|dont|never)\s+remember(?:\s+(?:this|that))?|不要\s*(?:記住|记住)|別\s*(?:記住|记住)|别\s*(?:記住|记住))[::,,]?\s*(.+)$/gim;
|
||||
for (const match of text.matchAll(negatedLinePattern)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["negated_request"],
|
||||
textPreview: evidenceTextPreview(match[1] ?? match[0], 80),
|
||||
}));
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const body = match[1]?.trim();
|
||||
if (body && /^(再说|再說|later|next time)$/i.test(body)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["deferral"],
|
||||
textPreview: evidenceTextPreview(body, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if (!body || body.length < 8) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["too_short"],
|
||||
textPreview: evidenceTextPreview(body ?? match[0], 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate actual trigger position (after possible newline)
|
||||
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
|
||||
|
||||
// Check if this is a negated request (e.g., "不要記住")
|
||||
if (isNegatedMemoryRequest(text, triggerIndex)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["negated_request"],
|
||||
textPreview: evidenceTextPreview(body, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dedupe by canonical body
|
||||
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
if (seen.has(key)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_ignored",
|
||||
phase: "explicit",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["duplicate_in_message"],
|
||||
textPreview: evidenceTextPreview(body, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
const type = classifyExplicitMemory(body);
|
||||
const memory: LongTermMemoryEntry = {
|
||||
id: id("mem"),
|
||||
type,
|
||||
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
retentionClock: nowMs,
|
||||
staleAfterDays: staleAfterDaysFor(type),
|
||||
};
|
||||
entries.push(memory);
|
||||
evidence.push(extractionEvidence({
|
||||
type: "explicit_memory_detected",
|
||||
phase: "explicit",
|
||||
outcome: "accepted",
|
||||
reasonCodes: ["explicit_trigger_matched"],
|
||||
memory: memoryEvidence(memory),
|
||||
textPreview: evidenceTextPreview(memory.text),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, evidence };
|
||||
}
|
||||
|
||||
function classifyExplicitMemory(text: string): LongTermType {
|
||||
const lower = text.toLowerCase();
|
||||
if (/https?:\/\/|linear|slack|notion|dashboard|grafana/.test(lower)) return "reference";
|
||||
if (/decide|decision|choose|chosen|决定|決定|选择|選擇/.test(lower)) return "decision";
|
||||
if (/project|repo|项目|專案/.test(lower)) return "project";
|
||||
return "feedback";
|
||||
}
|
||||
|
||||
export function staleAfterDaysFor(type: LongTermType): number | undefined {
|
||||
if (type === "feedback") return undefined;
|
||||
if (type === "decision") return 45;
|
||||
if (type === "project") return 60;
|
||||
return 90;
|
||||
}
|
||||
|
||||
export function extractActiveFiles(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
output: string,
|
||||
): Array<{ path: string; action: ActiveFile["action"] }> {
|
||||
if (toolName === "read" && typeof args.filePath === "string") return [{ path: args.filePath, action: "read" }];
|
||||
if (toolName === "edit" && typeof args.filePath === "string") return [{ path: args.filePath, action: "edit" }];
|
||||
if (toolName === "write" && typeof args.filePath === "string") return [{ path: args.filePath, action: "write" }];
|
||||
if (toolName === "grep") return extractGrepPaths(output).map(path => ({ path, action: "grep" as const }));
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractGrepPaths(output: string): string[] {
|
||||
const matches = output.match(/^(\/[^\n]+\.(ts|tsx|js|jsx|json|md|py|go|rs|toml|yml|yaml)):/gm) ?? [];
|
||||
return [...new Set(matches.map(match => match.replace(/:$/, "")))].slice(0, 10);
|
||||
}
|
||||
|
||||
function isErrorLine(line: string, knownValidationCommand: boolean): boolean {
|
||||
// 無條件捕捉的強訊號
|
||||
if (/TS\d{4}|ERR!|Traceback \(most recent call last\):|panic:/i.test(line)) return true;
|
||||
|
||||
// Error 類型前綴(獨立行)
|
||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(line)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 已知 validation command 才用寬鬆匹配
|
||||
if (knownValidationCommand) {
|
||||
return /\b(error|failed|failure|exception)\b/i.test(line);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractErrorsFromBash(command: string, output: string): OpenError[] {
|
||||
const classifiedCategory = classifyCommand(command);
|
||||
const knownValidationCommand = classifiedCategory !== null;
|
||||
|
||||
const lines = output
|
||||
.split("\n")
|
||||
.filter(line => isErrorLine(line, knownValidationCommand))
|
||||
.slice(0, 5);
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const category = classifiedCategory ?? "runtime";
|
||||
const summary = lines.join(" ").slice(0, 280);
|
||||
const fingerprint = hash(`${category}:${summary.toLowerCase().replace(/\s+/g, " ")}`);
|
||||
const now = Date.now();
|
||||
|
||||
return [
|
||||
{
|
||||
id: `err_${fingerprint}`,
|
||||
category,
|
||||
summary,
|
||||
command,
|
||||
file: extractFirstPath(summary),
|
||||
fingerprint,
|
||||
status: "open",
|
||||
firstSeen: now,
|
||||
lastSeen: now,
|
||||
seenCount: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function classifyCommand(command: string): OpenError["category"] | null {
|
||||
const c = command.toLowerCase();
|
||||
if (/\b(tsc|typecheck)\b/.test(c)) return "typecheck";
|
||||
if (/\b(test|vitest|jest|mocha|pytest|go test|cargo test)\b/.test(c)) return "test";
|
||||
if (/\b(lint|eslint|biome)\b/.test(c)) return "lint";
|
||||
if (/\b(build|vite build|webpack|tsup)\b/.test(c)) return "build";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCandidateBody(body: string): { text: string; hadTrigger: boolean } | null {
|
||||
const text = body.trim();
|
||||
const triggerPatterns = [
|
||||
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im,
|
||||
/(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im,
|
||||
/(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im,
|
||||
];
|
||||
|
||||
for (const pattern of triggerPatterns) {
|
||||
const match = pattern.exec(text);
|
||||
if (!match) continue;
|
||||
|
||||
const triggerIndex = match.index + (match[0].match(/^\s*/)?.[0]?.length || 0);
|
||||
if (isNegatedMemoryRequest(text, triggerIndex)) return null;
|
||||
|
||||
const extracted = match[1]?.trim();
|
||||
return extracted ? { text: extracted, hadTrigger: true } : null;
|
||||
}
|
||||
|
||||
return { text, hadTrigger: false };
|
||||
}
|
||||
|
||||
function extractFirstPath(text: string): string | undefined {
|
||||
return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Acceptance gate for workspace memory candidates.
|
||||
* Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts.
|
||||
*/
|
||||
type ExtractionRejectionLogEntry = {
|
||||
timestamp: string;
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
reasons: string[];
|
||||
source: "compaction";
|
||||
};
|
||||
|
||||
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
|
||||
try {
|
||||
const path = extractionRejectionLogPath();
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
|
||||
} catch (error) {
|
||||
console.error("[memory] failed to write extraction rejection log:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
},
|
||||
options: {
|
||||
fromMemoryTrigger?: boolean;
|
||||
} = {},
|
||||
): { accepted: boolean; reasons: string[] } {
|
||||
const text = entry.text.trim();
|
||||
const minLength = options.fromMemoryTrigger ? 6 : 20;
|
||||
|
||||
// Too short (with type-specific allowlist for stable config values)
|
||||
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
|
||||
// Stable config values can be short — allow below generic min length
|
||||
} else if (text.length < minLength) {
|
||||
return { accepted: false, reasons: ["too_short"] };
|
||||
}
|
||||
|
||||
// Indirect Prompt Injection / Adversarial Instructions
|
||||
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
||||
// comparative "instead of" is allowed.
|
||||
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return { accepted: false, reasons: ["prompt_injection"] };
|
||||
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return { accepted: false, reasons: ["prompt_injection"] };
|
||||
|
||||
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
|
||||
if (!quality.accepted) {
|
||||
void logExtractionRejection({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: entry.type,
|
||||
text: redactCredentials(text),
|
||||
reasons: quality.reasons,
|
||||
source: "compaction",
|
||||
});
|
||||
return { accepted: false, reasons: quality.reasons };
|
||||
}
|
||||
|
||||
return { accepted: true, reasons: ["quality_gate_passed"] };
|
||||
}
|
||||
|
||||
function shouldAcceptWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
},
|
||||
options: {
|
||||
fromMemoryTrigger?: boolean;
|
||||
} = {},
|
||||
): boolean {
|
||||
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate block from summary using multiple formats.
|
||||
* Supports: Plain text label, Markdown section, legacy XML.
|
||||
*/
|
||||
function extractCandidateBlock(summary: string): string | null {
|
||||
// 1. Plain text label (primary format, no Markdown header)
|
||||
const plainMatch = summary.match(/Memory candidates:\s*\n([\s\S]*?)(?:\n[A-Z][a-z]+ [a-z]+:|\n##\s|$)/i);
|
||||
if (plainMatch) return plainMatch[1];
|
||||
|
||||
// 2. Markdown section (legacy)
|
||||
const markdownMatch = summary.match(/##\s*Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||||
if (markdownMatch) return markdownMatch[1];
|
||||
|
||||
// 3. Legacy "Workspace Memory Candidates" section
|
||||
const legacyMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||||
if (legacyMatch) return legacyMatch[1];
|
||||
|
||||
// 4. Legacy XML block (backward compatible)
|
||||
const xmlMatch = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
|
||||
if (xmlMatch) return xmlMatch[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
|
||||
return parseWorkspaceMemoryCandidatesWithEvidence(summary).entries;
|
||||
}
|
||||
|
||||
export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): WorkspaceMemoryParseResult {
|
||||
const block = extractCandidateBlock(summary);
|
||||
if (!block) return { entries: [], evidence: [] };
|
||||
|
||||
const nowMs = Date.now();
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
for (const line of block.split("\n")) {
|
||||
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
||||
const item = line.trim().match(
|
||||
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
|
||||
);
|
||||
if (!item) continue;
|
||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||
const normalizedBody = normalizeCandidateBody(item[3]);
|
||||
if (!normalizedBody) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["negated_request"],
|
||||
memory: { type, source: "compaction" },
|
||||
textPreview: evidenceTextPreview(item[3], 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const minLength = normalizedBody.hadTrigger ? 6 : 12;
|
||||
if (normalizedBody.text.length < minLength) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["too_short"],
|
||||
memory: { type, source: "compaction" },
|
||||
textPreview: evidenceTextPreview(normalizedBody.text, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply quality gate
|
||||
const quality = evaluateWorkspaceMemoryCandidate(
|
||||
{ type, text: normalizedBody.text },
|
||||
{ fromMemoryTrigger: normalizedBody.hadTrigger },
|
||||
);
|
||||
if (!quality.accepted) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
reasonCodes: quality.reasons,
|
||||
memory: { type, source: "compaction" },
|
||||
textPreview: evidenceTextPreview(normalizedBody.text, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const memory: LongTermMemoryEntry = {
|
||||
id: id("mem"),
|
||||
type,
|
||||
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
retentionClock: nowMs,
|
||||
staleAfterDays: staleAfterDaysFor(type),
|
||||
};
|
||||
entries.push(memory);
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_accepted",
|
||||
phase: "extraction",
|
||||
outcome: "accepted",
|
||||
reasonCodes: ["quality_gate_passed", "valid_candidate_format"],
|
||||
memory: memoryEvidence(memory),
|
||||
textPreview: evidenceTextPreview(memory.text),
|
||||
}));
|
||||
}
|
||||
|
||||
return { entries, evidence };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { LongTermMemoryEntry, LongTermSource } from "./types.ts";
|
||||
|
||||
export type MemoryQualityInput = Pick<LongTermMemoryEntry, "type" | "text"> & {
|
||||
source?: LongTermSource;
|
||||
};
|
||||
|
||||
export type MemoryQualityResult = {
|
||||
accepted: boolean;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||
"empty",
|
||||
"progress_snapshot",
|
||||
"raw_error",
|
||||
"commit_or_ci_snapshot",
|
||||
"temporary_status",
|
||||
"active_file_snapshot",
|
||||
"code_or_api_signature",
|
||||
"path_heavy",
|
||||
]);
|
||||
|
||||
export function isHardQualityReason(reason: string): boolean {
|
||||
return HARD_QUALITY_REASONS.has(reason);
|
||||
}
|
||||
|
||||
export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult {
|
||||
const reasons: string[] = [];
|
||||
const text = entry.text.trim();
|
||||
|
||||
if (text.length === 0) reasons.push("empty");
|
||||
if (isProgressSnapshotViolation(text)) reasons.push("progress_snapshot");
|
||||
if (isRawErrorViolation(text)) reasons.push("raw_error");
|
||||
if (isCommitOrCiViolation(text)) reasons.push("commit_or_ci_snapshot");
|
||||
if (isPathHeavyViolation(text)) reasons.push("path_heavy");
|
||||
if (isTemporaryStatusViolation(text)) reasons.push("temporary_status");
|
||||
if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot");
|
||||
if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature");
|
||||
if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback");
|
||||
if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision");
|
||||
|
||||
return { accepted: reasons.length === 0, reasons };
|
||||
}
|
||||
|
||||
export function isProgressSnapshotViolation(text: string): boolean {
|
||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||
|
||||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||||
if (hasSnapshotContext && !hasLimitContext) return true;
|
||||
}
|
||||
|
||||
if (/\b(?:completed|done|finished|implemented|added|updated|fixed|reviewed|passed|modified)\b/i.test(text)) {
|
||||
if (/\b(?:wave|phase|task|plan|pr|commit|ci|test|suite|implementation|session|change|fix|review|file)\b/i.test(text)) return true;
|
||||
}
|
||||
if (/(?:已完成|完成|修復|实现|實作).{0,40}(?:wave|phase|task|plan|PR|測試|测试|實作|实现|修復)/iu.test(text)) return true;
|
||||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||||
if (/completed|done|finished|完成|已完成/i.test(text)) return true;
|
||||
}
|
||||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||||
if (/\b(?:currently|right now|latest change|previous session|last wave|next step)\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isFeedbackQualityViolation(text: string): boolean {
|
||||
const stablePreference = /\b(?:user|the user)\s+(?:prefers|wants|asked|expects|requires|likes|dislikes)\b/i.test(text)
|
||||
|| /\b(?:prefer|preference|going forward|from now on|always|never)\b/i.test(text)
|
||||
|| /(?:使用者|用戶|用户).{0,12}(?:偏好|希望|要求|想要)/u.test(text)
|
||||
|| /(?:以後|以后|請|请).{0,20}(?:使用|回答|保持|避免)/u.test(text);
|
||||
|
||||
if (stablePreference) return false;
|
||||
|
||||
const internalNote = /\b(?:implemented|updated|fixed|reviewed|added|changed|modified|created|writes|wrote)\b/i.test(text);
|
||||
if (internalNote) return true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isDecisionQualityViolation(text: string): boolean {
|
||||
const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|
||||
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
|
||||
if (!futureRule) return true;
|
||||
if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true;
|
||||
if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isRawErrorViolation(text: string): boolean {
|
||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
|
||||
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isCommitOrCiViolation(text: string): boolean {
|
||||
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return true;
|
||||
if (/\b[0-9a-f]{7,40}\b/.test(text)) return true;
|
||||
if (/\bCI\b.*\b(?:passed|failed|run|compatibility|flaky)\b/i.test(text)) return true;
|
||||
if (/\b(?:passed|failed|run|compatibility|flaky)\b.*\bCI\b/i.test(text)) return true;
|
||||
if (/\bcompatibility\s+run\s+\d+/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPathHeavyViolation(text: string): boolean {
|
||||
const pathCount = (text.match(/\/[\w.-]+(?:\/[\w.-]+)+/g) || []).length;
|
||||
return pathCount > 2;
|
||||
}
|
||||
|
||||
function isTemporaryStatusViolation(text: string): boolean {
|
||||
if (/^(currently|now|pending|in progress|todo|wip)\b/i.test(text)) return true;
|
||||
if (/\b(?:run npm test|tests? are running|next reply|before continuing)\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isActiveFileSnapshotViolation(text: string): boolean {
|
||||
return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text);
|
||||
}
|
||||
|
||||
function isCodeOrApiSignatureViolation(text: string): boolean {
|
||||
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return true;
|
||||
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* OpenCode SDK helper functions for memory plugin.
|
||||
*
|
||||
* These functions wrap OpenCode client API calls to extract:
|
||||
* - Latest user message text (for explicit memory extraction)
|
||||
* - Latest compaction summary (for memory candidate parsing)
|
||||
* - Pending todos (for compaction context)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract the latest user message text from a session.
|
||||
* Returns { id, text } or null if no user message found.
|
||||
*/
|
||||
export async function latestUserText(
|
||||
client: unknown,
|
||||
sessionID: string
|
||||
): Promise<{ id: string; text: string } | null> {
|
||||
try {
|
||||
// Cast client to access session.messages API
|
||||
const api = client as {
|
||||
session: {
|
||||
messages: (params: { path: { id: string } }) => Promise<{
|
||||
data?: Array<{
|
||||
info?: {
|
||||
role?: string;
|
||||
id?: string;
|
||||
};
|
||||
parts?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const result = await api.session.messages({ path: { id: sessionID } });
|
||||
const messages = result.data ?? [];
|
||||
|
||||
// Scan backwards from most recent messages to find the latest user message
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.info?.role !== "user") continue;
|
||||
|
||||
// Concatenate all text parts
|
||||
const text = (msg.parts ?? [])
|
||||
.filter((p: { type?: string }) => p.type === "text")
|
||||
.map((p: { text?: string }) => p.text ?? "")
|
||||
.join("\n");
|
||||
|
||||
if (text.trim()) {
|
||||
return {
|
||||
id: msg.info?.id ?? "",
|
||||
text: text.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the latest compaction summary from a session.
|
||||
* Compaction summaries are assistant messages marked with summary=true.
|
||||
*/
|
||||
export async function latestCompactionSummary(
|
||||
client: unknown,
|
||||
sessionID: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const api = client as {
|
||||
session: {
|
||||
messages: (params: { path: { id: string } }) => Promise<{
|
||||
data?: Array<{
|
||||
info?: {
|
||||
role?: string;
|
||||
summary?: boolean;
|
||||
};
|
||||
parts?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const result = await api.session.messages({ path: { id: sessionID } });
|
||||
const messages = result.data ?? [];
|
||||
|
||||
// Scan backwards to find the most recent summary (compaction)
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.info?.role !== "assistant" || msg.info?.summary !== true) continue;
|
||||
|
||||
const text = (msg.parts ?? [])
|
||||
.filter((p: { type?: string }) => p.type === "text")
|
||||
.map((p: { text?: string }) => p.text ?? "")
|
||||
.join("\n");
|
||||
|
||||
if (text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pending todos from a session.
|
||||
* Returns todos that are not marked as completed.
|
||||
*/
|
||||
export async function pendingTodos(
|
||||
client: unknown,
|
||||
sessionID: string
|
||||
): Promise<Array<{ content: string; status: string; priority?: string }>> {
|
||||
try {
|
||||
const api = client as {
|
||||
session: {
|
||||
todo: (params: { path: { id: string } }) => Promise<{
|
||||
data?: Array<{
|
||||
content?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const result = await api.session.todo({ path: { id: sessionID } });
|
||||
const todos = result.data ?? [];
|
||||
|
||||
// Filter out completed todos
|
||||
return todos
|
||||
.filter((todo: { status?: string }) => todo.status !== "completed")
|
||||
.map((todo: { content?: string; status?: string; priority?: string }) => ({
|
||||
content: todo.content ?? "",
|
||||
status: todo.status ?? "pending",
|
||||
priority: todo.priority,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is a sub-agent (has a parent session).
|
||||
* Sub-agents are short-lived and should not have their own memory tracking.
|
||||
*/
|
||||
export async function isSubAgent(
|
||||
client: unknown,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const api = client as {
|
||||
session: {
|
||||
get: (params: { path: { id: string } }) => Promise<{
|
||||
data?: {
|
||||
parentID?: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const result = await api.session.get({ path: { id: sessionID } });
|
||||
return result.data?.parentID != null;
|
||||
} catch {
|
||||
// If we can't determine, assume it's NOT a sub-agent (safe default)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHash } from "crypto";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { realpath } from "fs/promises";
|
||||
|
||||
export function dataHome(): string {
|
||||
return process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
|
||||
}
|
||||
|
||||
export async function workspaceKey(root: string): Promise<string> {
|
||||
const resolved = await realpath(root).catch(() => root);
|
||||
return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export async function memoryRoot(root: string): Promise<string> {
|
||||
return join(dataHome(), "opencode-working-memory", "workspaces", await workspaceKey(root));
|
||||
}
|
||||
|
||||
export async function workspaceMemoryPath(root: string): Promise<string> {
|
||||
return join(await memoryRoot(root), "workspace-memory.json");
|
||||
}
|
||||
|
||||
export async function workspacePendingJournalPath(root: string): Promise<string> {
|
||||
return join(await memoryRoot(root), "workspace-pending-journal.json");
|
||||
}
|
||||
|
||||
export async function workspaceEvidenceLogPath(root: string): Promise<string> {
|
||||
return join(await memoryRoot(root), "evidence", "events.jsonl");
|
||||
}
|
||||
|
||||
export async function sessionStatePath(root: string, sessionID: string): Promise<string> {
|
||||
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
|
||||
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
|
||||
}
|
||||
|
||||
export function migrationLogPath(migrationId: string): string {
|
||||
return join(dataHome(), "opencode-working-memory", "migration-logs", `${migrationId}.jsonl`);
|
||||
}
|
||||
|
||||
export function extractionRejectionLogPath(): string {
|
||||
return join(dataHome(), "opencode-working-memory", "extraction-rejections.jsonl");
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts";
|
||||
import { PROMOTION_RETRY_LIMITS } from "./types.ts";
|
||||
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
|
||||
/**
|
||||
* Retention limits for the pending memory journal.
|
||||
*
|
||||
* The journal is a scratchpad for memories that haven't been promoted to
|
||||
* workspace memory yet. It should not grow unboundedly:
|
||||
* - maxEntries: Hard cap on number of pending entries
|
||||
* - maxAgeDays: Prune entries older than this (compaction candidates that
|
||||
* were never promoted)
|
||||
*/
|
||||
export const PENDING_JOURNAL_LIMITS = {
|
||||
maxEntries: 50,
|
||||
maxAgeDays: 30,
|
||||
} as const;
|
||||
|
||||
function normalizeMemoryText(text: string): string {
|
||||
return text
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[\s\p{P}]+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function memoryKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
|
||||
return `${entry.type}:${normalizeMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
export async function emptyPendingJournal(root: string): Promise<PendingMemoryJournalStore> {
|
||||
return {
|
||||
version: 1,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
entries: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const result: LongTermMemoryEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = `${memoryKey(entry)}\u0000${entry.pendingOwnerSessionID ?? ""}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(entry);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective timestamp for an entry, preferring updatedAt over createdAt.
|
||||
* Returns 0 if both are invalid/missing.
|
||||
*/
|
||||
function entryTime(entry: LongTermMemoryEntry): number {
|
||||
const updatedAt = entry.updatedAt ? new Date(entry.updatedAt).getTime() : NaN;
|
||||
if (!Number.isNaN(updatedAt)) return updatedAt;
|
||||
|
||||
const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : NaN;
|
||||
if (!Number.isNaN(createdAt)) return createdAt;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isStaleEntry(entry: LongTermMemoryEntry, maxAgeDays: number): boolean {
|
||||
const time = entryTime(entry);
|
||||
|
||||
// Invalid timestamps are corruption safety and apply to every source.
|
||||
if (time === 0) return true;
|
||||
|
||||
// TTL policy applies only to compaction candidates. Explicit/manual entries
|
||||
// represent user intent and should survive age while under the hard cap.
|
||||
if (entry.source !== "compaction") return false;
|
||||
|
||||
return Date.now() - time > maxAgeDays * 86_400_000;
|
||||
}
|
||||
|
||||
function applyRetention(
|
||||
entries: LongTermMemoryEntry[],
|
||||
maxEntries: number,
|
||||
maxAgeDays: number,
|
||||
): LongTermMemoryEntry[] {
|
||||
const deduped = dedupeByText(entries);
|
||||
const freshEntries = deduped.filter(entry => !isStaleEntry(entry, maxAgeDays));
|
||||
const sorted = [...freshEntries].sort((a, b) => {
|
||||
const timeDiff = entryTime(b) - entryTime(a);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const capped = sorted.slice(0, maxEntries);
|
||||
return capped.sort((a, b) => {
|
||||
const timeDiff = entryTime(a) - entryTime(b);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeJournal(
|
||||
root: string,
|
||||
store: PendingMemoryJournalStore,
|
||||
): Promise<PendingMemoryJournalStore> {
|
||||
return workspaceKey(root).then(key => ({
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
entries: applyRetention(
|
||||
Array.isArray(store.entries) ? store.entries : [],
|
||||
PENDING_JOURNAL_LIMITS.maxEntries,
|
||||
PENDING_JOURNAL_LIMITS.maxAgeDays,
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function loadPendingJournal(root: string): Promise<PendingMemoryJournalStore> {
|
||||
const path = await workspacePendingJournalPath(root);
|
||||
const fallback = await emptyPendingJournal(root);
|
||||
const loaded = await readJSON(path, () => fallback) as Partial<PendingMemoryJournalStore>;
|
||||
return normalizeJournal(root, {
|
||||
version: loaded.version ?? 1,
|
||||
workspace: loaded.workspace ?? fallback.workspace,
|
||||
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
|
||||
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function savePendingJournal(root: string, store: PendingMemoryJournalStore): Promise<void> {
|
||||
await atomicWriteJSON(await workspacePendingJournalPath(root), await normalizeJournal(root, store));
|
||||
}
|
||||
|
||||
export async function updatePendingJournal(
|
||||
root: string,
|
||||
updater: (store: PendingMemoryJournalStore) => PendingMemoryJournalStore | Promise<PendingMemoryJournalStore>,
|
||||
): Promise<PendingMemoryJournalStore> {
|
||||
const path = await workspacePendingJournalPath(root);
|
||||
const fallback = await emptyPendingJournal(root);
|
||||
return updateJSON(path, () => fallback, async current => {
|
||||
const normalized = await normalizeJournal(root, current);
|
||||
return normalizeJournal(root, await updater(normalized));
|
||||
});
|
||||
}
|
||||
|
||||
export async function appendPendingMemories(root: string, memories: LongTermMemoryEntry[]): Promise<void> {
|
||||
if (memories.length === 0) return;
|
||||
await updatePendingJournal(root, store => {
|
||||
store.entries.push(...memories);
|
||||
return store;
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasPendingJournalEntries(root: string): Promise<boolean> {
|
||||
const journal = await loadPendingJournal(root);
|
||||
return journal.entries.length > 0;
|
||||
}
|
||||
|
||||
export async function clearPendingMemories(
|
||||
root: string,
|
||||
keys?: Set<string>,
|
||||
options: { ownerSessionID?: string; clearUnowned?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await updatePendingJournal(root, store => {
|
||||
if (!keys || keys.size === 0) {
|
||||
store.entries = [];
|
||||
return store;
|
||||
}
|
||||
|
||||
store.entries = store.entries.filter(entry => {
|
||||
if (!keys.has(memoryKey(entry))) return true;
|
||||
|
||||
if (options.ownerSessionID) {
|
||||
if (entry.pendingOwnerSessionID === options.ownerSessionID) return false;
|
||||
if (options.clearUnowned && !entry.pendingOwnerSessionID) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.clearUnowned) {
|
||||
return Boolean(entry.pendingOwnerSessionID);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
return store;
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordPromotionRejections(
|
||||
root: string,
|
||||
keys: Set<string>,
|
||||
reason: string,
|
||||
options: { ownerSessionID?: string; includeUnownedOnly?: boolean } = {},
|
||||
): Promise<Set<string>> {
|
||||
const exhausted = new Set<string>();
|
||||
if (keys.size === 0) return exhausted;
|
||||
|
||||
await updatePendingJournal(root, store => {
|
||||
const nowIso = new Date().toISOString();
|
||||
const exhaustedEntries = new Set<string>();
|
||||
|
||||
store.entries = store.entries.map(entry => {
|
||||
const key = memoryKey(entry);
|
||||
if (!keys.has(key)) return entry;
|
||||
if (options.ownerSessionID && entry.pendingOwnerSessionID !== options.ownerSessionID) return entry;
|
||||
if (!options.ownerSessionID && options.includeUnownedOnly && entry.pendingOwnerSessionID) return entry;
|
||||
|
||||
const promotionAttempts = (entry.promotionAttempts ?? 0) + 1;
|
||||
const max = entry.source === "manual"
|
||||
? PROMOTION_RETRY_LIMITS.maxManualAttempts
|
||||
: PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
|
||||
|
||||
if (promotionAttempts >= max) {
|
||||
exhausted.add(key);
|
||||
exhaustedEntries.add(`${key}\u0000${entry.pendingOwnerSessionID ?? ""}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
promotionAttempts,
|
||||
lastPromotionAttemptAt: nowIso,
|
||||
lastPromotionFailureReason: reason,
|
||||
};
|
||||
});
|
||||
|
||||
store.entries = store.entries.filter(entry => (
|
||||
!exhaustedEntries.has(`${memoryKey(entry)}\u0000${entry.pendingOwnerSessionID ?? ""}`)
|
||||
));
|
||||
return store;
|
||||
});
|
||||
|
||||
return exhausted;
|
||||
}
|
||||
+775
@@ -0,0 +1,775 @@
|
||||
/**
|
||||
* Memory V2 Plugin for OpenCode
|
||||
*
|
||||
* Architecture:
|
||||
* - Layer 1: Stable Workspace Memory (frozen per session cache epoch, refreshed at compaction)
|
||||
* - Layer 2: Hot Session State (active files, open errors, recent decisions, pending memories)
|
||||
* - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction)
|
||||
*
|
||||
* Cache Epoch Model:
|
||||
* - Each session creates a frozen workspace memory snapshot on first transform.
|
||||
* - Normal turns reuse the exact rendered string (system[1] remains stable).
|
||||
* - Compaction starts a new cache epoch: pending memories are promoted, the cache is cleared,
|
||||
* and the next transform re-renders workspace memory.
|
||||
* - Explicit memory ("remember X") goes to SessionState.pendingMemories + durable journal,
|
||||
* visible in ephemeral system[2+] for the current epoch, promoted to system[1] after compaction.
|
||||
*
|
||||
* This plugin:
|
||||
* - Caches frozen workspace memory per sessionID
|
||||
* - Processes explicit memory from latest user text once per message id
|
||||
* - Injects frozen workspace memory and dynamic hot session state into system prompt
|
||||
* - Updates session state after tool execution
|
||||
* - Augments compaction context with memory, hot state, todos, and instruction
|
||||
* - Parses compaction summaries for memory candidates and merges them
|
||||
*/
|
||||
|
||||
import { rm } from "fs/promises";
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import {
|
||||
extractExplicitMemoriesWithEvidence,
|
||||
extractActiveFiles,
|
||||
extractErrorsFromBash,
|
||||
parseWorkspaceMemoryCandidatesWithEvidence,
|
||||
} from "./extractors.ts";
|
||||
import {
|
||||
loadWorkspaceMemory,
|
||||
updateWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
accountWorkspaceMemoryRender,
|
||||
workspaceMemoryIdentityKey,
|
||||
} from "./workspace-memory.ts";
|
||||
import { reinforceMemory } from "./retention.ts";
|
||||
import {
|
||||
appendPendingMemories,
|
||||
clearPendingMemories,
|
||||
hasPendingJournalEntries,
|
||||
loadPendingJournal,
|
||||
memoryKey,
|
||||
recordPromotionRejections,
|
||||
} from "./pending-journal.ts";
|
||||
import {
|
||||
loadSessionState,
|
||||
updateSessionState,
|
||||
touchActiveFile,
|
||||
upsertOpenError,
|
||||
clearErrorsForSuccessfulCommand,
|
||||
markErrorsMaybeFixedForFile,
|
||||
addRecentDecision,
|
||||
renderHotSessionState,
|
||||
} from "./session-state.ts";
|
||||
import { sessionStatePath } from "./paths.ts";
|
||||
import {
|
||||
latestUserText,
|
||||
latestCompactionSummary,
|
||||
pendingTodos,
|
||||
} from "./opencode.ts";
|
||||
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts";
|
||||
import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Build the complete compaction prompt.
|
||||
*
|
||||
* Replaces OpenCode's default template (which uses --- separators that trigger
|
||||
* YAML frontmatter comment scope in markdown rendering, producing purple italic text).
|
||||
* Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter,
|
||||
* horizontal rules, and delimiter lines.
|
||||
*
|
||||
* @param privateContext - Background context (workspace memory, hot session state,
|
||||
* pending todos) from our plugin and any other plugins. Shown to the model to
|
||||
* inform the summary but not copied verbatim.
|
||||
*/
|
||||
function buildCompactionPrompt(privateContext: string): string {
|
||||
return [
|
||||
"Provide a detailed summary for continuing our conversation above.",
|
||||
"Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.",
|
||||
"",
|
||||
"Do not call any tools. Respond only with the summary text.",
|
||||
"Respond in the same language as the user's messages in the conversation.",
|
||||
"",
|
||||
"Formatting rules:",
|
||||
"- Start the response with \"## Goal\".",
|
||||
"- Use Markdown headings only.",
|
||||
"- Do not output YAML frontmatter.",
|
||||
"- Do not output horizontal rules.",
|
||||
"- Do not wrap the summary in delimiter lines such as ---.",
|
||||
"- Do not use code fences around the summary.",
|
||||
"",
|
||||
"Use this structure:",
|
||||
"",
|
||||
"## Goal",
|
||||
"",
|
||||
"## Instructions",
|
||||
"",
|
||||
"## Progress",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"",
|
||||
"## Discoveries",
|
||||
"",
|
||||
"## Next Steps",
|
||||
"",
|
||||
"## Relevant Files",
|
||||
"",
|
||||
"At the end of the summary, include a Memory candidates section only if there are durable facts that will change future behavior.",
|
||||
"",
|
||||
"CRITICAL MEMORY RULES:",
|
||||
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
|
||||
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
|
||||
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
|
||||
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
|
||||
"- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.",
|
||||
"- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.",
|
||||
"- If unsure, skip it.",
|
||||
"",
|
||||
"Good memory examples:",
|
||||
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
|
||||
"- [decision] Do not add semantic merge to memory dedupe.",
|
||||
"- [project] This repository is an OpenCode plugin using local JSON stores.",
|
||||
"- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.",
|
||||
"",
|
||||
"Bad memory examples to skip:",
|
||||
"- Wave 2 completed successfully.",
|
||||
"- 180 tests passed and CI is green.",
|
||||
"- Implemented owner-aware cleanup in plugin.ts.",
|
||||
"- The assistant reviewed code reviewer feedback and updated the plan.",
|
||||
"- Commit a762e86 contains the owner scope fix.",
|
||||
"",
|
||||
"Format when there ARE durable memories:",
|
||||
"Memory candidates:",
|
||||
"- [feedback|decision|project|reference] future-facing durable fact",
|
||||
"",
|
||||
"Format when there are NO durable memories:",
|
||||
"Memory candidates:",
|
||||
"(none)",
|
||||
"",
|
||||
"Background context, use this to inform the summary above.",
|
||||
"Do not output this context verbatim:",
|
||||
"",
|
||||
privateContext,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render todos for compaction context (plain text format, no Markdown headers).
|
||||
*/
|
||||
function renderTodosForCompaction(todos: Array<{ content: string; status: string; priority?: string }>): string {
|
||||
if (todos.length === 0) return "";
|
||||
const lines = ["Pending todos:"];
|
||||
for (const todo of todos) {
|
||||
const priority = todo.priority ? ` [${todo.priority}]` : "";
|
||||
const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○";
|
||||
lines.push(`- ${status} ${todo.content}${priority}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function safeErrorMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.replace(/\s+/g, " ").slice(0, 240);
|
||||
}
|
||||
|
||||
async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise<void> {
|
||||
const message = safeErrorMessage(error);
|
||||
console.error(`[memory] ${scope} failed: ${message}`);
|
||||
if (root) {
|
||||
await appendEvidenceEvent(root, {
|
||||
type: "hook_failed",
|
||||
phase: "hook",
|
||||
outcome: "failed",
|
||||
reasonCodes: [scope],
|
||||
details: { message },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
const { directory, client } = input;
|
||||
|
||||
// Cache for sub-agent detection — avoids repeated API calls per session.
|
||||
// Maps sessionID → parentID (string) or null (root session).
|
||||
const sessionParentCache = new Map<string, string | null>();
|
||||
|
||||
async function isSubAgent(sessionID: string): Promise<boolean> {
|
||||
if (sessionParentCache.has(sessionID)) {
|
||||
return sessionParentCache.get(sessionID) !== null;
|
||||
}
|
||||
try {
|
||||
const result = await client.session.get({ path: { id: sessionID } });
|
||||
const parentID = result.data?.parentID ?? null;
|
||||
sessionParentCache.set(sessionID, parentID);
|
||||
return parentID !== null;
|
||||
} catch {
|
||||
// If we can't determine, assume it's NOT a sub-agent (safe default).
|
||||
sessionParentCache.set(sessionID, null);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for frozen workspace memory per session
|
||||
const frozenWorkspaceMemoryCache = new Map<
|
||||
string,
|
||||
{
|
||||
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
|
||||
renderedPrompt: string;
|
||||
loadedAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Cache for processed user message IDs (to avoid duplicate processing)
|
||||
const processedUserMessages = new Map<string, Set<string>>();
|
||||
|
||||
function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef {
|
||||
return {
|
||||
memoryId: memory.id,
|
||||
memoryKeyHash: memoryKey(memory),
|
||||
identityKeyHash: workspaceMemoryIdentityKey(memory),
|
||||
type: memory.type,
|
||||
source: memory.source,
|
||||
status: memory.status,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingAppendedEvidence(memory: LongTermMemoryEntry): EvidenceEventInput {
|
||||
return {
|
||||
type: "pending_memory_appended",
|
||||
phase: "pending_journal",
|
||||
outcome: "accepted",
|
||||
memory: memoryEvidenceRef(memory),
|
||||
relations: [{ role: "pending", memory: memoryEvidenceRef(memory) }],
|
||||
reasonCodes: ["pending_journal_append"],
|
||||
textPreview: memory.text,
|
||||
};
|
||||
}
|
||||
|
||||
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
while (frozenWorkspaceMemoryCache.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
|
||||
const oldest = [...frozenWorkspaceMemoryCache.entries()]
|
||||
.sort((a, b) => a[1].loadedAt - b[1].loadedAt)[0]?.[0];
|
||||
if (!oldest) break;
|
||||
frozenWorkspaceMemoryCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneProcessedUserMessagesCache(): void {
|
||||
for (const [sessionID, messages] of processedUserMessages) {
|
||||
while (messages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
|
||||
const oldest = messages.values().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
messages.delete(oldest);
|
||||
}
|
||||
|
||||
if (messages.size === 0) {
|
||||
processedUserMessages.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
while (processedUserMessages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedSessionIDs) {
|
||||
const oldestSessionID = processedUserMessages.keys().next().value as string | undefined;
|
||||
if (!oldestSessionID) break;
|
||||
processedUserMessages.delete(oldestSessionID);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberProcessedUserMessage(sessionID: string, messageID: string, processedForSession: Set<string>): void {
|
||||
processedForSession.add(messageID);
|
||||
while (processedForSession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
|
||||
const oldest = processedForSession.values().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
processedForSession.delete(oldest);
|
||||
}
|
||||
|
||||
if (processedUserMessages.has(sessionID)) {
|
||||
processedUserMessages.delete(sessionID);
|
||||
}
|
||||
processedUserMessages.set(sessionID, processedForSession);
|
||||
pruneProcessedUserMessagesCache();
|
||||
}
|
||||
|
||||
async function processLatestUserMessage(sessionID: string): Promise<void> {
|
||||
const processedForSession = processedUserMessages.get(sessionID) ?? new Set<string>();
|
||||
const latestMessage = await latestUserText(client, sessionID);
|
||||
|
||||
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
|
||||
|
||||
const extraction = extractExplicitMemoriesWithEvidence(latestMessage.text);
|
||||
await appendEvidenceEvents(directory, extraction.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
messageHash: latestMessage.id,
|
||||
})));
|
||||
|
||||
const memories = extraction.entries.map(memory => ({
|
||||
...memory,
|
||||
pendingOwnerSessionID: sessionID,
|
||||
pendingMessageID: latestMessage.id,
|
||||
}));
|
||||
const decisions = memories.filter(memory => memory.type === "decision");
|
||||
|
||||
if (memories.length > 0) {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.pendingMemories.push(...memories);
|
||||
return state;
|
||||
});
|
||||
await appendPendingMemories(directory, memories);
|
||||
await appendEvidenceEvents(directory, memories.map(memory => ({
|
||||
...pendingAppendedEvidence(memory),
|
||||
sessionHash: sessionID,
|
||||
messageHash: latestMessage.id,
|
||||
})));
|
||||
}
|
||||
|
||||
if (decisions.length > 0) {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
for (const decision of decisions) {
|
||||
addRecentDecision(state, {
|
||||
text: decision.text,
|
||||
rationale: decision.rationale,
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession);
|
||||
}
|
||||
|
||||
function dedupePendingPromotionMemories(
|
||||
memories: LongTermMemoryEntry[],
|
||||
): LongTermMemoryEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: LongTermMemoryEntry[] = [];
|
||||
|
||||
for (const memory of memories) {
|
||||
const key = memoryKey(memory);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(memory);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
async function promotePendingMemories(
|
||||
sessionID?: string,
|
||||
options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const includeUnownedJournal = options.includeUnownedJournal ?? !sessionID;
|
||||
const includeOwnedJournal = options.includeOwnedJournal ?? Boolean(sessionID);
|
||||
const [journal, sessionState] = await Promise.all([
|
||||
loadPendingJournal(directory),
|
||||
sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const journalPending = journal.entries.filter(memory => {
|
||||
if (sessionID && includeOwnedJournal && memory.pendingOwnerSessionID === sessionID) return true;
|
||||
if (includeUnownedJournal && !memory.pendingOwnerSessionID) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const pending = dedupePendingPromotionMemories([
|
||||
...(sessionState?.pendingMemories ?? []),
|
||||
...journalPending,
|
||||
]);
|
||||
if (pending.length === 0) return;
|
||||
|
||||
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
|
||||
|
||||
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
|
||||
beforeEntries = [...workspaceMemory.entries];
|
||||
const existingByKey = new Map<string, { memory: typeof workspaceMemory.entries[number]; index: number }>();
|
||||
workspaceMemory.entries.forEach((memory, index) => {
|
||||
if (memory.status === "superseded") return;
|
||||
existingByKey.set(memoryKey(memory), { memory, index });
|
||||
});
|
||||
|
||||
const promotedAt = Date.now();
|
||||
for (const memory of pending) {
|
||||
const key = memoryKey(memory);
|
||||
const existing = existingByKey.get(key);
|
||||
if (existing) {
|
||||
const reinforced = reinforceMemory(
|
||||
existing.memory,
|
||||
sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion",
|
||||
promotedAt,
|
||||
);
|
||||
if (reinforced !== existing.memory) {
|
||||
workspaceMemory.entries[existing.index] = reinforced;
|
||||
existingByKey.set(key, { memory: reinforced, index: existing.index });
|
||||
}
|
||||
} else {
|
||||
workspaceMemory.entries.push({
|
||||
...memory,
|
||||
retentionClock: memory.retentionClock ?? promotedAt,
|
||||
});
|
||||
existingByKey.set(key, {
|
||||
memory: workspaceMemory.entries[workspaceMemory.entries.length - 1],
|
||||
index: workspaceMemory.entries.length - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return workspaceMemory;
|
||||
});
|
||||
|
||||
const accounting = accountPendingPromotions({
|
||||
pending,
|
||||
before: beforeEntries,
|
||||
after: updateResult.store.entries,
|
||||
events: updateResult.events,
|
||||
});
|
||||
|
||||
const exhaustedRejectedKeys = await recordPromotionRejections(
|
||||
directory,
|
||||
accounting.retryableRejectedKeys,
|
||||
"rejected_capacity",
|
||||
{
|
||||
ownerSessionID: sessionID,
|
||||
includeUnownedOnly: !sessionID,
|
||||
},
|
||||
);
|
||||
|
||||
await appendEvidenceEvents(directory, [
|
||||
...updateResult.evidence,
|
||||
...promotionAccountingEvidenceEvents({
|
||||
pending,
|
||||
after: updateResult.store.entries,
|
||||
events: updateResult.events,
|
||||
accounting,
|
||||
exhaustedRejectedKeys,
|
||||
}),
|
||||
].map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
|
||||
const sessionRemovalKeys = new Set([
|
||||
...accounting.clearableKeys,
|
||||
...exhaustedRejectedKeys,
|
||||
]);
|
||||
|
||||
if (sessionID) {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.pendingMemories = state.pendingMemories.filter(memory => {
|
||||
const key = memoryKey(memory);
|
||||
if (!sessionRemovalKeys.has(key)) return true;
|
||||
|
||||
if (accounting.clearableKeys.has(key)) return false;
|
||||
if (exhaustedRejectedKeys.has(key)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
return state;
|
||||
});
|
||||
clearFrozenWorkspaceMemoryCache(sessionID);
|
||||
}
|
||||
|
||||
if (accounting.clearableKeys.size > 0) {
|
||||
await clearPendingMemories(directory, accounting.clearableKeys, {
|
||||
ownerSessionID: sessionID,
|
||||
clearUnowned: !sessionID || includeUnownedJournal === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bashExitCode(hookOutput: unknown): number | undefined {
|
||||
const output = hookOutput as {
|
||||
exitCode?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
output?: string;
|
||||
};
|
||||
const candidates = [
|
||||
output.exitCode,
|
||||
output.metadata?.exitCode,
|
||||
output.metadata?.exit_code,
|
||||
output.metadata?.code,
|
||||
output.metadata?.status,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "number") return candidate;
|
||||
if (typeof candidate === "string" && /^-?\d+$/.test(candidate)) return Number(candidate);
|
||||
}
|
||||
const text = output.output ?? "";
|
||||
const match = text.match(/(?:exit\s*code|exitCode|status)[:=]\s*(-?\d+)/i);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get frozen workspace memory snapshot for a session.
|
||||
* Loads and renders from disk once per session, then reuses the exact rendered string.
|
||||
*/
|
||||
async function getFrozenWorkspaceMemorySnapshot(
|
||||
root: string,
|
||||
sessionID: string
|
||||
): Promise<{
|
||||
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
|
||||
renderedPrompt: string;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
const cached = frozenWorkspaceMemoryCache.get(sessionID);
|
||||
|
||||
// Cache is valid for the current session cache epoch.
|
||||
// It is intentionally invalidated after compaction so promoted memories
|
||||
// become visible in the next compacted context (new epoch starts).
|
||||
if (cached) {
|
||||
return { store: cached.store, renderedPrompt: cached.renderedPrompt };
|
||||
}
|
||||
|
||||
const store = await loadWorkspaceMemory(root);
|
||||
const renderAccounting = accountWorkspaceMemoryRender(store);
|
||||
const renderedPrompt = renderAccounting.prompt;
|
||||
await appendEvidenceEvents(root, renderAccounting.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
return { store, renderedPrompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear frozen workspace memory cache (e.g., after compaction).
|
||||
*/
|
||||
function clearFrozenWorkspaceMemoryCache(sessionID: string): void {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
}
|
||||
|
||||
function sessionIDFromEventProperties(properties: unknown): string | undefined {
|
||||
const props = properties as { sessionID?: string; info?: { id?: string } } | undefined;
|
||||
return props?.sessionID ?? props?.info?.id;
|
||||
}
|
||||
|
||||
return {
|
||||
// Inject workspace memory and hot session state into system prompt
|
||||
"experimental.chat.system.transform": async (hookInput, output) => {
|
||||
const { sessionID } = hookInput;
|
||||
if (!sessionID) return;
|
||||
|
||||
try {
|
||||
pruneFrozenWorkspaceMemoryCache();
|
||||
pruneProcessedUserMessagesCache();
|
||||
|
||||
// Sub-agents are short-lived - skip memory system
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Process explicit user memory even on no-tool turns. Keep this after the
|
||||
// sub-agent guard so child sessions never append to the parent journal.
|
||||
await processLatestUserMessage(sessionID);
|
||||
|
||||
// Before first snapshot in this session, promote durable unowned backlog from
|
||||
// prior sessions. Current-turn owned explicit memory remains pending and only
|
||||
// appears in hot state for this transform.
|
||||
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
||||
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
|
||||
}
|
||||
|
||||
// Get frozen workspace memory snapshot (loaded and rendered once per session)
|
||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
|
||||
// Get current hot session state
|
||||
const sessionState = await loadSessionState(directory, sessionID);
|
||||
|
||||
// Inject frozen workspace memory snapshot
|
||||
if (workspaceSnapshot.renderedPrompt) {
|
||||
output.system.push(workspaceSnapshot.renderedPrompt);
|
||||
}
|
||||
|
||||
// Render and inject hot session state
|
||||
const hotPrompt = renderHotSessionState(sessionState, directory);
|
||||
if (hotPrompt) {
|
||||
output.system.push(hotPrompt);
|
||||
}
|
||||
} catch (error) {
|
||||
await warnMemoryHook("chat.system.transform", error, directory);
|
||||
}
|
||||
},
|
||||
|
||||
// Track tool usage and update session state
|
||||
"tool.execute.after": async (hookInput, hookOutput) => {
|
||||
const { sessionID, tool: toolName, args } = hookInput;
|
||||
const { output: toolOutput } = hookOutput;
|
||||
if (!sessionID) return;
|
||||
|
||||
// Sub-agents don't need memory tracking
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
try {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
// Track active files from tool usage
|
||||
if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") {
|
||||
const files = extractActiveFiles(
|
||||
toolName,
|
||||
args as Record<string, unknown>,
|
||||
toolOutput ?? ""
|
||||
);
|
||||
for (const { path, action } of files) {
|
||||
touchActiveFile(state, path, action);
|
||||
if (action === "edit" || action === "write") {
|
||||
markErrorsMaybeFixedForFile(state, path, directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track errors from failed bash commands
|
||||
if (toolName === "bash") {
|
||||
const argsRecord = args as Record<string, unknown>;
|
||||
const command: string = typeof argsRecord?.command === "string"
|
||||
? argsRecord.command
|
||||
: "";
|
||||
const outputText: string = toolOutput ?? "";
|
||||
|
||||
// Check if command succeeded - clear errors for that category
|
||||
const exitCode = bashExitCode(hookOutput);
|
||||
if (typeof exitCode !== "number") {
|
||||
// Unknown exit status: do not extract and do not clear
|
||||
} else if (exitCode === 0 && command) {
|
||||
clearErrorsForSuccessfulCommand(state, command);
|
||||
} else if (command) {
|
||||
// Only extract errors for commands with explicit non-zero exit
|
||||
const errors = extractErrorsFromBash(command, outputText);
|
||||
for (const error of errors) {
|
||||
upsertOpenError(state, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
});
|
||||
|
||||
// Process explicit memory from latest user message
|
||||
// Only process once per message ID
|
||||
await processLatestUserMessage(sessionID);
|
||||
} catch (error) {
|
||||
await warnMemoryHook("tool.execute.after", error, directory);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the default compaction prompt with a ---free template.
|
||||
*
|
||||
* OpenCode's default template wraps sections in --- separators. When the
|
||||
* model follows the template (which our structured context encourages),
|
||||
* the TUI renders --- at position 0 as YAML frontmatter, applying the
|
||||
* "comment" syntax scope (purple italic in palenight theme).
|
||||
*
|
||||
* We set output.prompt to replace the entire prompt, removing all ---
|
||||
* and explicitly forbidding YAML frontmatter / horizontal rules.
|
||||
*/
|
||||
"experimental.session.compacting": async (hookInput, output) => {
|
||||
const { sessionID } = hookInput;
|
||||
if (!sessionID) return;
|
||||
|
||||
try {
|
||||
// Sub-agents don't need compaction support
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Preserve context injected by other plugins that ran before us.
|
||||
// Setting output.prompt bypasses the default prompt + context join,
|
||||
// so we must explicitly carry forward any existing output.context.
|
||||
const otherContext = output.context.filter(Boolean).join("\n\n");
|
||||
|
||||
// Build our private context (workspace memory, hot state, todos)
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. Frozen workspace memory snapshot
|
||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
if (workspaceSnapshot.renderedPrompt) {
|
||||
contextParts.push(workspaceSnapshot.renderedPrompt);
|
||||
}
|
||||
|
||||
// 2. Hot session state
|
||||
const sessionState = await loadSessionState(directory, sessionID);
|
||||
const hotPrompt = renderHotSessionState(sessionState, directory);
|
||||
if (hotPrompt) {
|
||||
contextParts.push(hotPrompt);
|
||||
}
|
||||
|
||||
// 3. Pending todos from OpenCode
|
||||
const todos = await pendingTodos(client, sessionID);
|
||||
const todosPrompt = renderTodosForCompaction(todos);
|
||||
if (todosPrompt) {
|
||||
contextParts.push(todosPrompt);
|
||||
}
|
||||
|
||||
// Combine: other plugins' context first, then our private context
|
||||
const privateContext = [otherContext, ...contextParts]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
// Replace the default prompt entirely with our ---free template
|
||||
output.prompt = buildCompactionPrompt(privateContext);
|
||||
|
||||
// Clear context array since we consumed it into output.prompt.
|
||||
// Subsequent plugins that set output.prompt will also need to check
|
||||
// output.context if they want to preserve other plugin contributions.
|
||||
output.context.length = 0;
|
||||
} catch (error) {
|
||||
await warnMemoryHook("session.compacting", error, directory);
|
||||
}
|
||||
},
|
||||
|
||||
// Handle session events
|
||||
event: async ({ event }) => {
|
||||
if (event.type === "session.compacted") {
|
||||
try {
|
||||
const sessionID = sessionIDFromEventProperties(event.properties);
|
||||
if (!sessionID) return;
|
||||
|
||||
// Sub-agents don't need post-compaction processing
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Parse latest compaction summary for memory candidates, stage them into
|
||||
// durable pending journal, then promote pending memories.
|
||||
const summary = await latestCompactionSummary(client, sessionID);
|
||||
const parseResult = summary ? parseWorkspaceMemoryCandidatesWithEvidence(summary) : { entries: [], evidence: [] };
|
||||
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
const candidates = parseResult.entries;
|
||||
if (candidates.length > 0) {
|
||||
await appendPendingMemories(directory, candidates);
|
||||
await appendEvidenceEvents(directory, candidates.map(memory => ({
|
||||
...pendingAppendedEvidence(memory),
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
}
|
||||
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
} catch (error) {
|
||||
// Keep pending memories in session/journal for retry on next event/session.
|
||||
await warnMemoryHook("event.session.compacted", error, directory);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
try {
|
||||
const sessionID = sessionIDFromEventProperties(event.properties);
|
||||
if (sessionID) {
|
||||
// Promote pending memories before deleting per-session state.
|
||||
// If promotion fails, leave session state and journal intact.
|
||||
let promoted = false;
|
||||
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
|
||||
promoted = true;
|
||||
if (promoted) {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
processedUserMessages.delete(sessionID);
|
||||
sessionParentCache.delete(sessionID);
|
||||
}
|
||||
|
||||
await rm(await sessionStatePath(directory, sessionID), { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
await warnMemoryHook("event.session.deleted", error, directory);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { LongTermMemoryEntry } from "./types.ts";
|
||||
import { memoryKey } from "./pending-journal.ts";
|
||||
import type { MemoryConsolidationEvent } from "./workspace-memory.ts";
|
||||
import { workspaceMemoryIdentityKey } from "./workspace-memory.ts";
|
||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
|
||||
export type PendingPromotionAccounting = {
|
||||
promotedKeys: Set<string>;
|
||||
absorbedKeys: Set<string>;
|
||||
supersededKeys: Set<string>;
|
||||
rejectedKeys: Set<string>;
|
||||
retryableRejectedKeys: Set<string>;
|
||||
clearableKeys: Set<string>;
|
||||
};
|
||||
|
||||
export function accountPendingPromotions(input: {
|
||||
pending: LongTermMemoryEntry[];
|
||||
before: LongTermMemoryEntry[];
|
||||
after: LongTermMemoryEntry[];
|
||||
events?: MemoryConsolidationEvent[];
|
||||
}): PendingPromotionAccounting {
|
||||
const beforeActive = input.before.filter(entry => entry.status !== "superseded");
|
||||
const afterActive = input.after.filter(entry => entry.status !== "superseded");
|
||||
const beforeExactKeys = new Set(beforeActive.map(entry => memoryKey(entry)));
|
||||
const afterExactKeys = new Set(afterActive.map(entry => memoryKey(entry)));
|
||||
const afterIdentityKeys = new Set(afterActive.map(entry => workspaceMemoryIdentityKey(entry)));
|
||||
const terminalEventByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event]));
|
||||
|
||||
const promotedKeys = new Set<string>();
|
||||
const absorbedKeys = new Set<string>();
|
||||
const supersededKeys = new Set<string>();
|
||||
const rejectedKeys = new Set<string>();
|
||||
|
||||
for (const memory of input.pending) {
|
||||
const key = memoryKey(memory);
|
||||
const identityKey = workspaceMemoryIdentityKey(memory);
|
||||
|
||||
if (beforeExactKeys.has(key)) {
|
||||
absorbedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (afterExactKeys.has(key)) {
|
||||
promotedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
const terminal = terminalEventByKey.get(key);
|
||||
if (terminal) {
|
||||
if (
|
||||
terminal.reason === "absorbed_exact" ||
|
||||
terminal.reason === "absorbed_identity"
|
||||
) {
|
||||
absorbedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (terminal.reason === "superseded_existing") {
|
||||
supersededKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (terminal.reason === "rejected_capacity") {
|
||||
rejectedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (afterIdentityKeys.has(identityKey)) {
|
||||
absorbedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
rejectedKeys.add(key);
|
||||
}
|
||||
|
||||
const clearableKeys = new Set([
|
||||
...promotedKeys,
|
||||
...absorbedKeys,
|
||||
...supersededKeys,
|
||||
...input.pending
|
||||
.filter(memory => {
|
||||
const terminal = terminalEventByKey.get(memoryKey(memory));
|
||||
return memory.source === "compaction" && terminal?.reason === "rejected_capacity";
|
||||
})
|
||||
.map(memory => memoryKey(memory)),
|
||||
]);
|
||||
|
||||
const retryableRejectedKeys = new Set(
|
||||
input.pending
|
||||
.filter(memory => {
|
||||
const key = memoryKey(memory);
|
||||
return rejectedKeys.has(key) &&
|
||||
!clearableKeys.has(key) &&
|
||||
(memory.source === "explicit" || memory.source === "manual");
|
||||
})
|
||||
.map(memory => memoryKey(memory)),
|
||||
);
|
||||
|
||||
return {
|
||||
promotedKeys,
|
||||
absorbedKeys,
|
||||
supersededKeys,
|
||||
rejectedKeys,
|
||||
retryableRejectedKeys,
|
||||
clearableKeys,
|
||||
};
|
||||
}
|
||||
|
||||
function memoryRef(memory: LongTermMemoryEntry | undefined): MemoryEvidenceRef | undefined {
|
||||
if (!memory) return undefined;
|
||||
return {
|
||||
memoryId: memory.id,
|
||||
memoryKeyHash: memoryKey(memory),
|
||||
identityKeyHash: workspaceMemoryIdentityKey(memory),
|
||||
type: memory.type,
|
||||
source: memory.source,
|
||||
status: memory.status,
|
||||
};
|
||||
}
|
||||
|
||||
function retainedMemoryFor(
|
||||
pending: LongTermMemoryEntry,
|
||||
event: MemoryConsolidationEvent | undefined,
|
||||
after: LongTermMemoryEntry[],
|
||||
): LongTermMemoryEntry | undefined {
|
||||
if (event?.retainedId) {
|
||||
const byId = after.find(memory => memory.id === event.retainedId);
|
||||
if (byId) return byId;
|
||||
}
|
||||
|
||||
const exactKey = memoryKey(pending);
|
||||
const identityKey = workspaceMemoryIdentityKey(pending);
|
||||
return after.find(memory => memory.status !== "superseded" && (
|
||||
memoryKey(memory) === exactKey || workspaceMemoryIdentityKey(memory) === identityKey
|
||||
));
|
||||
}
|
||||
|
||||
function promotionEventBase(
|
||||
type: EvidenceEventInput["type"],
|
||||
outcome: EvidenceEventInput["outcome"],
|
||||
memory: LongTermMemoryEntry,
|
||||
reasonCodes: string[],
|
||||
): EvidenceEventInput {
|
||||
return {
|
||||
type,
|
||||
phase: "promotion",
|
||||
outcome,
|
||||
memory: memoryRef(memory),
|
||||
reasonCodes,
|
||||
textPreview: memory.text,
|
||||
};
|
||||
}
|
||||
|
||||
export function promotionAccountingEvidenceEvents(input: {
|
||||
pending: LongTermMemoryEntry[];
|
||||
after: LongTermMemoryEntry[];
|
||||
events?: MemoryConsolidationEvent[];
|
||||
accounting: PendingPromotionAccounting;
|
||||
exhaustedRejectedKeys?: Set<string>;
|
||||
}): EvidenceEventInput[] {
|
||||
const terminalByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event]));
|
||||
const exhaustedRejectedKeys = input.exhaustedRejectedKeys ?? new Set<string>();
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
for (const pending of input.pending) {
|
||||
const key = memoryKey(pending);
|
||||
const terminal = terminalByKey.get(key);
|
||||
const retained = retainedMemoryFor(pending, terminal, input.after);
|
||||
|
||||
if (input.accounting.promotedKeys.has(key)) {
|
||||
evidence.push({
|
||||
...promotionEventBase("promotion_promoted", "promoted", pending, ["new_workspace_entry"]),
|
||||
relations: [
|
||||
{ role: "promoted", memory: memoryRef(retained ?? pending) },
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.accounting.absorbedKeys.has(key)) {
|
||||
const exact = terminal?.reason !== "absorbed_identity";
|
||||
evidence.push({
|
||||
...promotionEventBase(
|
||||
exact ? "promotion_absorbed_exact" : "promotion_absorbed_identity",
|
||||
"absorbed",
|
||||
pending,
|
||||
[exact ? "same_exact_key" : "same_identity_key"],
|
||||
),
|
||||
relations: [
|
||||
{ role: "absorbed" as const, memory: memoryRef(pending) },
|
||||
{ role: "retained" as const, memory: memoryRef(retained) },
|
||||
].filter(relation => relation.memory),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.accounting.supersededKeys.has(key)) {
|
||||
evidence.push({
|
||||
...promotionEventBase("promotion_superseded", "superseded", pending, ["superseded_existing"]),
|
||||
relations: [
|
||||
{ role: "superseded" as const, memory: memoryRef(pending) },
|
||||
{ role: "superseded_by" as const, memory: memoryRef(retained) },
|
||||
].filter(relation => relation.memory),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.accounting.rejectedKeys.has(key)) {
|
||||
evidence.push(promotionEventBase("promotion_rejected_capacity", "rejected", pending, ["capacity_rejected"]));
|
||||
if (input.accounting.retryableRejectedKeys.has(key)) {
|
||||
evidence.push(promotionEventBase(
|
||||
exhaustedRejectedKeys.has(key) ? "promotion_retry_exhausted" : "promotion_retry_scheduled",
|
||||
exhaustedRejectedKeys.has(key) ? "exhausted" : "retried",
|
||||
pending,
|
||||
[exhaustedRejectedKeys.has(key) ? "max_attempts_reached" : "retryable_capacity_rejection"],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shared redaction utilities for sensitive credential patterns.
|
||||
* Used by both workspace memory normalization and extraction rejection logging.
|
||||
*/
|
||||
|
||||
// Password labels in multiple languages
|
||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||
|
||||
// Username labels in multiple languages
|
||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||
|
||||
// Sensitive key labels
|
||||
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
|
||||
|
||||
// Secret value pattern (excludes common delimiters and brackets)
|
||||
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
||||
|
||||
// Prefix patterns for different credential types
|
||||
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
|
||||
const BEARER_PREFIX = String.raw`(Bearer\s+)`;
|
||||
|
||||
/**
|
||||
* Redacts sensitive credentials from text.
|
||||
* Handles:
|
||||
* - PINs in multiple formats
|
||||
* - Username/password pairs
|
||||
* - Standalone passwords
|
||||
* - Bearer tokens
|
||||
* - API keys, secrets, credentials, auth tokens, private keys
|
||||
*
|
||||
* Supports multiple languages and delimiters (ASCII and CJK).
|
||||
*/
|
||||
export function redactCredentials(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// 1. PIN
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 2. Username+password pair
|
||||
result = result.replace(
|
||||
new RegExp(
|
||||
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
|
||||
"gi",
|
||||
),
|
||||
"$1[REDACTED]$3$4[REDACTED]",
|
||||
);
|
||||
|
||||
// 3. Standalone password
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 4. Bearer tokens (but not "bearer token:" labels)
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 5. Sensitive keys/tokens
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
|
||||
// Retention decay model constants (v1.5)
|
||||
export const BASE_HALF_LIFE_DAYS = 45;
|
||||
export const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
||||
export const REINFORCEMENT_MAX_COUNT = 6;
|
||||
export const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
||||
export const DORMANT_DECAY_MULTIPLIER = 0.25;
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const TYPE_FACTOR = {
|
||||
reference: 1.0,
|
||||
project: 1.25,
|
||||
feedback: 2.25,
|
||||
decision: 2.5,
|
||||
} as const;
|
||||
|
||||
export const SOURCE_FACTOR = {
|
||||
compaction: 1.0,
|
||||
manual: 1.4,
|
||||
explicit: 2.0,
|
||||
} as const;
|
||||
|
||||
export const USER_IMPORTANCE_FACTOR = {
|
||||
low: 0.7,
|
||||
normal: 1.0,
|
||||
high: 1.5,
|
||||
} as const;
|
||||
|
||||
export const RETENTION_TYPE_MAX = {
|
||||
feedback: 10,
|
||||
decision: 10,
|
||||
project: 8,
|
||||
reference: 6,
|
||||
} as const;
|
||||
|
||||
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
|
||||
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
|
||||
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
|
||||
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
|
||||
|
||||
return typeFactor * sourceFactor * importanceFactor;
|
||||
}
|
||||
|
||||
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
|
||||
const reinforcementCount = Math.min(
|
||||
memory.reinforcementCount ?? 0,
|
||||
REINFORCEMENT_MAX_COUNT,
|
||||
);
|
||||
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
|
||||
return BASE_HALF_LIFE_DAYS / factor;
|
||||
}
|
||||
|
||||
function timestampMs(value: unknown, fallback: number): number {
|
||||
const ms = typeof value === "number" ? value : new Date(String(value)).getTime();
|
||||
return Number.isFinite(ms) ? ms : fallback;
|
||||
}
|
||||
|
||||
export function calculateRetentionStrength(
|
||||
memory: LongTermMemoryEntry,
|
||||
now: number,
|
||||
lastActivityAt?: string,
|
||||
): number {
|
||||
const initialStrength = calculateInitialStrength(memory);
|
||||
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
|
||||
|
||||
// Use retentionClock if available, fallback to updatedAt.
|
||||
const retentionStart = Number.isFinite(memory.retentionClock)
|
||||
? memory.retentionClock
|
||||
: memory.updatedAt ?? memory.createdAt;
|
||||
const createdAtMs = timestampMs(retentionStart, now);
|
||||
const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt);
|
||||
|
||||
// Calculate strength using exponential decay.
|
||||
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
|
||||
|
||||
return Number.isFinite(strength) ? Math.max(0, strength) : 0;
|
||||
}
|
||||
|
||||
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
|
||||
const lastActivity = store.lastActivityAt
|
||||
? new Date(store.lastActivityAt).getTime()
|
||||
: now;
|
||||
if (!Number.isFinite(lastActivity)) return 0;
|
||||
|
||||
const daysSinceActivity = (now - lastActivity) / DAY_MS;
|
||||
return Math.max(0, daysSinceActivity);
|
||||
}
|
||||
|
||||
export function calculateEffectiveAgeDays(
|
||||
entryStartMs: number,
|
||||
now: number,
|
||||
lastActivityAt?: string,
|
||||
): number {
|
||||
const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS);
|
||||
|
||||
if (!lastActivityAt) return wallAgeDays;
|
||||
|
||||
const lastActivityMs = new Date(lastActivityAt).getTime();
|
||||
if (!Number.isFinite(lastActivityMs)) return wallAgeDays;
|
||||
|
||||
const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS;
|
||||
const overlapStartMs = Math.max(entryStartMs, dormantStartMs);
|
||||
const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS);
|
||||
const activeDays = wallAgeDays - dormantOverlapDays;
|
||||
|
||||
return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER;
|
||||
}
|
||||
|
||||
function isSameUTCCalendarDay(ts1: number, ts2: number): boolean {
|
||||
const d1 = new Date(ts1);
|
||||
const d2 = new Date(ts2);
|
||||
return d1.getUTCFullYear() === d2.getUTCFullYear()
|
||||
&& d1.getUTCMonth() === d2.getUTCMonth()
|
||||
&& d1.getUTCDate() === d2.getUTCDate();
|
||||
}
|
||||
|
||||
export function reinforceMemory(
|
||||
memory: LongTermMemoryEntry,
|
||||
sessionId: string,
|
||||
now: number,
|
||||
): LongTermMemoryEntry {
|
||||
if (memory.lastReinforcedSessionID === sessionId) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
// Calendar-day diversity gate (OQ-2): same UTC day = no reinforcement.
|
||||
if (memory.lastReinforcedAt && isSameUTCCalendarDay(memory.lastReinforcedAt, now)) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
|
||||
return memory;
|
||||
}
|
||||
|
||||
return {
|
||||
...memory,
|
||||
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
|
||||
lastReinforcedAt: now,
|
||||
lastReinforcedSessionID: sessionId,
|
||||
retentionClock: now,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { relative } from "path";
|
||||
import { sessionStatePath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts";
|
||||
import { HOT_STATE_LIMITS } from "./types.ts";
|
||||
import { memoryKey } from "./pending-journal.ts";
|
||||
|
||||
const ACTION_WEIGHT: Record<ActiveFile["action"], number> = {
|
||||
edit: 50,
|
||||
write: 45,
|
||||
grep: 30,
|
||||
read: 20,
|
||||
};
|
||||
|
||||
export function createEmptySessionState(sessionID: string): SessionState {
|
||||
return {
|
||||
version: 1,
|
||||
sessionID,
|
||||
turn: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
activeFiles: [],
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadSessionState(root: string, sessionID: string): Promise<SessionState> {
|
||||
const fallback = createEmptySessionState(sessionID);
|
||||
const loaded = await readJSON(await sessionStatePath(root, sessionID), () => fallback);
|
||||
loaded.sessionID = sessionID;
|
||||
loaded.activeFiles = Array.isArray(loaded.activeFiles) ? loaded.activeFiles : [];
|
||||
loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : [];
|
||||
loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : [];
|
||||
loaded.pendingMemories = Array.isArray(loaded.pendingMemories) ? loaded.pendingMemories : [];
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export async function saveSessionState(root: string, state: SessionState): Promise<void> {
|
||||
await atomicWriteJSON(await sessionStatePath(root, state.sessionID), normalizeSessionState(state));
|
||||
}
|
||||
|
||||
export async function updateSessionState(
|
||||
root: string,
|
||||
sessionID: string,
|
||||
updater: (state: SessionState) => SessionState | Promise<SessionState>,
|
||||
): Promise<SessionState> {
|
||||
const path = await sessionStatePath(root, sessionID);
|
||||
return updateJSON(path, () => createEmptySessionState(sessionID), async current => {
|
||||
current.sessionID = sessionID;
|
||||
current.activeFiles = Array.isArray(current.activeFiles) ? current.activeFiles : [];
|
||||
current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : [];
|
||||
current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : [];
|
||||
current.pendingMemories = Array.isArray(current.pendingMemories) ? current.pendingMemories : [];
|
||||
return normalizeSessionState(await updater(current));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSessionState(state: SessionState): SessionState {
|
||||
state.updatedAt = new Date().toISOString();
|
||||
state.activeFiles = state.activeFiles.slice(0, HOT_STATE_LIMITS.maxActiveFilesStored);
|
||||
state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored);
|
||||
state.recentDecisions = state.recentDecisions.slice(0, HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
||||
state.pendingMemories = dedupePendingMemories(Array.isArray(state.pendingMemories) ? state.pendingMemories : [])
|
||||
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesStored);
|
||||
return state;
|
||||
}
|
||||
|
||||
function dedupePendingMemories(memories: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: LongTermMemoryEntry[] = [];
|
||||
for (const memory of memories) {
|
||||
const key = memoryKey(memory);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(memory);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function touchActiveFile(state: SessionState, filePath: string, action: ActiveFile["action"]): void {
|
||||
const now = Date.now();
|
||||
const existing = state.activeFiles.find(item => item.path === filePath);
|
||||
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.lastSeen = now;
|
||||
if (ACTION_WEIGHT[action] >= ACTION_WEIGHT[existing.action]) {
|
||||
existing.action = action;
|
||||
}
|
||||
} else {
|
||||
state.activeFiles.push({
|
||||
path: filePath,
|
||||
action,
|
||||
count: 1,
|
||||
lastSeen: now,
|
||||
});
|
||||
}
|
||||
|
||||
state.activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesStored);
|
||||
state.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
export function upsertOpenError(state: SessionState, error: OpenError): void {
|
||||
const now = Date.now();
|
||||
const existing = state.openErrors.find(item => item.fingerprint === error.fingerprint);
|
||||
|
||||
if (existing) {
|
||||
existing.summary = error.summary;
|
||||
existing.command = error.command ?? existing.command;
|
||||
existing.file = error.file ?? existing.file;
|
||||
existing.lastSeen = now;
|
||||
existing.status = "open";
|
||||
existing.seenCount += 1;
|
||||
} else {
|
||||
state.openErrors.unshift({
|
||||
...error,
|
||||
firstSeen: error.firstSeen ?? now,
|
||||
lastSeen: now,
|
||||
seenCount: Math.max(error.seenCount ?? 1, 1),
|
||||
status: "open",
|
||||
});
|
||||
}
|
||||
|
||||
state.openErrors.sort((a, b) => b.lastSeen - a.lastSeen);
|
||||
state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored);
|
||||
state.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
export function markErrorsMaybeFixedForFile(
|
||||
state: SessionState,
|
||||
filePath: string,
|
||||
workspaceRoot = "",
|
||||
): void {
|
||||
const candidates = new Set<string>([filePath]);
|
||||
if (workspaceRoot && filePath.startsWith(workspaceRoot)) {
|
||||
candidates.add(relative(workspaceRoot, filePath));
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (const error of state.openErrors) {
|
||||
if (error.status !== "open") continue;
|
||||
if (!error.file) continue;
|
||||
for (const candidate of candidates) {
|
||||
if (pathsMatch(error.file, candidate)) {
|
||||
error.status = "maybe_fixed";
|
||||
error.lastSeen = Date.now();
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) state.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
export function addRecentDecision(
|
||||
state: SessionState,
|
||||
decision: Pick<SessionDecision, "text" | "source" | "rationale">,
|
||||
): void {
|
||||
const normalized = decision.text.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
const existing = state.recentDecisions.find(item => (
|
||||
item.text.toLowerCase().replace(/\s+/g, " ").trim() === normalized
|
||||
));
|
||||
const now = Date.now();
|
||||
|
||||
if (existing) {
|
||||
existing.createdAt = now;
|
||||
existing.rationale = decision.rationale ?? existing.rationale;
|
||||
existing.source = decision.source;
|
||||
} else {
|
||||
state.recentDecisions.push({
|
||||
id: `decision_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
text: decision.text,
|
||||
rationale: decision.rationale,
|
||||
source: decision.source,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
state.recentDecisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
||||
state.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
export function clearErrorsForSuccessfulCommand(state: SessionState, command: string): void {
|
||||
const category = classifyCommand(command);
|
||||
if (!category) return;
|
||||
state.openErrors = state.openErrors.filter(error => error.category !== category);
|
||||
state.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
|
||||
const activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesRendered);
|
||||
const openErrors = [...state.openErrors]
|
||||
.sort((a, b) => b.lastSeen - a.lastSeen)
|
||||
.slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered);
|
||||
const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
||||
const pendingMemories = dedupePendingMemories(state.pendingMemories)
|
||||
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesRendered);
|
||||
|
||||
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0 && pendingMemories.length === 0) return "";
|
||||
|
||||
const lines: string[] = ["Hot session state (current session):"];
|
||||
|
||||
if (activeFiles.length > 0) {
|
||||
lines.push("active_files:");
|
||||
for (const item of activeFiles) {
|
||||
const viewPath = displayPath(workspaceRoot, item.path);
|
||||
lines.push(`- ${viewPath} (${item.action}, ${item.count}x)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (openErrors.length > 0) {
|
||||
lines.push("open_errors:");
|
||||
for (const err of openErrors) {
|
||||
lines.push(`- [${err.category}] ${err.summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (decisions.length > 0) {
|
||||
lines.push("recent_decisions:");
|
||||
for (const decision of decisions) {
|
||||
lines.push(`- ${decision.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingMemories.length > 0) {
|
||||
lines.push("pending_memories:");
|
||||
for (const memory of pendingMemories) {
|
||||
lines.push(`- [${memory.type}] ${memory.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
|
||||
}
|
||||
|
||||
function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] {
|
||||
return [...activeFiles].sort((a, b) => {
|
||||
const scoreA = ACTION_WEIGHT[a.action] + a.count * 3;
|
||||
const scoreB = ACTION_WEIGHT[b.action] + b.count * 3;
|
||||
if (scoreA !== scoreB) return scoreB - scoreA;
|
||||
return b.lastSeen - a.lastSeen;
|
||||
});
|
||||
}
|
||||
|
||||
function displayPath(workspaceRoot: string, filePath: string): string {
|
||||
if (!workspaceRoot || !filePath.startsWith(workspaceRoot)) return filePath;
|
||||
return relative(workspaceRoot, filePath) || ".";
|
||||
}
|
||||
|
||||
function pathsMatch(errorFile: string, touchedFile: string): boolean {
|
||||
const normalizedError = errorFile.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
const normalizedTouched = touchedFile.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
return normalizedError === normalizedTouched
|
||||
|| normalizedTouched.endsWith(`/${normalizedError}`)
|
||||
|| normalizedError.endsWith(`/${normalizedTouched}`);
|
||||
}
|
||||
|
||||
function classifyCommand(command: string): OpenError["category"] | null {
|
||||
const c = command.toLowerCase();
|
||||
if (/\b(tsc|typecheck)\b/.test(c)) return "typecheck";
|
||||
if (/\b(test|vitest|jest|mocha|pytest|go test|cargo test)\b/.test(c)) return "test";
|
||||
if (/\b(lint|eslint|biome)\b/.test(c)) return "lint";
|
||||
if (/\b(build|vite build|webpack|tsup)\b/.test(c)) return "build";
|
||||
return null;
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { existsSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
||||
import type { FileHandle } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
import { appendEvidenceEventForWorkspaceKey } from "./evidence-log.ts";
|
||||
|
||||
const fileLocks = new Map<string, Promise<unknown>>();
|
||||
const LOCK_WAIT_TIMEOUT_MS = 5000;
|
||||
const LOCK_STALE_MS = 30_000;
|
||||
const LOCK_HEARTBEAT_MS = 1_000;
|
||||
|
||||
function workspaceKeyFromStorePath(path: string): string | undefined {
|
||||
return path.match(/[\\/]opencode-working-memory[\\/]workspaces[\\/]([a-f0-9]{16})[\\/]/i)?.[1];
|
||||
}
|
||||
|
||||
function storeKindFromPath(path: string): string {
|
||||
if (path.endsWith("workspace-memory.json")) return "workspace_memory";
|
||||
if (path.endsWith("workspace-pending-journal.json")) return "pending_journal";
|
||||
if (path.includes(`${"/"}sessions${"/"}`) || path.includes(`${"\\"}sessions${"\\"}`)) return "session_state";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
async function emitStorageEvidence(path: string, event: Parameters<typeof appendEvidenceEventForWorkspaceKey>[1]): Promise<void> {
|
||||
const key = workspaceKeyFromStorePath(path);
|
||||
if (!key) return;
|
||||
await appendEvidenceEventForWorkspaceKey(key, event).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function quarantineCorruptJSON(path: string): Promise<string | null> {
|
||||
const quarantinePath = `${path}.corrupt-${Date.now()}-${process.pid}-${randomUUID()}`;
|
||||
|
||||
try {
|
||||
await rename(path, quarantinePath);
|
||||
return quarantinePath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
||||
if (!existsSync(path)) return fallback();
|
||||
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const quarantinePath = await quarantineCorruptJSON(path);
|
||||
|
||||
if (quarantinePath) {
|
||||
console.error(`[memory] invalid JSON in ${path}; quarantined to ${quarantinePath}: ${message}`);
|
||||
await emitStorageEvidence(path, {
|
||||
type: "storage_corrupt_json_quarantined",
|
||||
phase: "storage",
|
||||
outcome: "quarantined",
|
||||
reasonCodes: ["invalid_json"],
|
||||
details: {
|
||||
storeKind: storeKindFromPath(path),
|
||||
quarantined: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error(`[memory] invalid JSON in ${path}; using fallback without quarantine: ${message}`);
|
||||
}
|
||||
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
async function readJSONStrict<T>(path: string, fallback: () => T): Promise<T> {
|
||||
if (!existsSync(path)) return fallback();
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON in ${path}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function isLockStale(lockPath: string, now = Date.now()): Promise<boolean> {
|
||||
try {
|
||||
const stats = await stat(lockPath);
|
||||
|
||||
if (now - stats.mtimeMs > LOCK_STALE_MS) return true;
|
||||
|
||||
const content = await readFile(lockPath, "utf8");
|
||||
const [, createdText] = content.split("\n");
|
||||
const createdAt = Number(createdText);
|
||||
|
||||
return Number.isFinite(createdAt) && now - createdAt > LOCK_STALE_MS;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
||||
}
|
||||
}
|
||||
|
||||
async function writeLockInfo(handle: FileHandle): Promise<void> {
|
||||
const content = `${process.pid}\n${Date.now()}\n`;
|
||||
await handle.truncate(0);
|
||||
await handle.write(content, 0, "utf8");
|
||||
}
|
||||
|
||||
async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
const lockPath = `${path}.lock`;
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const started = Date.now();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const handle = await open(lockPath, "wx", 0o600);
|
||||
let heartbeat: NodeJS.Timeout | undefined;
|
||||
let heartbeatWrite: Promise<void> = Promise.resolve();
|
||||
const queueHeartbeat = (): void => {
|
||||
heartbeatWrite = heartbeatWrite
|
||||
.catch(() => undefined)
|
||||
.then(() => writeLockInfo(handle))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
try {
|
||||
await writeLockInfo(handle);
|
||||
heartbeat = setInterval(queueHeartbeat, LOCK_HEARTBEAT_MS);
|
||||
return await fn();
|
||||
} finally {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
await heartbeatWrite.catch(() => undefined);
|
||||
await handle.close();
|
||||
await rm(lockPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "EEXIST") throw error;
|
||||
|
||||
if (await isLockStale(lockPath)) {
|
||||
await rm(lockPath, { force: true });
|
||||
await emitStorageEvidence(path, {
|
||||
type: "storage_stale_lock_recovered",
|
||||
phase: "storage",
|
||||
outcome: "recovered",
|
||||
reasonCodes: ["stale_lock"],
|
||||
details: {
|
||||
storeKind: storeKindFromPath(path),
|
||||
waitMs: Date.now() - started,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) {
|
||||
await emitStorageEvidence(path, {
|
||||
type: "storage_lock_timeout",
|
||||
phase: "storage",
|
||||
outcome: "failed",
|
||||
reasonCodes: ["lock_wait_timeout"],
|
||||
details: {
|
||||
storeKind: storeKindFromPath(path),
|
||||
waitMs: LOCK_WAIT_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
throw new Error(`Timed out waiting for lock ${lockPath}`);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
export async function updateJSON<T>(
|
||||
path: string,
|
||||
fallback: () => T,
|
||||
updater: (current: T) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = fileLocks.get(path) ?? Promise.resolve();
|
||||
let release: () => void = () => {};
|
||||
const currentLock = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const queued = previous.then(() => currentLock, () => currentLock);
|
||||
fileLocks.set(path, queued);
|
||||
|
||||
try {
|
||||
await previous.catch(() => undefined);
|
||||
return await withFileLock(path, async () => {
|
||||
const current = await readJSONStrict(path, fallback);
|
||||
const updated = await updater(current);
|
||||
await atomicWriteJSON(path, updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
if (fileLocks.get(path) === queued) {
|
||||
fileLocks.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
export type LongTermType = "feedback" | "project" | "decision" | "reference";
|
||||
|
||||
export type LongTermSource = "explicit" | "compaction" | "manual";
|
||||
|
||||
export type LongTermMemoryEntry = {
|
||||
id: string;
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
rationale?: string;
|
||||
source: LongTermSource;
|
||||
confidence: number;
|
||||
status: "active" | "superseded";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
staleAfterDays?: number;
|
||||
supersedes?: string[];
|
||||
tags?: string[];
|
||||
pendingOwnerSessionID?: string;
|
||||
pendingMessageID?: string;
|
||||
promotionAttempts?: number;
|
||||
lastPromotionAttemptAt?: string;
|
||||
lastPromotionFailureReason?: string;
|
||||
retentionClock?: number; // Unix timestamp when retention started
|
||||
reinforcementCount?: number; // Number of times this memory was reinforced
|
||||
lastReinforcedAt?: number; // Unix timestamp of last reinforcement
|
||||
lastReinforcedSessionID?: string;
|
||||
userImportance?: "low" | "normal" | "high";
|
||||
safetyCritical?: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryStore = {
|
||||
version: 1;
|
||||
workspace: {
|
||||
root: string;
|
||||
key: string;
|
||||
};
|
||||
limits: {
|
||||
maxRenderedChars: number;
|
||||
maxEntries: number;
|
||||
};
|
||||
entries: LongTermMemoryEntry[];
|
||||
migrations?: string[];
|
||||
updatedAt: string;
|
||||
lastActivityAt?: string;
|
||||
};
|
||||
|
||||
export type PendingMemoryJournalStore = {
|
||||
version: 1;
|
||||
workspace: {
|
||||
root: string;
|
||||
key: string;
|
||||
};
|
||||
entries: LongTermMemoryEntry[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ActiveFile = {
|
||||
path: string;
|
||||
action: "read" | "grep" | "edit" | "write";
|
||||
count: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
|
||||
export type OpenError = {
|
||||
id: string;
|
||||
category: "typecheck" | "test" | "lint" | "build" | "runtime" | "tool";
|
||||
summary: string;
|
||||
command?: string;
|
||||
file?: string;
|
||||
fingerprint: string;
|
||||
status: "open" | "maybe_fixed";
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
seenCount: number;
|
||||
};
|
||||
|
||||
export type SessionDecision = {
|
||||
id: string;
|
||||
text: string;
|
||||
rationale?: string;
|
||||
source: "assistant" | "user" | "compaction";
|
||||
createdAt: number;
|
||||
promotedToLongTerm?: boolean;
|
||||
};
|
||||
|
||||
export type SessionState = {
|
||||
version: 1;
|
||||
sessionID: string;
|
||||
turn: number;
|
||||
updatedAt: string;
|
||||
activeFiles: ActiveFile[];
|
||||
openErrors: OpenError[];
|
||||
recentDecisions: SessionDecision[];
|
||||
pendingMemories: LongTermMemoryEntry[];
|
||||
};
|
||||
|
||||
export const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 3600,
|
||||
targetRenderedChars: 3000,
|
||||
maxEntries: 28,
|
||||
maxEntryTextChars: 260,
|
||||
maxRationaleChars: 180,
|
||||
} as const;
|
||||
|
||||
export const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 700,
|
||||
maxActiveFilesStored: 20,
|
||||
maxActiveFilesRendered: 8,
|
||||
maxOpenErrorsStored: 5,
|
||||
maxOpenErrorsRendered: 3,
|
||||
maxRecentDecisionsStored: 8,
|
||||
maxPendingMemoriesStored: 12,
|
||||
maxPendingMemoriesRendered: 6,
|
||||
} as const;
|
||||
|
||||
export const PROMOTION_RETRY_LIMITS = {
|
||||
maxExplicitAttempts: 3,
|
||||
maxManualAttempts: 3,
|
||||
} as const;
|
||||
|
||||
export const WORKSPACE_MEMORY_CACHE_LIMITS = {
|
||||
maxFrozenSessions: 50,
|
||||
maxProcessedSessionIDs: 200,
|
||||
maxProcessedMessagesPerSession: 50,
|
||||
frozenTtlMs: 60 * 60 * 1000,
|
||||
} as const;
|
||||
@@ -0,0 +1,282 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { appendFile, mkdir, readFile, readdir, rename, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dataHome as defaultDataHome } from "./paths.ts";
|
||||
|
||||
export type WorkspaceCleanupClassification =
|
||||
| "test_temp_definite"
|
||||
| "orphan_unknown"
|
||||
| "live_or_existing"
|
||||
| "invalid_or_unreadable";
|
||||
|
||||
export type WorkspaceCleanupResult = {
|
||||
workspaceKey: string;
|
||||
workspaceDir: string;
|
||||
root?: string;
|
||||
rootExists: boolean;
|
||||
classification: WorkspaceCleanupClassification;
|
||||
reasons: string[];
|
||||
entryCount?: number;
|
||||
migrations?: string[];
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupScanOptions = {
|
||||
dataHome?: string;
|
||||
nowMs?: number;
|
||||
minAgeMs?: number;
|
||||
includeOrphans?: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupScan = {
|
||||
results: WorkspaceCleanupResult[];
|
||||
candidates: WorkspaceCleanupResult[];
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupMode = "dry-run" | "quarantine";
|
||||
|
||||
export type WorkspaceCleanupOptions = WorkspaceCleanupScanOptions & {
|
||||
mode?: WorkspaceCleanupMode;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupQuarantineEvent = WorkspaceCleanupResult & {
|
||||
from: string;
|
||||
to: string;
|
||||
quarantinedAt: string;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupRunResult = WorkspaceCleanupScan & {
|
||||
mode: WorkspaceCleanupMode;
|
||||
quarantined: WorkspaceCleanupQuarantineEvent[];
|
||||
quarantineDir?: string;
|
||||
};
|
||||
|
||||
type WorkspaceMemoryShape = {
|
||||
workspace?: {
|
||||
root?: unknown;
|
||||
key?: unknown;
|
||||
};
|
||||
entries?: unknown[];
|
||||
migrations?: unknown[];
|
||||
updatedAt?: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_MIN_AGE_MS = 10 * 60 * 1_000;
|
||||
|
||||
const KNOWN_TEST_ROOT_PREFIXES = [
|
||||
"memory-plugin-test-",
|
||||
"memory-plugin-prompt-",
|
||||
"wm-",
|
||||
"wm-quality-",
|
||||
"wm-accounting-",
|
||||
"wm-redact-",
|
||||
"wm-normalized-",
|
||||
"wm-ordering-",
|
||||
"wm-extraction-",
|
||||
];
|
||||
|
||||
function normalizePathForComparison(path: string): string {
|
||||
return resolve(path).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isInsidePath(path: string, parent: string): boolean {
|
||||
const normalizedPath = normalizePathForComparison(path);
|
||||
const normalizedParent = normalizePathForComparison(parent);
|
||||
return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`);
|
||||
}
|
||||
|
||||
export function isTempRoot(root: string, osTmpdir = tmpdir()): boolean {
|
||||
const normalized = normalizePathForComparison(root);
|
||||
const normalizedTmp = normalizePathForComparison(osTmpdir);
|
||||
|
||||
if (isInsidePath(normalized, normalizedTmp)) return true;
|
||||
if (isInsidePath(normalized, "/tmp")) return true;
|
||||
if (isInsidePath(normalized, "/private/tmp")) return true;
|
||||
|
||||
return /^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/.test(normalized);
|
||||
}
|
||||
|
||||
export function isKnownTestWorkspaceRoot(root: string): boolean {
|
||||
const name = basename(root);
|
||||
return KNOWN_TEST_ROOT_PREFIXES.some(prefix => name.startsWith(prefix));
|
||||
}
|
||||
|
||||
function classifyCandidate(result: WorkspaceCleanupResult, includeOrphans: boolean): boolean {
|
||||
if (result.reasons.includes("recent_workspace_dir")) return false;
|
||||
if (result.reasons.includes("lock_present")) return false;
|
||||
if (result.classification === "test_temp_definite") return true;
|
||||
return includeOrphans && result.classification === "orphan_unknown";
|
||||
}
|
||||
|
||||
export async function classifyWorkspaceDir(
|
||||
workspaceDir: string,
|
||||
options: { nowMs?: number; minAgeMs?: number } = {},
|
||||
): Promise<WorkspaceCleanupResult> {
|
||||
const workspaceKey = basename(workspaceDir);
|
||||
const reasons: string[] = [];
|
||||
const memoryPath = join(workspaceDir, "workspace-memory.json");
|
||||
|
||||
if (existsSync(`${memoryPath}.lock`)) {
|
||||
reasons.push("lock_present");
|
||||
}
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(workspaceDir);
|
||||
} catch {
|
||||
return {
|
||||
workspaceKey,
|
||||
workspaceDir,
|
||||
rootExists: false,
|
||||
classification: "invalid_or_unreadable",
|
||||
reasons: ["workspace_dir_unreadable"],
|
||||
};
|
||||
}
|
||||
|
||||
const minAgeMs = options.minAgeMs ?? DEFAULT_MIN_AGE_MS;
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
if (minAgeMs > 0 && nowMs - stats.mtimeMs < minAgeMs) {
|
||||
reasons.push("recent_workspace_dir");
|
||||
}
|
||||
|
||||
let store: WorkspaceMemoryShape;
|
||||
try {
|
||||
store = JSON.parse(await readFile(memoryPath, "utf8")) as WorkspaceMemoryShape;
|
||||
} catch {
|
||||
return {
|
||||
workspaceKey,
|
||||
workspaceDir,
|
||||
rootExists: false,
|
||||
classification: "invalid_or_unreadable",
|
||||
reasons: [...reasons, "invalid_json"],
|
||||
};
|
||||
}
|
||||
|
||||
const root = typeof store.workspace?.root === "string" ? store.workspace.root : undefined;
|
||||
const key = typeof store.workspace?.key === "string" ? store.workspace.key : workspaceKey;
|
||||
const entryCount = Array.isArray(store.entries) ? store.entries.length : undefined;
|
||||
const migrations = Array.isArray(store.migrations) ? store.migrations.filter((item): item is string => typeof item === "string") : undefined;
|
||||
const updatedAt = typeof store.updatedAt === "string" ? store.updatedAt : undefined;
|
||||
|
||||
if (!root) {
|
||||
return {
|
||||
workspaceKey: key,
|
||||
workspaceDir,
|
||||
rootExists: false,
|
||||
classification: "invalid_or_unreadable",
|
||||
reasons: [...reasons, "missing_workspace_root"],
|
||||
entryCount,
|
||||
migrations,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const rootExists = existsSync(root);
|
||||
if (rootExists) {
|
||||
return {
|
||||
workspaceKey: key,
|
||||
workspaceDir,
|
||||
root,
|
||||
rootExists,
|
||||
classification: "live_or_existing",
|
||||
reasons: [...reasons, "root_exists"],
|
||||
entryCount,
|
||||
migrations,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
reasons.push("root_missing");
|
||||
const tempRoot = isTempRoot(root);
|
||||
const testRoot = isKnownTestWorkspaceRoot(root);
|
||||
if (tempRoot) reasons.push("root_under_temp");
|
||||
if (testRoot) reasons.push(`test_prefix_${KNOWN_TEST_ROOT_PREFIXES.find(prefix => basename(root).startsWith(prefix))?.replace(/-$/, "") ?? basename(root)}`);
|
||||
|
||||
return {
|
||||
workspaceKey: key,
|
||||
workspaceDir,
|
||||
root,
|
||||
rootExists,
|
||||
classification: tempRoot || testRoot ? "test_temp_definite" : "orphan_unknown",
|
||||
reasons,
|
||||
entryCount,
|
||||
migrations,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function workspacesDir(dataHome: string): string {
|
||||
return join(dataHome, "opencode-working-memory", "workspaces");
|
||||
}
|
||||
|
||||
export async function scanWorkspaceResidues(options: WorkspaceCleanupScanOptions = {}): Promise<WorkspaceCleanupScan> {
|
||||
const root = workspacesDir(options.dataHome ?? defaultDataHome());
|
||||
const results: WorkspaceCleanupResult[] = [];
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(root);
|
||||
} catch {
|
||||
return { results, candidates: [] };
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const workspaceDir = join(root, entry);
|
||||
const stats = await stat(workspaceDir).catch(() => undefined);
|
||||
if (!stats?.isDirectory()) continue;
|
||||
|
||||
results.push(await classifyWorkspaceDir(workspaceDir, {
|
||||
nowMs: options.nowMs,
|
||||
minAgeMs: options.minAgeMs,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
candidates: results.filter(result => classifyCandidate(result, options.includeOrphans ?? false)),
|
||||
};
|
||||
}
|
||||
|
||||
function quarantineName(now: Date): string {
|
||||
return `workspace-cleanup-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`;
|
||||
}
|
||||
|
||||
export async function cleanupWorkspaceResidues(options: WorkspaceCleanupOptions = {}): Promise<WorkspaceCleanupRunResult> {
|
||||
const mode = options.mode ?? "dry-run";
|
||||
const now = options.now ?? new Date();
|
||||
const scan = await scanWorkspaceResidues({
|
||||
dataHome: options.dataHome,
|
||||
nowMs: options.nowMs,
|
||||
minAgeMs: options.minAgeMs,
|
||||
includeOrphans: options.includeOrphans,
|
||||
});
|
||||
|
||||
if (mode === "dry-run" || scan.candidates.length === 0) {
|
||||
return { ...scan, mode, quarantined: [] };
|
||||
}
|
||||
|
||||
const dataHome = options.dataHome ?? defaultDataHome();
|
||||
const quarantineDir = join(dataHome, "opencode-working-memory", "quarantine", quarantineName(now));
|
||||
const quarantined: WorkspaceCleanupQuarantineEvent[] = [];
|
||||
|
||||
for (const candidate of scan.candidates) {
|
||||
const destination = join(quarantineDir, "workspaces", candidate.workspaceKey);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await rename(candidate.workspaceDir, destination);
|
||||
|
||||
const event: WorkspaceCleanupQuarantineEvent = {
|
||||
...candidate,
|
||||
from: candidate.workspaceDir,
|
||||
to: destination,
|
||||
quarantinedAt: now.toISOString(),
|
||||
};
|
||||
quarantined.push(event);
|
||||
|
||||
await mkdir(quarantineDir, { recursive: true });
|
||||
await appendFile(join(quarantineDir, "manifest.jsonl"), JSON.stringify(event) + "\n", "utf8");
|
||||
}
|
||||
|
||||
return { ...scan, mode, quarantined, quarantineDir };
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
import {
|
||||
RETENTION_TYPE_MAX,
|
||||
calculateRetentionStrength,
|
||||
reinforceMemory,
|
||||
} from "./retention.ts";
|
||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
const MIN_ENVELOPE_LENGTH = 80;
|
||||
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
||||
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
|
||||
|
||||
export type MemoryConsolidationReason =
|
||||
| "promoted"
|
||||
| "absorbed_exact"
|
||||
| "absorbed_identity"
|
||||
| "superseded_existing"
|
||||
| "rejected_capacity";
|
||||
|
||||
export type MemoryConsolidationEvent = {
|
||||
memoryKey: string;
|
||||
identityKey: string;
|
||||
memory: LongTermMemoryEntry;
|
||||
reason: MemoryConsolidationReason;
|
||||
retainedId?: string;
|
||||
supersededId?: string;
|
||||
};
|
||||
|
||||
export type LongTermLimitResult = {
|
||||
kept: LongTermMemoryEntry[];
|
||||
dropped: MemoryConsolidationEvent[];
|
||||
absorbed: MemoryConsolidationEvent[];
|
||||
superseded: MemoryConsolidationEvent[];
|
||||
evidence: EvidenceEventInput[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||||
store: WorkspaceMemoryStore;
|
||||
events: MemoryConsolidationEvent[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryRenderAccounting = {
|
||||
rendered: LongTermMemoryEntry[];
|
||||
omitted: Array<{
|
||||
memory: LongTermMemoryEntry;
|
||||
reason: "superseded" | "type_cap" | "global_cap" | "char_budget" | "empty_render_budget";
|
||||
}>;
|
||||
evidence: EvidenceEventInput[];
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type QualityCleanupMigrationLogEntry = {
|
||||
migrationId: string;
|
||||
timestamp: string;
|
||||
workspaceKey: string;
|
||||
workspaceRoot: string;
|
||||
entryId: string;
|
||||
type: LongTermMemoryEntry["type"];
|
||||
source: LongTermMemoryEntry["source"];
|
||||
text: string;
|
||||
reasons: string[];
|
||||
hardReasons: string[];
|
||||
beforeStatus: "active";
|
||||
afterStatus: "superseded";
|
||||
};
|
||||
|
||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
const nowIso = new Date().toISOString();
|
||||
return {
|
||||
version: 1,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
limits: {
|
||||
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: [],
|
||||
migrations: [],
|
||||
updatedAt: nowIso,
|
||||
lastActivityAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const fallback = await emptyWorkspaceMemory(root);
|
||||
const loaded = await readJSON(path, () => fallback) as Partial<WorkspaceMemoryStore>;
|
||||
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: loaded.version ?? 1,
|
||||
workspace: loaded.workspace ?? { root, key: await workspaceKey(root) },
|
||||
limits: {
|
||||
maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
|
||||
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
|
||||
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
|
||||
lastActivityAt: loaded.lastActivityAt ?? loaded.updatedAt ?? fallback.lastActivityAt,
|
||||
};
|
||||
|
||||
// Always normalize on load so redaction/migrations are always-on.
|
||||
const normalized = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||||
|
||||
// Persist security/correctness mutations, but avoid read-time maintenance
|
||||
// writes for ordering/capacity/timestamp-only normalization.
|
||||
if (hasSecurityOrMigrationChange(store, normalized.store)) {
|
||||
await atomicWriteJSON(path, normalized.store);
|
||||
}
|
||||
|
||||
return normalized.store;
|
||||
}
|
||||
|
||||
function hasSecurityOrMigrationChange(
|
||||
before: WorkspaceMemoryStore,
|
||||
after: WorkspaceMemoryStore,
|
||||
): boolean {
|
||||
const beforeById = new Map((before.entries ?? []).map(entry => [entry.id, entry]));
|
||||
for (const afterEntry of after.entries ?? []) {
|
||||
const beforeEntry = beforeById.get(afterEntry.id);
|
||||
if (!beforeEntry) continue;
|
||||
if (beforeEntry.text !== afterEntry.text) return true;
|
||||
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
|
||||
if (beforeEntry.status !== afterEntry.status) return true;
|
||||
}
|
||||
|
||||
const beforeMigrations = JSON.stringify(before.migrations ?? []);
|
||||
const afterMigrations = JSON.stringify(after.migrations ?? []);
|
||||
if ((before.lastActivityAt ?? "") !== (after.lastActivityAt ?? "")) return true;
|
||||
return beforeMigrations !== afterMigrations;
|
||||
}
|
||||
|
||||
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
|
||||
const normalized = await normalizeWorkspaceMemory(root, store);
|
||||
await atomicWriteJSON(await workspaceMemoryPath(root), normalized);
|
||||
}
|
||||
|
||||
export async function updateWorkspaceMemory(
|
||||
root: string,
|
||||
updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise<WorkspaceMemoryStore>,
|
||||
): Promise<WorkspaceMemoryStore> {
|
||||
return (await updateWorkspaceMemoryWithAccounting(root, updater)).store;
|
||||
}
|
||||
|
||||
export async function updateWorkspaceMemoryWithAccounting(
|
||||
root: string,
|
||||
updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise<WorkspaceMemoryStore>,
|
||||
): Promise<WorkspaceMemoryNormalizationResult> {
|
||||
const path = await workspaceMemoryPath(root);
|
||||
const fallback = await emptyWorkspaceMemory(root);
|
||||
let finalResult: WorkspaceMemoryNormalizationResult | undefined;
|
||||
const store = await updateJSON(path, () => fallback, async current => {
|
||||
const normalized = await normalizeWorkspaceMemory(root, current);
|
||||
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized));
|
||||
return finalResult.store;
|
||||
});
|
||||
|
||||
return finalResult ?? {
|
||||
store,
|
||||
kept: store.entries.filter(entry => entry.status !== "superseded"),
|
||||
dropped: [],
|
||||
absorbed: [],
|
||||
superseded: [],
|
||||
evidence: [],
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function normalizeWorkspaceMemory(
|
||||
root: string,
|
||||
store: WorkspaceMemoryStore,
|
||||
): Promise<WorkspaceMemoryStore> {
|
||||
return (await normalizeWorkspaceMemoryWithAccounting(root, store)).store;
|
||||
}
|
||||
|
||||
export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
root: string,
|
||||
store: WorkspaceMemoryStore,
|
||||
): Promise<WorkspaceMemoryNormalizationResult> {
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
let result: WorkspaceMemoryStore = {
|
||||
...store,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
limits: {
|
||||
maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: Array.isArray(store.entries) ? store.entries : [],
|
||||
migrations: Array.isArray(store.migrations) ? store.migrations : [],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Always-on credential redaction
|
||||
result.entries = result.entries.map(entry => {
|
||||
const text = redactCredentials(entry.text);
|
||||
const rationale = entry.rationale ? redactCredentials(entry.rationale) : undefined;
|
||||
|
||||
if (text === entry.text && rationale === entry.rationale) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
text,
|
||||
rationale,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
});
|
||||
|
||||
// One-time migrations for legacy/low-quality snapshot violations.
|
||||
// Run quality cleanup first so hard violations receive quality audit tags
|
||||
// before the older P0 project-only cleanup marks progress snapshots.
|
||||
const beforeQualityCleanup = result;
|
||||
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
|
||||
result = qualityCleanup.store;
|
||||
let skipRemainingMigrations = false;
|
||||
if (qualityCleanup.events.length > 0) {
|
||||
try {
|
||||
await appendQualityCleanupMigrationLog(qualityCleanup.events);
|
||||
} catch (error) {
|
||||
console.error("[memory] failed to write quality cleanup migration log:", error);
|
||||
console.error("[memory] aborting migration to maintain audit trail integrity");
|
||||
result = beforeQualityCleanup;
|
||||
skipRemainingMigrations = true;
|
||||
}
|
||||
}
|
||||
if (!skipRemainingMigrations) {
|
||||
result = runMigrationP0Cleanup(result, nowIso);
|
||||
}
|
||||
|
||||
// P0 accounting only considers active entries. Entries that were already
|
||||
// superseded before this normalization are preserved in storage; entries that
|
||||
// lose during this enforcement are reported via accounting events but are not
|
||||
// archived as superseded records in this wave.
|
||||
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
|
||||
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
|
||||
const accounting = enforceLongTermLimitsWithAccounting(activeEntries, result);
|
||||
|
||||
const normalizedStore = {
|
||||
...result,
|
||||
entries: [...accounting.kept, ...supersededEntries],
|
||||
updatedAt: nowIso,
|
||||
lastActivityAt: nowIso,
|
||||
};
|
||||
|
||||
return {
|
||||
store: normalizedStore,
|
||||
kept: accounting.kept,
|
||||
dropped: accounting.dropped,
|
||||
absorbed: accounting.absorbed,
|
||||
superseded: accounting.superseded,
|
||||
evidence: accounting.evidence,
|
||||
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
||||
};
|
||||
}
|
||||
|
||||
export function runMigrationP0Cleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
): WorkspaceMemoryStore {
|
||||
if (store.migrations?.includes(MIGRATION_ID)) {
|
||||
return store;
|
||||
}
|
||||
|
||||
const entries = store.entries.map(entry => {
|
||||
if (entry.source !== "compaction") return entry;
|
||||
if (entry.type !== "project") return entry;
|
||||
|
||||
if (isProgressSnapshotViolation(entry.text)) {
|
||||
return {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
return {
|
||||
...store,
|
||||
entries,
|
||||
migrations: [...(store.migrations || []), MIGRATION_ID],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationLogEntry[]): Promise<void> {
|
||||
if (events.length === 0) return;
|
||||
const path = migrationLogPath(QUALITY_CLEANUP_MIGRATION_ID);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n", "utf8");
|
||||
}
|
||||
|
||||
export function runMigrationQualityCleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
|
||||
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
|
||||
return { store, events: [] };
|
||||
}
|
||||
|
||||
const events: QualityCleanupMigrationLogEntry[] = [];
|
||||
let changed = false;
|
||||
const entries = store.entries.map(entry => {
|
||||
if (entry.source !== "compaction") return entry;
|
||||
if (entry.status === "superseded") return entry;
|
||||
|
||||
const quality = assessMemoryQuality(entry);
|
||||
if (quality.accepted) return entry;
|
||||
|
||||
const hardReasons = quality.reasons.filter(isHardQualityReason);
|
||||
if (hardReasons.length === 0) return entry;
|
||||
|
||||
changed = true;
|
||||
events.push({
|
||||
migrationId: QUALITY_CLEANUP_MIGRATION_ID,
|
||||
timestamp: nowIso,
|
||||
workspaceKey: store.workspace.key,
|
||||
workspaceRoot: store.workspace.root,
|
||||
entryId: entry.id,
|
||||
type: entry.type,
|
||||
source: entry.source,
|
||||
text: entry.text,
|
||||
reasons: quality.reasons,
|
||||
hardReasons,
|
||||
beforeStatus: "active",
|
||||
afterStatus: "superseded",
|
||||
});
|
||||
|
||||
const tags = new Set([
|
||||
...(entry.tags ?? []),
|
||||
"quality_cleanup",
|
||||
...hardReasons.map(reason => `quality:${reason}`),
|
||||
]);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
updatedAt: nowIso,
|
||||
tags: [...tags],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
store: {
|
||||
...store,
|
||||
entries,
|
||||
migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID],
|
||||
updatedAt: changed ? nowIso : store.updatedAt,
|
||||
},
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
|
||||
if (source === "explicit") return 3;
|
||||
if (source === "manual") return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function canonicalMemoryText(text: string): string {
|
||||
return text
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[\s\p{P}]+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
|
||||
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
function normalizeUrlIdentity(raw: string): string | null {
|
||||
const cleaned = raw.replace(/[),.;:!?]+$/g, "");
|
||||
try {
|
||||
const url = new URL(cleaned);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
url.protocol = url.protocol.toLowerCase();
|
||||
url.hostname = url.hostname.toLowerCase();
|
||||
url.hash = "";
|
||||
if (url.pathname.length > 1) {
|
||||
url.pathname = url.pathname.replace(/\/+$/g, "");
|
||||
}
|
||||
return `url:${url.toString()}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePathIdentity(raw: string): string | null {
|
||||
const unwrapped = raw
|
||||
.trim()
|
||||
.replace(/^[`"']+|[`"']+$/g, "")
|
||||
.replace(/[),.;:!?]+$/g, "")
|
||||
.replace(/\\+/g, "/");
|
||||
|
||||
if (!unwrapped) return null;
|
||||
const collapsed = unwrapped.startsWith("/")
|
||||
? `/${unwrapped.slice(1).replace(/\/+$/g, "/").replace(/\/+/g, "/")}`
|
||||
: unwrapped.replace(/\/+/g, "/");
|
||||
const withoutTrailingSlash = collapsed.length > 1 ? collapsed.replace(/\/+$/g, "") : collapsed;
|
||||
return `path:${withoutTrailingSlash}`;
|
||||
}
|
||||
|
||||
function isConcretePathIdentity(pathIdentity: string): boolean {
|
||||
const path = pathIdentity.slice("path:".length);
|
||||
if (!path || path === "." || path === "..") return false;
|
||||
|
||||
if (path.startsWith("/")) return true;
|
||||
if (/^\.\.?\//.test(path)) return true;
|
||||
if (/^\.[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||
if (/^[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||
return /\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config)$/i.test(path);
|
||||
}
|
||||
|
||||
function normalizeConcretePathIdentity(raw: string): string | null {
|
||||
const pathIdentity = normalizePathIdentity(raw);
|
||||
if (!pathIdentity) return null;
|
||||
return isConcretePathIdentity(pathIdentity) ? pathIdentity : null;
|
||||
}
|
||||
|
||||
function extractConcreteIdentityKey(text: string): string | null {
|
||||
const urlMatch = text.match(/https?:\/\/[^\s`"'<>]+/i);
|
||||
if (urlMatch) {
|
||||
const urlIdentity = normalizeUrlIdentity(urlMatch[0]);
|
||||
if (urlIdentity) return urlIdentity;
|
||||
}
|
||||
|
||||
const wrappedPathPattern = /[`"']([^`"']+)[`"']/g;
|
||||
for (const match of text.matchAll(wrappedPathPattern)) {
|
||||
const pathIdentity = normalizeConcretePathIdentity(match[1]);
|
||||
if (pathIdentity) return pathIdentity;
|
||||
}
|
||||
|
||||
const pathMatch = text.match(/(?:\/[^ | ||||