mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4832b382b6 | |||
| 17258ce60f | |||
| 6fad91953c | |||
| 7b33b51c1e | |||
| 2edb643667 | |||
| d7302e531a | |||
| bb929fc578 | |||
| 98e5ecaa91 | |||
| c008fb860f | |||
| c743ddb331 | |||
| 0631286962 | |||
| 75c89747d8 |
+11
@@ -40,3 +40,14 @@ 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/
|
||||
|
||||
@@ -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: 5200,
|
||||
maxEntries: 28,
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Imports & Module Organization
|
||||
@@ -105,57 +127,52 @@ import { join } from "path";
|
||||
|
||||
// 2. Third-party (OpenCode SDK)
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
// 3. Local modules (if any)
|
||||
// (none currently)
|
||||
// 3. Local modules
|
||||
import { loadWorkspaceMemory } from "./storage.js";
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: camelCase for variables & functions
|
||||
const maxItems = 50;
|
||||
async function loadCoreMemory() { }
|
||||
const maxEntries = 28;
|
||||
async function loadWorkspaceMemory() { }
|
||||
|
||||
// ✅ REQUIRED: SCREAMING_SNAKE_CASE for constants
|
||||
const CORE_MEMORY_LIMITS = { goal: 1000, progress: 2000, context: 1500 };
|
||||
const SLOT_CONFIG = { error: 3, decision: 5, todo: 3, dependency: 3 };
|
||||
const LONG_TERM_LIMITS = { maxRenderedChars: 5200, maxEntries: 28 };
|
||||
const HOT_STATE_LIMITS = { maxRenderedChars: 1200 };
|
||||
|
||||
// ✅ REQUIRED: PascalCase for types
|
||||
type CoreMemory = { ... };
|
||||
type WorkingMemoryItem = { ... };
|
||||
type WorkspaceMemoryStore = { ... };
|
||||
type SessionState = { ... };
|
||||
|
||||
// ✅ REQUIRED: get*/set*/load*/save* naming for file operations
|
||||
function getCoreMemoryPath(directory: string, sessionID: string): string { }
|
||||
async function loadCoreMemory(directory: string, sessionID: string): Promise<CoreMemory | null> { }
|
||||
async function saveCoreMemory(directory: string, memory: CoreMemory): Promise<void> { }
|
||||
|
||||
// ✅ REQUIRED: ensure*/validate* for pre-checks
|
||||
async function ensureCoreMemoryDir(directory: string): Promise<void> { }
|
||||
|
||||
// ✅ REQUIRED: Prefix private/internal functions with _
|
||||
function _compressPath(filePath: string): string { }
|
||||
// ✅ REQUIRED: get*/load*/save* naming for file operations
|
||||
function getWorkspaceMemoryPath(workspaceKey: string): string { }
|
||||
async function loadWorkspaceMemory(workspaceKey: string): Promise<WorkspaceMemoryStore | null> { }
|
||||
async function saveWorkspaceMemory(memory: WorkspaceMemoryStore): Promise<void> { }
|
||||
```
|
||||
|
||||
### Function Signatures & Organization
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Parameters on separate lines if > 80 chars
|
||||
async function loadWorkingMemory(
|
||||
directory: string,
|
||||
sessionID: string
|
||||
): Promise<WorkingMemory | null> {
|
||||
async function extractWorkspaceMemoryCandidates(
|
||||
content: string
|
||||
): Promise<WorkspaceMemoryCandidate[]> {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ REQUIRED: Explicit return types (no inference)
|
||||
function getCompactionLogPath(directory: string, sessionID: string): string {
|
||||
return join(directory, ".opencode", "memory-working", `${sessionID}_compaction.json`);
|
||||
function renderWorkspaceMemory(
|
||||
entries: LongTermMemoryEntry[],
|
||||
maxChars: number
|
||||
): string {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ REQUIRED: Async for file/network I/O
|
||||
async function saveCoreMemory(directory: string, memory: CoreMemory): Promise<void> {
|
||||
async function saveWorkspaceMemory(memory: WorkspaceMemoryStore): Promise<void> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -163,28 +180,23 @@ async function saveCoreMemory(directory: string, memory: CoreMemory): Promise<vo
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Try-catch with descriptive console.error
|
||||
async function loadCoreMemory(directory: string, sessionID: string): Promise<CoreMemory | null> {
|
||||
const path = getCoreMemoryPath(directory, sessionID);
|
||||
// ✅ REQUIRED: Try-catch with graceful degradation
|
||||
async function loadWorkspaceMemory(workspaceKey: string): Promise<WorkspaceMemoryStore | null> {
|
||||
const path = getWorkspaceMemoryPath(workspaceKey);
|
||||
if (!existsSync(path)) return null;
|
||||
|
||||
try {
|
||||
const content = await readFile(path, "utf-8");
|
||||
return JSON.parse(content) as CoreMemory;
|
||||
return JSON.parse(content) as WorkspaceMemoryStore;
|
||||
} catch (error) {
|
||||
console.error("Failed to load core memory:", error);
|
||||
return null; // Graceful degradation
|
||||
// Plugin should never block agent - return null and continue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ REQUIRED: Type guards for runtime safety
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ✅ REQUIRED: Validate JSON before use
|
||||
const data = JSON.parse(content);
|
||||
const typedData = data as CoreMemory; // Explicit cast after validation
|
||||
const typedData = data as WorkspaceMemoryStore; // Explicit cast after validation
|
||||
```
|
||||
|
||||
### Comments & Documentation
|
||||
@@ -192,149 +204,125 @@ const typedData = data as CoreMemory; // Explicit cast after validation
|
||||
```typescript
|
||||
// ✅ REQUIRED: Section headers for major sections
|
||||
// ============================================================================
|
||||
// Phase 1: Core Memory Foundation
|
||||
// Workspace Memory: Long-term cross-session storage
|
||||
// ============================================================================
|
||||
|
||||
// ✅ REQUIRED: Block comments for complex logic
|
||||
// Migration: Convert old format (items array) to new format (slots + pool)
|
||||
if (data.items && !data.slots) {
|
||||
// ... migration logic
|
||||
// Quality gate: Reject candidates that are git hashes, errors, or path-heavy
|
||||
function shouldAcceptWorkspaceMemoryCandidate(candidate: string): boolean {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ USE: Inline comments sparingly
|
||||
const gamma = 0.85; // Exponential decay rate (15% per event)
|
||||
|
||||
// ✅ AVOID: Over-commenting obvious code
|
||||
const name = "test"; // Set name to test ❌ (obvious)
|
||||
```
|
||||
|
||||
### Code Organization
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Organize plugin file by phase/feature
|
||||
// 1. Header & module documentation
|
||||
// 2. Imports
|
||||
// 3. Types & schemas (grouped by phase)
|
||||
// 4. Constants & configs
|
||||
// 5. Helper functions (private first, public after)
|
||||
// 6. Main plugin export
|
||||
// 7. Hook implementations
|
||||
|
||||
export default {
|
||||
// Plugin definition
|
||||
} as Plugin;
|
||||
```
|
||||
|
||||
### Working with OpenCode Plugin SDK
|
||||
|
||||
```typescript
|
||||
// ✅ REQUIRED: Use proper hook signatures
|
||||
import { tool, type Plugin } from "@opencode-ai/plugin";
|
||||
|
||||
export default {
|
||||
id: "working-memory",
|
||||
name: "Working Memory Plugin",
|
||||
|
||||
// ✅ Core hooks
|
||||
hooks: {
|
||||
"tool.execute.after": async (ctx) => {
|
||||
// Tool just executed
|
||||
},
|
||||
"experimental.chat.system.transform": async (ctx) => {
|
||||
// Transform system prompt before sending
|
||||
},
|
||||
"experimental.session.compacting": async (ctx) => {
|
||||
// Session is being compacted (clearing old messages)
|
||||
},
|
||||
},
|
||||
|
||||
// ✅ Exposed tools
|
||||
tools: [
|
||||
tool({
|
||||
id: "core_memory_update",
|
||||
name: "Update Core Memory",
|
||||
description: "Update goal/progress/context blocks",
|
||||
// ... schema & execute
|
||||
}),
|
||||
],
|
||||
} as Plugin;
|
||||
// ✅ USE: Inline comments sparingly for non-obvious logic
|
||||
const canonical = normalizeText(text); // Lowercase, strip punctuation, collapse whitespace
|
||||
```
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Core Memory Files
|
||||
- Location: `.opencode/memory-core/<sessionID>.json`
|
||||
- Schema: `{ sessionID, blocks: { goal, progress, context }, updatedAt }`
|
||||
- Limits: goal (1000 chars), progress (2000 chars), context (1500 chars)
|
||||
### Plugin Entry Point
|
||||
|
||||
### Working Memory Files
|
||||
- Location: `.opencode/memory-working/<sessionID>.json`
|
||||
- Schema: `{ sessionID, slots, pool, eventCounter, updatedAt }`
|
||||
- Slot limits: error (3), decision (5), todo (3), dependency (3)
|
||||
- Pool decay: γ=0.85 per event
|
||||
```typescript
|
||||
// index.ts
|
||||
import { MemoryV2Plugin } from "./src/plugin.ts";
|
||||
|
||||
### Pressure Monitoring
|
||||
- Triggers at: 70% (safe→moderate), 85% (moderate→high), 95% (high→critical)
|
||||
- Files: `.opencode/memory-working/<sessionID>_pressure.json`
|
||||
- Intervention: Sends `promptAsync()` with complete visible prompt
|
||||
export default {
|
||||
id: "working-memory",
|
||||
server: MemoryV2Plugin,
|
||||
};
|
||||
```
|
||||
|
||||
### Storage Governance (Layer 1 & 2)
|
||||
- **Layer 1**: Session deletion cleanup - removes orphaned memory files
|
||||
- **Layer 2**: Tool output cache sweep - maintains 300 most recent files, 7-day TTL
|
||||
- Triggered at `eventCounter % 500 === 0` (automatic maintenance)
|
||||
### Workspace Memory Files
|
||||
|
||||
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
|
||||
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
|
||||
- **Schema**: See `src/types.ts:WorkspaceMemoryStore`
|
||||
- **Limits**: 5200 chars, 28 entries max
|
||||
|
||||
### Session State Files
|
||||
|
||||
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/sessions/{sessionID}.json`
|
||||
- **Session ID**: Hash of session ID from OpenCode
|
||||
- **Schema**: See `src/types.ts:SessionState`
|
||||
|
||||
### Memory Types
|
||||
|
||||
| Type | Purpose | Stale After |
|
||||
|------|---------|-------------|
|
||||
| `feedback` | User preferences | 90 days |
|
||||
| `project` | Project info | 60 days |
|
||||
| `decision` | Important decisions | 45 days |
|
||||
| `reference` | Key references | 90 days |
|
||||
|
||||
### Quality Guards
|
||||
|
||||
1. **False Positive Error Prevention**: Commands with "error" in output but `exitCode === undefined` are not tracked as errors
|
||||
2. **Negative Memory Filtering**: "don't remember" patterns are correctly interpreted
|
||||
3. **Compaction Quality Gate**: Rejects git hashes, stack traces, path-heavy facts
|
||||
|
||||
## Plugin Hooks
|
||||
|
||||
### `experimental.chat.system.transform`
|
||||
|
||||
Injects workspace memory and hot session state into system prompt.
|
||||
|
||||
### `tool.execute.after`
|
||||
|
||||
- Tracks active files (read, grep, edit, write actions)
|
||||
- Tracks open errors from failed commands
|
||||
- Clears errors when commands succeed
|
||||
- Ignores `exitCode === undefined`
|
||||
|
||||
### `experimental.session.compacting`
|
||||
|
||||
Extracts workspace memory candidates from conversation, applies quality gate and deduplication.
|
||||
|
||||
### `event`
|
||||
|
||||
- `session.compacted`: Promote session decisions to workspace memory
|
||||
- `session.deleted`: Clean up session state files
|
||||
|
||||
## Debugging & Testing
|
||||
|
||||
### Manual Testing Steps
|
||||
1. **Phase 1 (Core Memory)**: Check `.opencode/memory-core/` after `core_memory_update`
|
||||
2. **Phase 2 (Smart Pruning)**: Verify tool outputs are filtered before context injection
|
||||
3. **Phase 3 (Working Memory)**: Check `.opencode/memory-working/` for slot/pool items
|
||||
4. **Phase 4 (Pressure Monitoring)**: Monitor pressure % in system prompts, verify interventions
|
||||
5. **Phase 4.5 (Storage Governance)**: Run 500+ events, check sweep logs
|
||||
|
||||
1. **Workspace Memory**: Check `~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json` after compaction
|
||||
2. **Session State**: Check `~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json` after tool usage
|
||||
3. **Error Tracking**: Run failing commands, verify errors appear in session state
|
||||
4. **Error Clearing**: Run successful commands, verify errors are cleared
|
||||
|
||||
### Common Issues
|
||||
- **File not found**: Ensure `.opencode/` directory exists and is writable
|
||||
|
||||
- **File not found**: Ensure `~/.local/share/opencode-working-memory/` exists and is writable
|
||||
- **Type errors**: Check all imports use `import type { ... }` for types
|
||||
- **Lost memory**: Verify `.opencode/memory-*/` is in `.gitignore` (not committed)
|
||||
- **Sweep not running**: Check `eventCounter` in `<sessionID>.json`, should trigger at multiples of 500
|
||||
- **Memory not persisting**: Verify workspace key is consistent (same workspace = same key)
|
||||
- **False positive errors**: Check `exitCode` handling in `plugin.ts`
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Memory budgets**: Core (5.5k chars total), Working (1.6k chars for system prompt)
|
||||
- **Pruning**: Hyper-aggressive mode activates at ≥85% pressure
|
||||
- **Compaction**: Preserves most recent 10 items when space-constrained
|
||||
- **Decay**: Pool items scored by exponential decay (γ=0.85) + mention count
|
||||
- **Storage sweep**: Limits cache to 300 files, removes files older than 7 days
|
||||
|
||||
## File Path References
|
||||
|
||||
When referencing code locations in documentation/comments, use:
|
||||
```
|
||||
path/to/file.ts:L123 or path/to/file.ts:Line 123
|
||||
```
|
||||
|
||||
Example: `Function sendPressureInterventionMessage() @ index.ts:L1286`
|
||||
- **Workspace memory budget**: 5200 chars injected into system prompt
|
||||
- **Session state budget**: 1200 chars injected into system prompt
|
||||
- **Total overhead**: ~1500-6000 chars per message (minimal)
|
||||
- **Storage footprint**: ~2-5 KB per workspace for memory, ~1-3 KB per session
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feature/my-feature`
|
||||
3. Make changes following the code style guidelines above
|
||||
4. Test manually in OpenCode session
|
||||
5. Commit with descriptive message: `git commit -m "Add feature: ..."`
|
||||
4. Run tests: `npm test && npm run typecheck`
|
||||
5. Commit with descriptive message: `git commit -m "feat: add ..."`
|
||||
6. Push to your fork: `git push origin feature/my-feature`
|
||||
7. Open a pull request
|
||||
|
||||
## Architecture Documentation
|
||||
|
||||
See `docs/architecture.md` for detailed technical documentation including:
|
||||
- Memory tier hierarchy
|
||||
- Pruning algorithms
|
||||
- Decay formulas
|
||||
- Pressure monitoring logic
|
||||
- Storage governance policies
|
||||
- Three-layer memory architecture
|
||||
- Memory extraction and quality gates
|
||||
- Error fingerprinting
|
||||
- Deduplication strategies
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: February 2026
|
||||
**Plugin Status**: Production (Phases 1-4.5 complete)
|
||||
**Last Updated**: April 2026
|
||||
**Plugin Status**: Production (Memory V2 architecture)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# 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.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,37 +1,40 @@
|
||||
# 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.
|
||||
OpenCode Working Memory helps your agent keep useful context across compactions and sessions: project decisions, preferences, important references, active files, and unresolved errors.
|
||||
|
||||
## What You Get
|
||||
It works automatically, without manual memory 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.
|
||||
|
||||
## Installation
|
||||
|
||||
### For Humans
|
||||
|
||||
**Option A: Let an LLM do it**
|
||||
|
||||
Paste this into Claude Code, Cursor, or any AI agent:
|
||||
|
||||
```
|
||||
Install opencode-working-memory plugin by following:
|
||||
https://raw.githubusercontent.com/sdwolf4103/opencode-working-memory/main/docs/installation.md
|
||||
```
|
||||
|
||||
**Option B: Manual setup**
|
||||
|
||||
1. Add to your `~/.config/opencode/opencode.json`:
|
||||
Add OpenCode Working Memory to your OpenCode config:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -39,204 +42,190 @@ https://raw.githubusercontent.com/sdwolf4103/opencode-working-memory/main/docs/i
|
||||
}
|
||||
```
|
||||
|
||||
2. Restart OpenCode
|
||||
|
||||
That's it! The plugin is now active.
|
||||
|
||||
### For LLM Agents
|
||||
|
||||
1. Read the OpenCode configuration file at `~/.config/opencode/opencode.json`
|
||||
2. Add `"opencode-working-memory"` to the `plugin` array
|
||||
3. Verify installation by checking the config file
|
||||
|
||||
## Quick Start
|
||||
|
||||
The plugin works automatically once installed. No configuration needed.
|
||||
|
||||
**Use the tools:**
|
||||
|
||||
```bash
|
||||
# Update persistent memory
|
||||
opencode run "use core_memory_update to set my goal"
|
||||
|
||||
# Read current memory state
|
||||
opencode run "use core_memory_read to show me what you remember"
|
||||
|
||||
# Add important items to working memory
|
||||
opencode run "use working_memory_add to remember this file path"
|
||||
```
|
||||
|
||||
**The agent will automatically:**
|
||||
- Track memory pressure and warn when approaching compaction
|
||||
- Preserve important context during compaction
|
||||
- Clean up old tool-output cache files every 20 tool calls
|
||||
- Remove artifacts when sessions are deleted
|
||||
|
||||
## Features
|
||||
|
||||
### 🧠 Core Memory (Phase 1)
|
||||
|
||||
Persistent blocks that survive conversation resets:
|
||||
|
||||
- **goal** (1000 chars) - Current task/objective
|
||||
- **progress** (2000 chars) - What's done, in-progress, next steps
|
||||
- **context** (1500 chars) - Key file paths, conventions, patterns
|
||||
|
||||
### 💡 Working Memory (Phase 3)
|
||||
|
||||
Auto-extracts and ranks important information:
|
||||
|
||||
- **Slots** (guaranteed visibility): errors, decisions, todos, dependencies
|
||||
- **Pool** (ranked by relevance): file paths, recent activity
|
||||
- Exponential decay keeps memory fresh
|
||||
- FIFO limits prevent bloat
|
||||
|
||||
### 🎯 Memory Pressure Monitoring (Phase 4)
|
||||
|
||||
Real-time token tracking from session database:
|
||||
|
||||
- Monitors context window usage (75% moderate → 90% high)
|
||||
- Proactive intervention messages when pressure is high
|
||||
- Pressure-aware smart pruning (adapts compression based on pressure)
|
||||
|
||||
### 🧹 Storage Governance (Phase 5)
|
||||
|
||||
Prevents unbounded disk growth:
|
||||
|
||||
- **Layer 1**: Auto-cleanup on session deletion (all artifacts removed)
|
||||
- **Layer 2**: Active cache management (max 300 files/session, 7-day TTL)
|
||||
- Triggers every 20 tool calls
|
||||
- Silent background operation
|
||||
|
||||
### 📊 Smart Pruning (Phase 2)
|
||||
|
||||
Intelligent tool output compression:
|
||||
|
||||
- Per-tool strategies (keep-all, keep-ends, keep-last, discard)
|
||||
- Pressure-aware limits (2k/5k/10k lines based on memory pressure)
|
||||
- Preserves important context while reducing noise
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Installation Guide](docs/installation.md) - Detailed setup instructions
|
||||
- [Architecture Overview](docs/architecture.md) - How it works under the hood
|
||||
- [Configuration](docs/configuration.md) - Customization options
|
||||
- [Agent Developer Guide](AGENTS.md) - For plugin developers
|
||||
|
||||
## Tools Provided
|
||||
|
||||
The plugin exposes these tools to your OpenCode agent:
|
||||
|
||||
- `core_memory_update` - Update goal/progress/context blocks
|
||||
- `core_memory_read` - Read current memory state
|
||||
- `working_memory_add` - Manually add important items
|
||||
- `working_memory_clear` - Clear all working memory
|
||||
- `working_memory_clear_slot` - Clear specific slot (errors/decisions)
|
||||
- `working_memory_remove` - Remove specific item by content
|
||||
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:
|
||||
|
||||
## Configuration (Optional)
|
||||
| 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 |
|
||||
|
||||
The plugin works great with zero configuration. But if you want to customize:
|
||||
## Workspace Memory
|
||||
|
||||
Create `~/.config/opencode/working-memory.json`:
|
||||
Workspace memory is for durable information that should help future sessions.
|
||||
|
||||
```json
|
||||
{
|
||||
"storage_governance": {
|
||||
"tool_output_max_files": 300,
|
||||
"tool_output_max_age_ms": 604800000,
|
||||
"sweep_interval": 20
|
||||
},
|
||||
"memory_pressure": {
|
||||
"thresholds": {
|
||||
"moderate": 0.75,
|
||||
"high": 0.90,
|
||||
"critical": 0.95
|
||||
}
|
||||
}
|
||||
}
|
||||
Examples:
|
||||
|
||||
```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/.
|
||||
```
|
||||
|
||||
See [Configuration Guide](docs/configuration.md) for all 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
|
||||
|
||||
## 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
|
||||
- Superseding older decisions with newer ones
|
||||
- Consolidation accounting so promoted, absorbed, superseded, and rejected memories are handled differently
|
||||
- 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.
|
||||
|
||||
## Configuration
|
||||
|
||||
OpenCode Working Memory works out of the box.
|
||||
|
||||
Default behavior:
|
||||
|
||||
- Workspace memory budget: 5200 characters
|
||||
- Workspace memory limit: 28 entries
|
||||
- Hot session state budget: 1200 characters
|
||||
- Active files shown: 8
|
||||
- Open errors shown: 3
|
||||
|
||||
See [Configuration](docs/configuration.md) for customization options.
|
||||
|
||||
## Ongoing Work
|
||||
|
||||
Current focus:
|
||||
|
||||
- Improve memory recording quality so only durable, useful facts are kept.
|
||||
- Strengthen deduplication and supersession so stale memories do not pile up.
|
||||
- Add better forgetting behavior for obsolete decisions, preferences, and project facts.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenCode >= 1.0.0
|
||||
- Node.js >= 18.0.0
|
||||
- `@opencode-ai/plugin` >= 1.2.0
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
||||
MIT License. See [LICENSE](LICENSE) for details.
|
||||
|
||||
## Support
|
||||
|
||||
- 📖 [Documentation](docs/)
|
||||
- 🐛 [Report Issues](https://github.com/sdwolf4103/opencode-working-memory/issues)
|
||||
- 💬 [Discussions](https://github.com/sdwolf4103/opencode-working-memory/discussions)
|
||||
|
||||
## Credits
|
||||
|
||||
Inspired by the needs of real-world OpenCode usage and built to solve actual pain points in AI-assisted development.
|
||||
- [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,265 @@
|
||||
# Release Notes
|
||||
|
||||
## 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
|
||||
+268
-277
@@ -2,374 +2,365 @@
|
||||
|
||||
## 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 │
|
||||
│ • Limits: 5200 chars / 28 entries │
|
||||
│ • Survives: session reset, compaction (same workspace) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TIER 2: WORKING MEMORY │
|
||||
│ Session-scoped slots + memory pool │
|
||||
│ Slots: error(3) | decision(5) | todo(3) | dependency(3) │
|
||||
│ Pool: Exponential decay (γ=0.85) + mention tracking │
|
||||
│ LAYER 2: HOT SESSION STATE (Short-term, per-session) │
|
||||
│ • Session-scoped tracking: active files, open errors │
|
||||
│ • Storage: sessions/{sessionID}.json │
|
||||
│ • Auto-extracted from tool usage patterns │
|
||||
│ • Cleared: on new session start │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TIER 3: SMART PRUNING │
|
||||
│ Filters tool outputs before adding to conversation │
|
||||
│ Removes: file lists, verbose logs, repetitive content │
|
||||
│ Modes: normal → aggressive → hyper-aggressive │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TIER 4: PRESSURE MONITORING │
|
||||
│ Tracks context usage: safe → moderate → high │
|
||||
│ Thresholds: 75% (moderate) | 90% (high) │
|
||||
│ Intervention: Sends promptAsync() with full visible prompt │
|
||||
│ LAYER 3: NATIVE OPENCODE STATE │
|
||||
│ • Uses OpenCode's built-in todos during compaction │
|
||||
│ • No additional storage required │
|
||||
│ • Delegated to OpenCode's native features │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Phase 1: Core Memory Foundation
|
||||
## Layer 1: Workspace Memory
|
||||
|
||||
### Purpose
|
||||
Provide persistent memory blocks that survive conversation compaction and are always injected into the system prompt.
|
||||
|
||||
Long-term memory that persists across sessions within the same workspace. Perfect for:
|
||||
- Project conventions and patterns
|
||||
- Important decisions that span sessions
|
||||
- User preferences for this codebase
|
||||
|
||||
### Storage
|
||||
- **Location**: `.opencode/memory-core/<sessionID>.json`
|
||||
|
||||
- **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json`
|
||||
- **Workspace Key**: First 16 chars of `sha256(realpath(workspaceRoot))`
|
||||
- **Schema**:
|
||||
```typescript
|
||||
{
|
||||
sessionID: string;
|
||||
blocks: {
|
||||
goal: { content: string; chars: number; maxChars: 1000; updatedAt: string };
|
||||
progress: { content: string; chars: number; maxChars: 2000; updatedAt: string };
|
||||
context: { content: string; chars: number; maxChars: 1500; updatedAt: string };
|
||||
};
|
||||
updatedAt: string;
|
||||
version: 1,
|
||||
workspace: { root: string, key: string },
|
||||
limits: { maxRenderedChars: 5200, maxEntries: 28 },
|
||||
entries: LongTermMemoryEntry[],
|
||||
updatedAt: string
|
||||
}
|
||||
```
|
||||
|
||||
### Character Limits
|
||||
- **goal**: 1000 chars (ONE specific task)
|
||||
- **progress**: 2000 chars (done/in-progress/blocked checklist)
|
||||
- **context**: 1500 chars (current working files + key patterns)
|
||||
### Entry Types
|
||||
|
||||
### Operations
|
||||
- **replace**: Completely replace block content
|
||||
- **append**: Add content to end (auto-adds newline)
|
||||
| Type | Purpose | Example |
|
||||
|------|---------|---------|
|
||||
| `feedback` | User preferences | "User prefers functional React components" |
|
||||
| `project` | Project-level info | "This monorepo uses turborepo" |
|
||||
| `decision` | Important decisions | "Use PostgreSQL for primary database" |
|
||||
| `reference` | Key references | "API endpoints defined in `src/api/`" |
|
||||
|
||||
### Tools
|
||||
- `core_memory_update`: Update or append to blocks
|
||||
- `core_memory_read`: Read current state of all blocks
|
||||
### Entry Sources
|
||||
|
||||
| Source | Confidence | How Added |
|
||||
|--------|------------|-----------|
|
||||
| `explicit` | 1.0 | User said "remember this" |
|
||||
| `compaction` | 0.75 | Extracted during compaction |
|
||||
| `manual` | varies | Programmatically added |
|
||||
|
||||
### Memory Extraction
|
||||
|
||||
During compaction, 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 and Deduplication
|
||||
|
||||
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. Group decisions and feedback by topic where possible.
|
||||
4. Keep the best surviving entry by source, confidence, type, and freshness rules.
|
||||
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.
|
||||
|
||||
### 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; obsolete same-topic memories are superseded; stale or over-capacity compaction memories are rejected.
|
||||
|
||||
## Quality Guarantees
|
||||
|
||||
### No False Positive Errors
|
||||
|
||||
```typescript
|
||||
pressure = (visiblePromptChars / estimatedContextLimit) * 100
|
||||
// Bad: Would create false positive
|
||||
"Error: something failed" in output
|
||||
|
||||
// Good: Actually failed
|
||||
exitCode === 1 && output.includes("Error")
|
||||
|
||||
// Good: Actually succeeded
|
||||
exitCode === 0 (clears errors for that category)
|
||||
|
||||
// Good: Ignore ambiguous cases
|
||||
exitCode === undefined → skip error tracking
|
||||
```
|
||||
|
||||
Where:
|
||||
- `visiblePromptChars`: Total characters in system prompt + tool outputs
|
||||
- `estimatedContextLimit`: ~180,000 chars (conservative estimate)
|
||||
|
||||
### Pressure Levels
|
||||
|
||||
| Level | Threshold | Behavior |
|
||||
|-------|-----------|----------|
|
||||
| **safe** | < 75% | Normal operation |
|
||||
| **moderate** | 75-89% | Warning in system prompt + aggressive pruning |
|
||||
| **high** | ≥ 90% | Hyper-aggressive pruning + intervention |
|
||||
|
||||
### Pressure Storage
|
||||
|
||||
- **Location**: `.opencode/memory-working/<sessionID>_pressure.json`
|
||||
- **Schema**:
|
||||
```typescript
|
||||
{
|
||||
sessionID: string;
|
||||
level: "safe" | "moderate" | "high";
|
||||
percentage: number;
|
||||
visiblePromptChars: number;
|
||||
estimatedLimit: 180000;
|
||||
lastChecked: string;
|
||||
interventionsSent: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Intervention Mechanism
|
||||
|
||||
When pressure reaches **high** (≥90%):
|
||||
1. Plugin sends `promptAsync()` message to agent
|
||||
2. Message includes full visible prompt for review
|
||||
3. Agent can compress core memory, clear working memory, or continue
|
||||
4. Intervention tracked in `interventionsSent` counter
|
||||
|
||||
### System Prompt Injection
|
||||
|
||||
```
|
||||
[Memory Pressure: 87% (high) - 156,600/180,000 chars]
|
||||
⚠️ High memory pressure detected. Consider:
|
||||
- Compressing core_memory blocks (use core_memory_update)
|
||||
- Clearing resolved errors (use working_memory_clear_slot)
|
||||
- Removing old pool items (auto-pruned at score < 0.01)
|
||||
```
|
||||
|
||||
## Phase 4.5: Storage Governance
|
||||
|
||||
### Purpose
|
||||
Prevent `.opencode/` directory bloat from accumulating tool output caches and orphaned memory files.
|
||||
|
||||
### Layer 1: Session Deletion Cleanup
|
||||
|
||||
**Trigger**: `experimental.session.deleted` hook
|
||||
|
||||
**Actions**:
|
||||
1. Remove `.opencode/memory-core/<sessionID>.json`
|
||||
2. Remove `.opencode/memory-working/<sessionID>.json`
|
||||
3. Remove `.opencode/memory-working/<sessionID>_pressure.json`
|
||||
4. Remove `.opencode/memory-working/<sessionID>_compaction.json`
|
||||
|
||||
### Layer 2: Tool Output Cache Sweep
|
||||
|
||||
**Trigger**: Every 500 events (`eventCounter % 500 === 0`)
|
||||
|
||||
**Target**: `.opencode/cache/tool-outputs/` directory
|
||||
|
||||
**Policy**:
|
||||
- Keep most recent **300 files** (sorted by mtime)
|
||||
- Delete files older than **7 days** (TTL policy)
|
||||
|
||||
**Logging**: Write sweep results to `.opencode/memory-working/<sessionID>_sweep.json`
|
||||
### Negative Memory Filtering
|
||||
|
||||
```typescript
|
||||
{
|
||||
sessionID: string;
|
||||
timestamp: string;
|
||||
eventCounter: number;
|
||||
results: {
|
||||
filesScanned: number;
|
||||
filesDeleted: number;
|
||||
bytesReclaimed: number;
|
||||
errors: Array<string>;
|
||||
};
|
||||
}
|
||||
// Correctly interpreted
|
||||
"don't remember this" → NOT added to memory
|
||||
"不要記住這個" → NOT added to memory
|
||||
"remember this" → added to memory candidates
|
||||
```
|
||||
|
||||
### Canonical Deduplication
|
||||
|
||||
```typescript
|
||||
// Same memory (after normalization)
|
||||
"Use npm cache for plugins"
|
||||
"USE NPM CACHE for plugins!!"
|
||||
"use npm cache for plugins."
|
||||
|
||||
// All map to same canonical key
|
||||
canonical("Use npm cache for plugins") === "use npm cache for plugins"
|
||||
```
|
||||
|
||||
### Compaction Quality Gate
|
||||
|
||||
```typescript
|
||||
// Rejected (not valuable as long-term memory)
|
||||
"4832b38 fix: something" // git hash
|
||||
"Error: something failed" // raw error
|
||||
"at Object.method (file.ts:42)" // stack trace
|
||||
"/Users/x/project/file.ts /Users/x/project/other.ts" // path-heavy
|
||||
|
||||
// Accepted
|
||||
"[decision] Use npm cache for plugin loading" // good pattern
|
||||
```
|
||||
|
||||
## File System Layout
|
||||
|
||||
```
|
||||
~/.local/share/opencode-working-memory/
|
||||
└── workspaces/
|
||||
└── {workspaceKey}/
|
||||
├── workspace-memory.json # Long-term memory
|
||||
└── sessions/
|
||||
└── {hashedSessionID}.json # Session state
|
||||
```
|
||||
|
||||
### Workspace Key
|
||||
|
||||
```typescript
|
||||
// First 16 chars of SHA-256 hash of workspace root realpath
|
||||
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Memory Budgets
|
||||
- **Core Memory**: 4,500 chars (injected every message)
|
||||
- **Working Memory**: ~1,600 chars (injected every message)
|
||||
- **Total Overhead**: ~6,100 chars per message
|
||||
|
||||
### Compaction Behavior
|
||||
When OpenCode compacts conversation (clears old messages):
|
||||
- Core memory: **Preserved** (persistent across compactions)
|
||||
- Working memory: **Preserved** (session-scoped, cleared on session end)
|
||||
- Pressure state: **Preserved** (tracks across compaction)
|
||||
- Compaction log: Saved to `<sessionID>_compaction.json`
|
||||
| Layer | Max Chars | Max Entries |
|
||||
|-------|-----------|-------------|
|
||||
| Workspace Memory | 5200 | 28 |
|
||||
| Hot Session State | 1200 | 8 files, 3 errors |
|
||||
|
||||
### Injection Overhead
|
||||
|
||||
- Workspace memory: ~200-500 chars per message
|
||||
- Hot session state: ~200-400 chars per message
|
||||
- Total: ~400-900 chars per message (minimal)
|
||||
|
||||
### Storage Footprint
|
||||
- Each session: 4 JSON files (~5-20 KB total)
|
||||
- Tool output cache: Max 300 files (~10-50 MB depending on outputs)
|
||||
- Sweep every 500 events keeps storage bounded
|
||||
|
||||
- Workspace memory: ~2-5 KB per workspace
|
||||
- Session state: ~1-3 KB per session
|
||||
- Auto-cleanup on workspace/session deletion
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Custom Slot Types
|
||||
To add new slot types:
|
||||
1. Update `SlotType` union in types
|
||||
2. Add to `SLOT_CONFIG` with max items
|
||||
3. Update `formatWorkingMemoryForPrompt()` for display
|
||||
4. Update extraction heuristics in `tool.execute.after`
|
||||
### Custom Memory Types
|
||||
|
||||
### Custom Pruning Rules
|
||||
To add pruning heuristics:
|
||||
1. Update `shouldPrune()` with new detection logic
|
||||
2. Add to `pruneToolOutput()` with filtering rules
|
||||
3. Test with representative tool outputs
|
||||
|
||||
### Custom Pressure Thresholds
|
||||
Adjust in constants:
|
||||
Add new types in `src/types.ts`:
|
||||
```typescript
|
||||
const PRESSURE_THRESHOLDS = {
|
||||
moderate: 70,
|
||||
high: 85,
|
||||
critical: 95,
|
||||
};
|
||||
export type LongTermType = "feedback" | "project" | "decision" | "reference" | "custom";
|
||||
```
|
||||
|
||||
## Migration & Compatibility
|
||||
### Custom Error Categories
|
||||
|
||||
### Old Format → New Format
|
||||
Plugin automatically migrates from old format:
|
||||
Add new categories in `src/types.ts`:
|
||||
```typescript
|
||||
// Old format (pre-Phase 3)
|
||||
{ items: Array<Item> }
|
||||
|
||||
// New format (Phase 3+)
|
||||
{ slots: Record<SlotType, Array<Item>>, pool: Array<Item> }
|
||||
export type ErrorCategory = "typecheck" | "test" | "lint" | "build" | "runtime" | "custom";
|
||||
```
|
||||
|
||||
Migration happens on first load of old format files.
|
||||
### Custom Extraction Patterns
|
||||
|
||||
## File System Layout
|
||||
Modify `src/extractors.ts` to add new extraction patterns.
|
||||
|
||||
```
|
||||
.opencode/
|
||||
├── memory-core/
|
||||
│ └── <sessionID>.json # Core memory blocks
|
||||
├── memory-working/
|
||||
│ ├── <sessionID>.json # Working memory (slots + pool)
|
||||
│ ├── <sessionID>_pressure.json # Pressure monitoring state
|
||||
│ ├── <sessionID>_compaction.json # Compaction event log
|
||||
│ └── <sessionID>_sweep.json # Storage sweep log
|
||||
└── cache/
|
||||
└── tool-outputs/
|
||||
└── *.json # Tool output cache (auto-swept)
|
||||
```
|
||||
## Migration Notes
|
||||
|
||||
## Security Considerations
|
||||
### Memory V1 to V2
|
||||
|
||||
- All files written with `0644` permissions (owner read/write, group/others read)
|
||||
- Directories created with `0755` permissions (owner rwx, group/others rx)
|
||||
- No sensitive data should be stored in memory blocks (user responsibility)
|
||||
- Session IDs are opaque identifiers, not derived from sensitive data
|
||||
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`
|
||||
|
||||
+140
-304
@@ -2,251 +2,127 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Working Memory Plugin works out-of-the-box with sensible defaults. Advanced users can customize behavior by modifying constants in `index.ts`.
|
||||
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: 5200, // Maximum characters in system prompt
|
||||
targetRenderedChars: 4200, // Target characters (leave buffer)
|
||||
maxEntries: 28, // Maximum number of entries
|
||||
maxEntryTextChars: 260, // Maximum characters per entry text
|
||||
maxRationaleChars: 180, // Maximum characters per entry rationale
|
||||
};
|
||||
```
|
||||
|
||||
**Recommendations**:
|
||||
- Keep **goal** focused on current task (clear when completed)
|
||||
- Use **progress** for checklists (avoid line numbers, commit hashes, API signatures)
|
||||
- Use **context** for files you're actively editing (avoid type definitions, function signatures)
|
||||
- Keep `maxRenderedChars` under 5500 to avoid context bloat
|
||||
- `maxEntries` of 28 provides good coverage without overwhelming
|
||||
- Entry text limits ensure entries stay concise
|
||||
|
||||
## Working Memory Configuration
|
||||
|
||||
### Slot Limits
|
||||
## Hot Session State Limits
|
||||
|
||||
```typescript
|
||||
const SLOT_CONFIG: Record<SlotType, number> = {
|
||||
error: 3, // Recent errors needing fixes
|
||||
decision: 5, // Important decisions made
|
||||
todo: 3, // Current task checklist
|
||||
dependency: 3, // File/package dependencies
|
||||
const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 1200, // Maximum characters in system prompt
|
||||
maxActiveFilesStored: 20, // Maximum files tracked in state
|
||||
maxActiveFilesRendered: 8, // Maximum files shown in prompt
|
||||
maxOpenErrorsStored: 5, // Maximum errors tracked
|
||||
maxOpenErrorsRendered: 3, // Maximum errors shown in prompt
|
||||
maxRecentDecisionsStored: 8, // Maximum decisions tracked
|
||||
};
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase slot limits if you need more items tracked
|
||||
- Decrease for stricter memory budgets
|
||||
- Total overhead: ~100-200 chars per item
|
||||
**Recommendations**:
|
||||
- Keep `maxRenderedChars` under 1500 for fast prompts
|
||||
- `maxActiveFilesRendered` of 8 provides good context coverage
|
||||
- `maxOpenErrorsRendered` of 3 avoids overwhelming error lists
|
||||
|
||||
### Memory Pool Decay
|
||||
## Memory Types
|
||||
|
||||
```typescript
|
||||
const POOL_DECAY_GAMMA = 0.85; // Exponential decay rate (15% per event)
|
||||
const POOL_MIN_SCORE = 0.01; // Items below this score are pruned
|
||||
### Long-Term Memory Types
|
||||
|
||||
| Type | Purpose | Stale After (days) |
|
||||
|------|---------|---------------------|
|
||||
| `feedback` | User preferences for workspace | 90 |
|
||||
| `project` | Project-level information | 60 |
|
||||
| `decision` | Important decisions | 45 |
|
||||
| `reference` | Key references | 90 |
|
||||
|
||||
### Memory Sources
|
||||
|
||||
| Source | Confidence | Description |
|
||||
|--------|------------|-------------|
|
||||
| `explicit` | 1.0 | User explicitly said "remember this" |
|
||||
| `compaction` | 0.75 | Extracted during conversation compaction |
|
||||
| `manual` | varies | Added programmatically |
|
||||
|
||||
## Active File Scoring
|
||||
|
||||
Files are ranked by action type:
|
||||
|
||||
| Action | Weight | Description |
|
||||
|--------|--------|-------------|
|
||||
| `write` | 4 | File created/overwritten |
|
||||
| `edit` | 3 | File modified |
|
||||
| `read` | 2 | File read |
|
||||
| `grep` | 1 | Grep searched in file |
|
||||
|
||||
Score formula: `count * action_weight * recency_decay`
|
||||
|
||||
## Error Categories
|
||||
|
||||
| Category | Recognition Pattern |
|
||||
|----------|---------------------|
|
||||
| `typecheck` | TS errors, TypeScript failures |
|
||||
| `test` | Test failures |
|
||||
| `lint` | ESLint warnings/errors |
|
||||
| `build` | Build failures |
|
||||
| `runtime` | Uncaught errors, Node exceptions |
|
||||
| `tool` | Tool execution failures |
|
||||
|
||||
## Storage Paths
|
||||
|
||||
```
|
||||
~/.local/share/opencode-working-memory/
|
||||
└── workspaces/
|
||||
└── {workspaceKey}/
|
||||
├── workspace-memory.json # Long-term memory
|
||||
└── sessions/
|
||||
└── {hashedSessionID}.json # Session state (hashed)
|
||||
```
|
||||
|
||||
**Formula**: `score = exp(-γ * age) + mentionCount`
|
||||
|
||||
**Tuning**:
|
||||
- Lower `γ` (e.g., 0.75) → faster decay, more aggressive pruning
|
||||
- Higher `γ` (e.g., 0.90) → slower decay, items stay longer
|
||||
- Lower `POOL_MIN_SCORE` (e.g., 0.005) → more items retained
|
||||
|
||||
### Pool Size Limits
|
||||
### Workspace Key
|
||||
|
||||
```typescript
|
||||
const POOL_MAX_ITEMS = 50; // Hard limit on pool size
|
||||
// First 16 characters of SHA-256 hash
|
||||
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16);
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase for longer sessions with more context
|
||||
- Decrease for stricter memory budgets
|
||||
- Each item adds ~50-150 chars to system prompt
|
||||
|
||||
## Pressure Monitoring
|
||||
|
||||
### Thresholds
|
||||
### Session ID
|
||||
|
||||
```typescript
|
||||
const PRESSURE_THRESHOLDS = {
|
||||
moderate: 70, // Warning appears in system prompt
|
||||
high: 85, // Aggressive pruning activates
|
||||
critical: 95, // Intervention sent to agent
|
||||
// Hashed session ID for privacy
|
||||
const hashedSessionID = sha256(sessionID).slice(0, 32);
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
To customize limits, edit the constants in `src/types.ts`:
|
||||
|
||||
```typescript
|
||||
// Example: Increase workspace memory limit
|
||||
export const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 6000, // Increased from 5200
|
||||
maxEntries: 35, // Increased from 28
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase thresholds for more relaxed monitoring
|
||||
- Decrease for earlier warnings and interventions
|
||||
|
||||
### Context Limit Estimate
|
||||
|
||||
```typescript
|
||||
const ESTIMATED_CONTEXT_LIMIT = 180000; // Conservative estimate (chars)
|
||||
```
|
||||
|
||||
**Note**: OpenCode actual limit varies by model. Adjust based on your observations.
|
||||
|
||||
## Smart Pruning
|
||||
|
||||
### Line Thresholds
|
||||
|
||||
```typescript
|
||||
// Normal mode (pressure < 75%)
|
||||
const PRUNE_THRESHOLD_NORMAL = 50;
|
||||
|
||||
// Aggressive mode (75% ≤ pressure < 90%)
|
||||
const PRUNE_THRESHOLD_AGGRESSIVE = 30;
|
||||
|
||||
// Hyper-aggressive mode (pressure ≥ 90%)
|
||||
const PRUNE_THRESHOLD_HYPER = 15;
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase thresholds to keep more tool output
|
||||
- Decrease for more aggressive pruning
|
||||
|
||||
### Keep Lines
|
||||
|
||||
```typescript
|
||||
// Normal mode
|
||||
const KEEP_LINES_NORMAL = 30; // Keep first/last 30 lines
|
||||
|
||||
// Aggressive mode
|
||||
const KEEP_LINES_AGGRESSIVE = 20; // Keep first/last 20 lines
|
||||
|
||||
// Hyper-aggressive mode
|
||||
const KEEP_LINES_HYPER = 10; // Keep first/last 10 lines
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase to preserve more context from tool outputs
|
||||
- Decrease for stricter truncation
|
||||
|
||||
## Storage Governance
|
||||
|
||||
### Session Cleanup
|
||||
|
||||
Automatically triggered on `experimental.session.deleted` hook. No configuration needed.
|
||||
|
||||
### Tool Output Cache Sweep
|
||||
|
||||
```typescript
|
||||
const SWEEP_INTERVAL = 500; // Trigger every N events
|
||||
const SWEEP_MAX_FILES = 300; // Keep most recent N files
|
||||
const SWEEP_TTL_DAYS = 7; // Delete files older than N days
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase `SWEEP_INTERVAL` for less frequent sweeps (lower overhead)
|
||||
- Increase `SWEEP_MAX_FILES` to cache more tool outputs (more disk usage)
|
||||
- Increase `SWEEP_TTL_DAYS` to keep older files longer
|
||||
|
||||
## Compaction Behavior
|
||||
|
||||
### Item Preservation
|
||||
|
||||
```typescript
|
||||
const COMPACTION_KEEP_ITEMS = 10; // Preserve N most recent items on compaction
|
||||
```
|
||||
|
||||
**Tuning**:
|
||||
- Increase to preserve more working memory across compactions
|
||||
- Decrease for stricter memory reset on compaction
|
||||
|
||||
## System Prompt Injection
|
||||
|
||||
### Core Memory Format
|
||||
|
||||
```typescript
|
||||
// Injected as:
|
||||
<core_memory>
|
||||
<goal chars="87/1000">...</goal>
|
||||
<progress chars="560/2000">...</progress>
|
||||
<context chars="479/1500">...</context>
|
||||
</core_memory>
|
||||
```
|
||||
|
||||
**Customization**: Modify `formatCoreMemoryForPrompt()` in `index.ts` to change format.
|
||||
|
||||
### Working Memory Format
|
||||
|
||||
```typescript
|
||||
// Injected as:
|
||||
<working_memory>
|
||||
Recent session context (auto-managed, sorted by relevance):
|
||||
|
||||
⚠️ Errors:
|
||||
- item content
|
||||
|
||||
📁 Key Files:
|
||||
- file path
|
||||
|
||||
(N items shown, updated: HH:MM:SS AM)
|
||||
</working_memory>
|
||||
```
|
||||
|
||||
**Customization**: Modify `formatWorkingMemoryForPrompt()` in `index.ts` to change:
|
||||
- Section emoji/icons
|
||||
- Display format
|
||||
- Item ordering
|
||||
|
||||
### Pressure Warning Format
|
||||
|
||||
```typescript
|
||||
// Injected as:
|
||||
[Memory Pressure: 87% (high) - 156,600/180,000 chars]
|
||||
⚠️ High memory pressure detected. Consider:
|
||||
- Action item 1
|
||||
- Action item 2
|
||||
```
|
||||
|
||||
**Customization**: Modify `formatPressureWarning()` in `index.ts`.
|
||||
|
||||
## Auto-Extraction Heuristics
|
||||
|
||||
### File Path Detection
|
||||
|
||||
```typescript
|
||||
// Detects:
|
||||
- Absolute paths: /users/name/project/file.ts
|
||||
- Relative paths: src/components/Button.tsx
|
||||
- Dot paths: ./utils/helpers.ts
|
||||
- Tilde paths: ~/project/file.ts
|
||||
```
|
||||
|
||||
**Customization**: Modify regex in `extractFilePaths()`.
|
||||
|
||||
### Error Detection
|
||||
|
||||
```typescript
|
||||
// Detects:
|
||||
- "Error:", "ERROR:", "error:"
|
||||
- Stack traces with "at " prefix
|
||||
- TypeScript errors with "TS####:"
|
||||
```
|
||||
|
||||
**Customization**: Modify `extractErrors()` heuristics.
|
||||
|
||||
### Decision Detection
|
||||
|
||||
```typescript
|
||||
// Detects:
|
||||
- "decided to...", "decision:", "chose to..."
|
||||
- "using X instead of Y"
|
||||
- "will use X approach"
|
||||
```
|
||||
|
||||
**Customization**: Modify `extractDecisions()` heuristics.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Currently, the plugin does not support environment variables. All configuration is done via constants in `index.ts`.
|
||||
|
||||
**Future Enhancement**: Consider adding `.env` support for:
|
||||
```
|
||||
OPENCODE_WM_CORE_GOAL_LIMIT=1000
|
||||
OPENCODE_WM_POOL_DECAY_GAMMA=0.85
|
||||
OPENCODE_WM_SWEEP_INTERVAL=500
|
||||
**Note**: After customization, rebuild the plugin:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
@@ -254,123 +130,83 @@ OPENCODE_WM_SWEEP_INTERVAL=500
|
||||
### High-Frequency Sessions (500+ messages)
|
||||
|
||||
```typescript
|
||||
// Aggressive pruning
|
||||
const PRUNE_THRESHOLD_NORMAL = 30;
|
||||
const PRUNE_THRESHOLD_AGGRESSIVE = 20;
|
||||
|
||||
// Faster decay
|
||||
const POOL_DECAY_GAMMA = 0.75;
|
||||
|
||||
// More frequent sweeps
|
||||
const SWEEP_INTERVAL = 250;
|
||||
// Reduce memory overhead
|
||||
const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 800, // Reduced
|
||||
maxActiveFilesRendered: 5, // Reduced
|
||||
maxOpenErrorsRendered: 2, // Reduced
|
||||
};
|
||||
```
|
||||
|
||||
### Long-Running Sessions (Multi-day)
|
||||
|
||||
```typescript
|
||||
// Preserve more context
|
||||
const POOL_MAX_ITEMS = 100;
|
||||
const COMPACTION_KEEP_ITEMS = 20;
|
||||
|
||||
// Slower decay
|
||||
const POOL_DECAY_GAMMA = 0.90;
|
||||
|
||||
// Longer TTL
|
||||
const SWEEP_TTL_DAYS = 14;
|
||||
const LONG_TERM_LIMITS = {
|
||||
maxEntries: 40, // Increased
|
||||
targetRenderedChars: 5000, // Increased
|
||||
};
|
||||
```
|
||||
|
||||
### Memory-Constrained Environments
|
||||
|
||||
```typescript
|
||||
// Strict limits
|
||||
const CORE_MEMORY_LIMITS = {
|
||||
goal: 500,
|
||||
progress: 1000,
|
||||
context: 800,
|
||||
const LONG_TERM_LIMITS = {
|
||||
maxRenderedChars: 3000,
|
||||
maxEntries: 15,
|
||||
};
|
||||
|
||||
const POOL_MAX_ITEMS = 20;
|
||||
|
||||
// Aggressive pruning
|
||||
const PRUNE_THRESHOLD_NORMAL = 20;
|
||||
const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 600,
|
||||
maxActiveFilesRendered: 4,
|
||||
};
|
||||
```
|
||||
|
||||
## Debugging Configuration
|
||||
|
||||
### Enable Verbose Logging
|
||||
|
||||
Add `console.log()` statements in key functions:
|
||||
|
||||
```typescript
|
||||
// In loadCoreMemory()
|
||||
console.log("[Core Memory] Loaded:", memory);
|
||||
|
||||
// In applyDecay()
|
||||
console.log("[Pool Decay] Pruned items:", prunedCount);
|
||||
|
||||
// In sweepToolOutputCache()
|
||||
console.log("[Sweep] Deleted files:", deletedCount);
|
||||
```
|
||||
## Debugging
|
||||
|
||||
### Inspect Memory Files
|
||||
|
||||
```bash
|
||||
# Core memory
|
||||
cat .opencode/memory-core/<sessionID>.json | jq
|
||||
# Workspace memory
|
||||
cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json | jq
|
||||
|
||||
# Working memory
|
||||
cat .opencode/memory-working/<sessionID>.json | jq
|
||||
|
||||
# Pressure state
|
||||
cat .opencode/memory-working/<sessionID>_pressure.json | jq
|
||||
|
||||
# Sweep log
|
||||
cat .opencode/memory-working/<sessionID>_sweep.json | jq
|
||||
# Session state
|
||||
cat ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json | jq
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
### Clear Workspace Memory
|
||||
|
||||
### Upgrading from Pre-Phase 3
|
||||
|
||||
Old format files are automatically migrated:
|
||||
|
||||
```typescript
|
||||
// Old format
|
||||
{ items: Array<Item> }
|
||||
|
||||
// New format (auto-migrated)
|
||||
{ slots: { error: [], decision: [], ... }, pool: [...] }
|
||||
```bash
|
||||
# Remove workspace memory (start fresh)
|
||||
rm ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json
|
||||
```
|
||||
|
||||
No manual intervention required.
|
||||
### Clear Session State
|
||||
|
||||
### Upgrading from Phase 3 to Phase 4.5
|
||||
|
||||
Storage governance is backward compatible. No migration needed.
|
||||
```bash
|
||||
# Remove all session states
|
||||
rm ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Core Memory Discipline**:
|
||||
- Clear `goal` immediately after task completion
|
||||
- Keep `progress` concise (use checklist format)
|
||||
- Only put actively edited files in `context`
|
||||
1. **Workspace Memory Hygiene**:
|
||||
- Let 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`
|
||||
|
||||
+88
-84
@@ -1,131 +1,135 @@
|
||||
# Installation Guide
|
||||
|
||||
## Prerequisites
|
||||
## Quick Install
|
||||
|
||||
- **OpenCode** 1.0.0 or higher
|
||||
- **Node.js** 18+ (for development only)
|
||||
|
||||
## Quick Install (For Users)
|
||||
|
||||
### Option 1: Install from npm (Recommended)
|
||||
|
||||
```bash
|
||||
npm install opencode-working-memory
|
||||
```
|
||||
|
||||
Then add to your `.opencode/package.json`:
|
||||
Add to your `~/.config/opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
"opencode-working-memory"
|
||||
]
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Install from GitHub
|
||||
Restart OpenCode. OpenCode Working Memory activates automatically — no manual setup needed.
|
||||
|
||||
Add to your `.opencode/package.json`:
|
||||
> **Note**: The correct key is `plugin` (singular), not `plugins`.
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"opencode-working-memory": "github:sdwolf4103/opencode-working-memory"
|
||||
},
|
||||
"plugins": [
|
||||
"opencode-working-memory"
|
||||
]
|
||||
}
|
||||
```
|
||||
## For LLM Agents
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
cd .opencode
|
||||
npm install
|
||||
```
|
||||
|
||||
### Option 3: Local Development Install
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sdwolf4103/opencode-working-memory.git
|
||||
cd opencode-working-memory
|
||||
npm install
|
||||
```
|
||||
|
||||
Link to your OpenCode project:
|
||||
|
||||
```bash
|
||||
cd /path/to/your/project/.opencode
|
||||
npm link /path/to/opencode-working-memory
|
||||
```
|
||||
|
||||
Add to `.opencode/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
"opencode-working-memory"
|
||||
]
|
||||
}
|
||||
```
|
||||
1. Edit `~/.config/opencode/opencode.json`
|
||||
2. Add `"opencode-working-memory"` to the `plugin` array
|
||||
3. Verify the config file was saved correctly
|
||||
|
||||
## Verification
|
||||
|
||||
After installation, start an OpenCode session and run:
|
||||
After restarting OpenCode, memory context appears automatically in system prompts. You'll see:
|
||||
|
||||
```
|
||||
core_memory_update goal "Test installation"
|
||||
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)
|
||||
```
|
||||
|
||||
You should see a success message. Check `.opencode/memory-core/` for the session file.
|
||||
**No tools to call**. OpenCode Working Memory works automatically via hooks.
|
||||
|
||||
## Configuration
|
||||
## How Memory Works
|
||||
|
||||
The plugin works out-of-the-box with sensible defaults. For advanced configuration, see [configuration.md](./configuration.md).
|
||||
### 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 `.opencode/package.json` includes plugin in `"plugins": []` array
|
||||
2. Verify `npm install` completed successfully
|
||||
3. Restart OpenCode session
|
||||
**Solution**:
|
||||
1. Check `~/.config/opencode/opencode.json` uses `"plugin"` (not `"plugins"`)
|
||||
2. Restart OpenCode to trigger automatic installation
|
||||
3. Check OpenCode logs for any download errors
|
||||
|
||||
### 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. Check plugin hooks are registered (look for "Working Memory Plugin" in session logs)
|
||||
3. 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
|
||||
|
||||
**Symptom**: TypeScript errors when modifying plugin
|
||||
**Symptom**: TypeScript errors when modifying the plugin source
|
||||
|
||||
**Solution**:
|
||||
1. Ensure `@opencode-ai/plugin` is installed: `npm install @opencode-ai/plugin`
|
||||
2. Run type checking: `npx tsc --noEmit`
|
||||
3. See [AGENTS.md](../AGENTS.md) for code style guidelines
|
||||
1. Run `npm install` to install dev dependencies
|
||||
2. Run `npm run typecheck` to check for errors
|
||||
3. Run `npm test` to verify functionality
|
||||
|
||||
## Uninstallation
|
||||
|
||||
Remove `"opencode-working-memory"` from the `plugin` array in `~/.config/opencode/opencode.json`.
|
||||
|
||||
Memory files in `~/.local/share/opencode-working-memory/` persist unless manually deleted.
|
||||
|
||||
## Manual Memory Management
|
||||
|
||||
### View Workspace Memory
|
||||
|
||||
```bash
|
||||
cd .opencode
|
||||
npm uninstall opencode-working-memory
|
||||
cat ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json | jq
|
||||
```
|
||||
|
||||
Remove from `.opencode/package.json` plugins array. Memory files in `.opencode/memory-*` will persist unless manually deleted.
|
||||
### 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
|
||||
|
||||
+21
-2
@@ -1,9 +1,23 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.0.0",
|
||||
"description": "Advanced four-tier memory architecture for OpenCode with intelligent pressure monitoring and auto-storage governance",
|
||||
"version": "1.3.0",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"files": [
|
||||
"index.ts",
|
||||
"src/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"opencode",
|
||||
"plugin",
|
||||
@@ -25,6 +39,11 @@
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"bun-types": "^1.2.21",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { createHash } from "crypto";
|
||||
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
|
||||
function id(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function hash(value: string): string {
|
||||
return createHash("sha1").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a memory request is negated (e.g., "不要記住", "don't remember").
|
||||
* Uses structured adjacency detection to avoid false positives.
|
||||
*/
|
||||
function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
|
||||
const prefix = text.slice(Math.max(0, matchIndex - 30), matchIndex);
|
||||
|
||||
// Chinese negative: 不要/別/不用 + optional 幫我, must be adjacent to trigger
|
||||
if (/(?:不要|別|别|不用|不需要|勿)\s*(?:幫我|帮我)?\s*$/u.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// English negative: do not / don't / never / not + optional please, must be adjacent to trigger
|
||||
if (/(?:do\s+not|don't|dont|never|not)\s+(?:please\s+)?$/i.test(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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[] {
|
||||
// 注意:所有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 now = new Date().toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const pattern of patterns) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const body = match[1]?.trim();
|
||||
if (!body || body.length < 8) continue;
|
||||
|
||||
// Calculate actual trigger position (after possible newline)
|
||||
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
|
||||
|
||||
// Check if this is a negated request (e.g., "不要記住")
|
||||
if (isNegatedMemoryRequest(text, triggerIndex)) continue;
|
||||
|
||||
// Check if it's a deferral (e.g., "later", "next time")
|
||||
if (/^(再说|再說|later|next time)$/i.test(body)) continue;
|
||||
|
||||
// Dedupe by canonical body
|
||||
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const type = classifyExplicitMemory(body);
|
||||
entries.push({
|
||||
id: id("mem"),
|
||||
type,
|
||||
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
staleAfterDays: staleAfterDaysFor(type),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function classifyExplicitMemory(text: string): LongTermType {
|
||||
const lower = text.toLowerCase();
|
||||
if (/https?:\/\/|linear|slack|notion|dashboard|grafana/.test(lower)) return "reference";
|
||||
if (/decide|decision|choose|chosen|决定|決定|选择|選擇/.test(lower)) return "decision";
|
||||
if (/project|repo|项目|專案/.test(lower)) return "project";
|
||||
return "feedback";
|
||||
}
|
||||
|
||||
export function staleAfterDaysFor(type: LongTermType): number | undefined {
|
||||
if (type === "feedback") return undefined;
|
||||
if (type === "decision") return 45;
|
||||
if (type === "project") return 60;
|
||||
return 90;
|
||||
}
|
||||
|
||||
export function extractActiveFiles(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
output: string,
|
||||
): Array<{ path: string; action: ActiveFile["action"] }> {
|
||||
if (toolName === "read" && typeof args.filePath === "string") return [{ path: args.filePath, action: "read" }];
|
||||
if (toolName === "edit" && typeof args.filePath === "string") return [{ path: args.filePath, action: "edit" }];
|
||||
if (toolName === "write" && typeof args.filePath === "string") return [{ path: args.filePath, action: "write" }];
|
||||
if (toolName === "grep") return extractGrepPaths(output).map(path => ({ path, action: "grep" as const }));
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractGrepPaths(output: string): string[] {
|
||||
const matches = output.match(/^(\/[^\n]+\.(ts|tsx|js|jsx|json|md|py|go|rs|toml|yml|yaml)):/gm) ?? [];
|
||||
return [...new Set(matches.map(match => match.replace(/:$/, "")))].slice(0, 10);
|
||||
}
|
||||
|
||||
function isErrorLine(line: string, knownValidationCommand: boolean): boolean {
|
||||
// 無條件捕捉的強訊號
|
||||
if (/TS\d{4}|ERR!|Traceback \(most recent call last\):|panic:/i.test(line)) return true;
|
||||
|
||||
// Error 類型前綴(獨立行)
|
||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(line)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 已知 validation command 才用寬鬆匹配
|
||||
if (knownValidationCommand) {
|
||||
return /\b(error|failed|failure|exception)\b/i.test(line);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractErrorsFromBash(command: string, output: string): OpenError[] {
|
||||
const classifiedCategory = classifyCommand(command);
|
||||
const knownValidationCommand = classifiedCategory !== null;
|
||||
|
||||
const lines = output
|
||||
.split("\n")
|
||||
.filter(line => isErrorLine(line, knownValidationCommand))
|
||||
.slice(0, 5);
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const category = classifiedCategory ?? "runtime";
|
||||
const summary = lines.join(" ").slice(0, 280);
|
||||
const fingerprint = hash(`${category}:${summary.toLowerCase().replace(/\s+/g, " ")}`);
|
||||
const now = Date.now();
|
||||
|
||||
return [
|
||||
{
|
||||
id: `err_${fingerprint}`,
|
||||
category,
|
||||
summary,
|
||||
command,
|
||||
file: extractFirstPath(summary),
|
||||
fingerprint,
|
||||
status: "open",
|
||||
firstSeen: now,
|
||||
lastSeen: now,
|
||||
seenCount: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function classifyCommand(command: string): OpenError["category"] | null {
|
||||
const c = command.toLowerCase();
|
||||
if (/\b(tsc|typecheck)\b/.test(c)) return "typecheck";
|
||||
if (/\b(test|vitest|jest|mocha|pytest|go test|cargo test)\b/.test(c)) return "test";
|
||||
if (/\b(lint|eslint|biome)\b/.test(c)) return "lint";
|
||||
if (/\b(build|vite build|webpack|tsup)\b/.test(c)) return "build";
|
||||
return null;
|
||||
}
|
||||
|
||||
function 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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality gate for workspace memory candidates.
|
||||
* Rejects low-quality entries like git hashes, error messages, etc.
|
||||
*/
|
||||
function shouldAcceptWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
},
|
||||
options: {
|
||||
fromMemoryTrigger?: boolean;
|
||||
} = {},
|
||||
): boolean {
|
||||
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 false;
|
||||
}
|
||||
|
||||
// Git history / commit hash
|
||||
if (/\b[0-9a-f]{7,40}\b/.test(text)) return false;
|
||||
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return false;
|
||||
|
||||
// Raw error / stack trace
|
||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError):/i.test(text)) return false;
|
||||
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return false;
|
||||
|
||||
// Active file list
|
||||
if (/^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text)) return false;
|
||||
|
||||
// Temporary progress
|
||||
if (/^(currently|now|pending|in progress|todo|wip):/i.test(text)) return false;
|
||||
|
||||
// Code signature / API doc
|
||||
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
|
||||
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false;
|
||||
|
||||
// Path-heavy facts (rediscoverable from repo)
|
||||
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
||||
if (pathCount > 2) return false;
|
||||
|
||||
// Session-specific progress snapshots for project type
|
||||
if (entry.type === "project") {
|
||||
if (isProjectSnapshotViolation(text)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isProjectSnapshotViolation(text: string): boolean {
|
||||
// Test/suite counts
|
||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||
|
||||
// File counts with snapshot/process context only, not static limits
|
||||
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;
|
||||
}
|
||||
|
||||
// Phase/Wave/Sprint/Milestone/Task progress
|
||||
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;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate block from summary using multiple formats.
|
||||
* Supports: Plain text label, Markdown section, legacy XML.
|
||||
*/
|
||||
function extractCandidateBlock(summary: string): string | null {
|
||||
// 1. Plain text label (primary format, no Markdown header)
|
||||
const plainMatch = summary.match(/Memory candidates:\s*\n([\s\S]*?)(?:\n[A-Z][a-z]+ [a-z]+:|\n##\s|$)/i);
|
||||
if (plainMatch) return plainMatch[1];
|
||||
|
||||
// 2. Markdown section (legacy)
|
||||
const markdownMatch = summary.match(/##\s*Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||||
if (markdownMatch) return markdownMatch[1];
|
||||
|
||||
// 3. Legacy "Workspace Memory Candidates" section
|
||||
const legacyMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||||
if (legacyMatch) return legacyMatch[1];
|
||||
|
||||
// 4. Legacy XML block (backward compatible)
|
||||
const xmlMatch = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
|
||||
if (xmlMatch) return xmlMatch[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
|
||||
const block = extractCandidateBlock(summary);
|
||||
if (!block) return [];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
|
||||
for (const line of block.split("\n")) {
|
||||
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
||||
const item = line.trim().match(
|
||||
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
|
||||
);
|
||||
if (!item) continue;
|
||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||
const normalizedBody = normalizeCandidateBody(item[3]);
|
||||
if (!normalizedBody) continue;
|
||||
|
||||
const minLength = normalizedBody.hadTrigger ? 6 : 12;
|
||||
if (normalizedBody.text.length < minLength) continue;
|
||||
|
||||
// Apply quality gate
|
||||
if (!shouldAcceptWorkspaceMemoryCandidate(
|
||||
{ type, text: normalizedBody.text },
|
||||
{ fromMemoryTrigger: normalizedBody.hadTrigger },
|
||||
)) continue;
|
||||
|
||||
entries.push({
|
||||
id: id("mem"),
|
||||
type,
|
||||
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
staleAfterDays: staleAfterDaysFor(type),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* OpenCode SDK helper functions for memory plugin.
|
||||
*
|
||||
* These functions wrap OpenCode client API calls to extract:
|
||||
* - Latest user message text (for explicit memory extraction)
|
||||
* - Latest compaction summary (for memory candidate parsing)
|
||||
* - Pending todos (for compaction context)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract the latest user message text from a session.
|
||||
* Returns { id, text } or null if no user message found.
|
||||
*/
|
||||
export async function latestUserText(
|
||||
client: unknown,
|
||||
sessionID: string
|
||||
): Promise<{ id: string; text: string } | null> {
|
||||
try {
|
||||
// Cast client to access session.messages API
|
||||
const api = client as {
|
||||
session: {
|
||||
messages: (params: { path: { id: string } }) => Promise<{
|
||||
data?: Array<{
|
||||
info?: {
|
||||
role?: string;
|
||||
id?: string;
|
||||
};
|
||||
parts?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const result = await api.session.messages({ path: { id: sessionID } });
|
||||
const messages = result.data ?? [];
|
||||
|
||||
// Scan backwards from most recent messages to find the latest user message
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.info?.role !== "user") continue;
|
||||
|
||||
// Concatenate all text parts
|
||||
const text = (msg.parts ?? [])
|
||||
.filter((p: { type?: string }) => p.type === "text")
|
||||
.map((p: { text?: string }) => p.text ?? "")
|
||||
.join("\n");
|
||||
|
||||
if (text.trim()) {
|
||||
return {
|
||||
id: msg.info?.id ?? "",
|
||||
text: text.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the latest compaction summary from a session.
|
||||
* Compaction summaries are assistant messages marked with summary=true.
|
||||
*/
|
||||
export async function latestCompactionSummary(
|
||||
client: unknown,
|
||||
sessionID: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const api = client as {
|
||||
session: {
|
||||
messages: (params: { path: { id: string } }) => Promise<{
|
||||
data?: Array<{
|
||||
info?: {
|
||||
role?: string;
|
||||
summary?: boolean;
|
||||
};
|
||||
parts?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const result = await api.session.messages({ path: { id: sessionID } });
|
||||
const messages = result.data ?? [];
|
||||
|
||||
// Scan backwards to find the most recent summary (compaction)
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.info?.role !== "assistant" || msg.info?.summary !== true) continue;
|
||||
|
||||
const text = (msg.parts ?? [])
|
||||
.filter((p: { type?: string }) => p.type === "text")
|
||||
.map((p: { text?: string }) => p.text ?? "")
|
||||
.join("\n");
|
||||
|
||||
if (text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pending todos from a session.
|
||||
* Returns todos that are not marked as completed.
|
||||
*/
|
||||
export async function pendingTodos(
|
||||
client: unknown,
|
||||
sessionID: string
|
||||
): Promise<Array<{ content: string; status: string; priority?: string }>> {
|
||||
try {
|
||||
const api = client as {
|
||||
session: {
|
||||
todo: (params: { path: { id: string } }) => Promise<{
|
||||
data?: Array<{
|
||||
content?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const result = await api.session.todo({ path: { id: sessionID } });
|
||||
const todos = result.data ?? [];
|
||||
|
||||
// Filter out completed todos
|
||||
return todos
|
||||
.filter((todo: { status?: string }) => todo.status !== "completed")
|
||||
.map((todo: { content?: string; status?: string; priority?: string }) => ({
|
||||
content: todo.content ?? "",
|
||||
status: todo.status ?? "pending",
|
||||
priority: todo.priority,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is a sub-agent (has a parent session).
|
||||
* Sub-agents are short-lived and should not have their own memory tracking.
|
||||
*/
|
||||
export async function isSubAgent(
|
||||
client: unknown,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const api = client as {
|
||||
session: {
|
||||
get: (params: { path: { id: string } }) => Promise<{
|
||||
data?: {
|
||||
parentID?: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const result = await api.session.get({ path: { id: sessionID } });
|
||||
return result.data?.parentID != null;
|
||||
} catch {
|
||||
// If we can't determine, assume it's NOT a sub-agent (safe default)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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 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`);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts";
|
||||
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
|
||||
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);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(entry);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeJournal(
|
||||
root: string,
|
||||
store: PendingMemoryJournalStore,
|
||||
): Promise<PendingMemoryJournalStore> {
|
||||
return workspaceKey(root).then(key => ({
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
// TODO(memory-consolidation follow-up): add the deferred pending journal
|
||||
// safety cap (max entries and old compaction pruning). P0 currently relies
|
||||
// on promotion accounting to clear terminal compaction candidates without
|
||||
// changing journal capacity behavior.
|
||||
entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []),
|
||||
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>): Promise<void> {
|
||||
await updatePendingJournal(root, store => {
|
||||
if (!keys || keys.size === 0) {
|
||||
store.entries = [];
|
||||
return store;
|
||||
}
|
||||
store.entries = store.entries.filter(entry => !keys.has(memoryKey(entry)));
|
||||
return store;
|
||||
});
|
||||
}
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* 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 {
|
||||
extractExplicitMemories,
|
||||
extractActiveFiles,
|
||||
extractErrorsFromBash,
|
||||
parseWorkspaceMemoryCandidates,
|
||||
} from "./extractors.ts";
|
||||
import {
|
||||
loadWorkspaceMemory,
|
||||
updateWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
renderWorkspaceMemory,
|
||||
} from "./workspace-memory.ts";
|
||||
import {
|
||||
appendPendingMemories,
|
||||
clearPendingMemories,
|
||||
hasPendingJournalEntries,
|
||||
loadPendingJournal,
|
||||
memoryKey,
|
||||
} 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 } from "./promotion-accounting.ts";
|
||||
|
||||
/**
|
||||
* Build the complete compaction prompt.
|
||||
*
|
||||
* Replaces OpenCode's default template (which uses --- separators that trigger
|
||||
* YAML frontmatter comment scope in markdown rendering, producing purple italic text).
|
||||
* Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter,
|
||||
* horizontal rules, and delimiter lines.
|
||||
*
|
||||
* @param privateContext - Background context (workspace memory, hot session state,
|
||||
* pending todos) from our plugin and any other plugins. Shown to the model to
|
||||
* inform the summary but not copied verbatim.
|
||||
*/
|
||||
function buildCompactionPrompt(privateContext: string): string {
|
||||
return [
|
||||
"Provide a detailed summary for continuing our conversation above.",
|
||||
"Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.",
|
||||
"",
|
||||
"Do not call any tools. Respond only with the summary text.",
|
||||
"Respond in the same language as the user's messages in the conversation.",
|
||||
"",
|
||||
"Formatting rules:",
|
||||
"- Start the response with \"## Goal\".",
|
||||
"- Use Markdown headings only.",
|
||||
"- Do not output YAML frontmatter.",
|
||||
"- Do not output horizontal rules.",
|
||||
"- Do not wrap the summary in delimiter lines such as ---.",
|
||||
"- Do not use code fences around the summary.",
|
||||
"",
|
||||
"Use this structure:",
|
||||
"",
|
||||
"## Goal",
|
||||
"",
|
||||
"## Instructions",
|
||||
"",
|
||||
"## Progress",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"",
|
||||
"## Discoveries",
|
||||
"",
|
||||
"## Next Steps",
|
||||
"",
|
||||
"## Relevant Files",
|
||||
"",
|
||||
"At the end of the summary, extract durable memory entries for future sessions.",
|
||||
"",
|
||||
"Memory quality bar:",
|
||||
"Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.",
|
||||
"",
|
||||
"Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.",
|
||||
"",
|
||||
"When unsure, skip it. Fewer high-signal memories are better than many low-value ones.",
|
||||
"",
|
||||
"Good memory examples:",
|
||||
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
|
||||
"- [decision] Use frozen workspace memory snapshots plus ephemeral hot state for cache stability.",
|
||||
"- [project] The plugin should piggyback memory extraction on OpenCode compaction and avoid extra LLM calls.",
|
||||
"- [reference] Workspace memory appears in frozen system[1]; pending memories appear in hot session state until compaction.",
|
||||
"",
|
||||
"Bad memory examples to skip:",
|
||||
"- 42 tests passed.",
|
||||
"- Wave 2 completed successfully.",
|
||||
"- Modified 5 files.",
|
||||
"- commit 4309cb8 contains the latest fix.",
|
||||
"- TypeError: Cannot read properties of undefined.",
|
||||
"- Currently running npm test.",
|
||||
"",
|
||||
"A memory should still be useful if a new agent opens this workspace next week.",
|
||||
"",
|
||||
"Only extract facts that are likely to stay true across sessions.",
|
||||
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
|
||||
"For progress, extract the stable goal or durable milestone, not the current number.",
|
||||
"For references, extract configuration values that do not usually change between sessions.",
|
||||
"For feedback, extract unresolved issues or user preferences that future sessions need to know.",
|
||||
"Use exactly this candidate format, including square brackets around the type:",
|
||||
"",
|
||||
"Memory candidates:",
|
||||
"- [feedback] content",
|
||||
"- [project] content",
|
||||
"- [decision] content",
|
||||
"- [reference] content",
|
||||
"",
|
||||
"Do not write '- project content'; write '- [project] content'.",
|
||||
"",
|
||||
"Background context, use this to inform the summary above.",
|
||||
"Do not output this context verbatim:",
|
||||
"",
|
||||
privateContext,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render todos for compaction context (plain text format, no Markdown headers).
|
||||
*/
|
||||
function renderTodosForCompaction(todos: Array<{ content: string; status: string; priority?: string }>): string {
|
||||
if (todos.length === 0) return "";
|
||||
const lines = ["Pending todos:"];
|
||||
for (const todo of todos) {
|
||||
const priority = todo.priority ? ` [${todo.priority}]` : "";
|
||||
const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○";
|
||||
lines.push(`- ${status} ${todo.content}${priority}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
const { directory, client } = input;
|
||||
|
||||
// Cache for sub-agent detection — avoids repeated API calls per session.
|
||||
// Maps sessionID → parentID (string) or null (root session).
|
||||
const sessionParentCache = new Map<string, string | null>();
|
||||
|
||||
async function isSubAgent(sessionID: string): Promise<boolean> {
|
||||
if (sessionParentCache.has(sessionID)) {
|
||||
return sessionParentCache.get(sessionID) !== null;
|
||||
}
|
||||
try {
|
||||
const result = await client.session.get({ path: { id: sessionID } });
|
||||
const parentID = result.data?.parentID ?? null;
|
||||
sessionParentCache.set(sessionID, parentID);
|
||||
return parentID !== null;
|
||||
} catch {
|
||||
// If we can't determine, assume it's NOT a sub-agent (safe default).
|
||||
sessionParentCache.set(sessionID, null);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for frozen workspace memory per session
|
||||
const frozenWorkspaceMemoryCache = new Map<
|
||||
string,
|
||||
{
|
||||
store: Awaited<ReturnType<typeof loadWorkspaceMemory>>;
|
||||
renderedPrompt: string;
|
||||
loadedAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Cache for processed user message IDs (to avoid duplicate processing)
|
||||
const processedUserMessages = new Map<string, Set<string>>();
|
||||
|
||||
async function processLatestUserMessage(sessionID: string): Promise<void> {
|
||||
const processedForSession = processedUserMessages.get(sessionID) ?? new Set<string>();
|
||||
const latestMessage = await latestUserText(client, sessionID);
|
||||
|
||||
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
|
||||
|
||||
const memories = extractExplicitMemories(latestMessage.text);
|
||||
const decisions = memories.filter(memory => memory.type === "decision");
|
||||
|
||||
if (memories.length > 0) {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.pendingMemories.push(...memories);
|
||||
return state;
|
||||
});
|
||||
await appendPendingMemories(directory, memories);
|
||||
}
|
||||
|
||||
if (decisions.length > 0) {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
for (const decision of decisions) {
|
||||
addRecentDecision(state, {
|
||||
text: decision.text,
|
||||
rationale: decision.rationale,
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
processedForSession.add(latestMessage.id);
|
||||
processedUserMessages.set(sessionID, processedForSession);
|
||||
}
|
||||
|
||||
async function promotePendingMemories(sessionID?: string): Promise<void> {
|
||||
const [journal, sessionState] = await Promise.all([
|
||||
loadPendingJournal(directory),
|
||||
sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const pending = [
|
||||
...(sessionState?.pendingMemories ?? []),
|
||||
...journal.entries,
|
||||
];
|
||||
if (pending.length === 0) return;
|
||||
|
||||
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
|
||||
|
||||
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
|
||||
beforeEntries = [...workspaceMemory.entries];
|
||||
const existingKeys = new Set(
|
||||
workspaceMemory.entries
|
||||
.filter(memory => memory.status !== "superseded")
|
||||
.map(memory => memoryKey(memory)),
|
||||
);
|
||||
|
||||
for (const memory of pending) {
|
||||
const key = memoryKey(memory);
|
||||
if (!existingKeys.has(key)) {
|
||||
workspaceMemory.entries.push(memory);
|
||||
existingKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return workspaceMemory;
|
||||
});
|
||||
|
||||
const accounting = accountPendingPromotions({
|
||||
pending,
|
||||
before: beforeEntries,
|
||||
after: updateResult.store.entries,
|
||||
events: updateResult.events,
|
||||
});
|
||||
|
||||
if (sessionID) {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.pendingMemories = state.pendingMemories.filter(memory => !accounting.clearableKeys.has(memoryKey(memory)));
|
||||
return state;
|
||||
});
|
||||
clearFrozenWorkspaceMemoryCache(sessionID);
|
||||
}
|
||||
|
||||
if (accounting.clearableKeys.size > 0) {
|
||||
await clearPendingMemories(directory, accounting.clearableKeys);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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 renderedPrompt = renderWorkspaceMemory(store);
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: 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;
|
||||
|
||||
// Sub-agents are short-lived - skip memory system
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Before first snapshot in this session, promote durable pending memories from
|
||||
// prior sessions. Keep this before processing latest user text so current-turn
|
||||
// explicit memory remains pending (not immediately frozen into system[1]).
|
||||
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
||||
await promotePendingMemories();
|
||||
}
|
||||
|
||||
// Process explicit user memory even on no-tool turns.
|
||||
await processLatestUserMessage(sessionID);
|
||||
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
|
||||
// Track tool usage and update session state
|
||||
"tool.execute.after": async (hookInput, hookOutput) => {
|
||||
const { sessionID, tool: toolName, args } = hookInput;
|
||||
const { output: toolOutput } = hookOutput;
|
||||
if (!sessionID) return;
|
||||
|
||||
// Sub-agents don't need memory tracking
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
// Track active files from tool usage
|
||||
if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") {
|
||||
const files = extractActiveFiles(
|
||||
toolName,
|
||||
args as Record<string, unknown>,
|
||||
toolOutput ?? ""
|
||||
);
|
||||
for (const { path, action } of files) {
|
||||
touchActiveFile(state, path, action);
|
||||
if (action === "edit" || action === "write") {
|
||||
markErrorsMaybeFixedForFile(state, path, directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track errors from failed bash commands
|
||||
if (toolName === "bash") {
|
||||
const argsRecord = args as Record<string, unknown>;
|
||||
const command: string = typeof argsRecord?.command === "string"
|
||||
? argsRecord.command
|
||||
: "";
|
||||
const outputText: string = toolOutput ?? "";
|
||||
|
||||
// Check if command succeeded - clear errors for that category
|
||||
const exitCode = bashExitCode(hookOutput);
|
||||
if (typeof exitCode !== "number") {
|
||||
// Unknown exit status: do not extract and do not clear
|
||||
} else if (exitCode === 0 && command) {
|
||||
clearErrorsForSuccessfulCommand(state, command);
|
||||
} else if (command) {
|
||||
// Only extract errors for commands with explicit non-zero exit
|
||||
const errors = extractErrorsFromBash(command, outputText);
|
||||
for (const error of errors) {
|
||||
upsertOpenError(state, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
});
|
||||
|
||||
// Process explicit memory from latest user message
|
||||
// Only process once per message ID
|
||||
await processLatestUserMessage(sessionID);
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the default compaction prompt with a ---free template.
|
||||
*
|
||||
* OpenCode's default template wraps sections in --- separators. When the
|
||||
* model follows the template (which our structured context encourages),
|
||||
* the TUI renders --- at position 0 as YAML frontmatter, applying the
|
||||
* "comment" syntax scope (purple italic in palenight theme).
|
||||
*
|
||||
* We set output.prompt to replace the entire prompt, removing all ---
|
||||
* and explicitly forbidding YAML frontmatter / horizontal rules.
|
||||
*/
|
||||
"experimental.session.compacting": async (hookInput, output) => {
|
||||
const { sessionID } = hookInput;
|
||||
if (!sessionID) return;
|
||||
|
||||
// Sub-agents don't need compaction support
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Preserve context injected by other plugins that ran before us.
|
||||
// Setting output.prompt bypasses the default prompt + context join,
|
||||
// so we must explicitly carry forward any existing output.context.
|
||||
const otherContext = output.context.filter(Boolean).join("\n\n");
|
||||
|
||||
// Build our private context (workspace memory, hot state, todos)
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. Frozen workspace memory 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;
|
||||
},
|
||||
|
||||
// Handle session events
|
||||
event: async ({ event }) => {
|
||||
if (event.type === "session.compacted") {
|
||||
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 candidates = summary ? parseWorkspaceMemoryCandidates(summary) : [];
|
||||
if (candidates.length > 0) {
|
||||
await appendPendingMemories(directory, candidates);
|
||||
}
|
||||
|
||||
try {
|
||||
await promotePendingMemories(sessionID);
|
||||
} catch {
|
||||
// Keep pending memories in session/journal for retry on next event/session.
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = sessionIDFromEventProperties(event.properties);
|
||||
if (sessionID) {
|
||||
// Promote pending memories before deleting per-session state.
|
||||
// If promotion fails, leave session state and journal intact.
|
||||
try {
|
||||
await promotePendingMemories(sessionID);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up caches
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
processedUserMessages.delete(sessionID);
|
||||
sessionParentCache.delete(sessionID);
|
||||
await rm(await sessionStatePath(directory, sessionID), { force: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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";
|
||||
|
||||
export type PendingPromotionAccounting = {
|
||||
promotedKeys: Set<string>;
|
||||
absorbedKeys: Set<string>;
|
||||
supersededKeys: Set<string>;
|
||||
rejectedKeys: 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" || terminal.reason === "rejected_stale") {
|
||||
rejectedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (afterIdentityKeys.has(identityKey)) {
|
||||
absorbedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
rejectedKeys.add(key);
|
||||
}
|
||||
|
||||
return {
|
||||
promotedKeys,
|
||||
absorbedKeys,
|
||||
supersededKeys,
|
||||
rejectedKeys,
|
||||
clearableKeys: new Set([
|
||||
...promotedKeys,
|
||||
...absorbedKeys,
|
||||
...supersededKeys,
|
||||
...input.pending
|
||||
.filter(memory => {
|
||||
const terminal = terminalEventByKey.get(memoryKey(memory));
|
||||
return memory.source === "compaction" && (
|
||||
terminal?.reason === "rejected_capacity" ||
|
||||
terminal?.reason === "rejected_stale"
|
||||
);
|
||||
})
|
||||
.map(memory => memoryKey(memory)),
|
||||
]),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { existsSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
|
||||
const fileLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
||||
if (!existsSync(path)) return fallback();
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
export async function updateJSON<T>(
|
||||
path: string,
|
||||
fallback: () => T,
|
||||
updater: (current: T) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = fileLocks.get(path) ?? Promise.resolve();
|
||||
let release: () => void = () => {};
|
||||
const currentLock = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const queued = previous.then(() => currentLock, () => currentLock);
|
||||
fileLocks.set(path, queued);
|
||||
|
||||
try {
|
||||
await previous.catch(() => undefined);
|
||||
const current = await readJSON(path, fallback);
|
||||
const updated = await updater(current);
|
||||
await atomicWriteJSON(path, updated);
|
||||
return updated;
|
||||
} finally {
|
||||
release();
|
||||
if (fileLocks.get(path) === queued) {
|
||||
fileLocks.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
export type LongTermType = "feedback" | "project" | "decision" | "reference";
|
||||
|
||||
export type LongTermSource = "explicit" | "compaction" | "manual";
|
||||
|
||||
export type LongTermMemoryEntry = {
|
||||
id: string;
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
rationale?: string;
|
||||
source: LongTermSource;
|
||||
confidence: number;
|
||||
status: "active" | "superseded";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
staleAfterDays?: number;
|
||||
supersedes?: string[];
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryStore = {
|
||||
version: 1;
|
||||
workspace: {
|
||||
root: string;
|
||||
key: string;
|
||||
};
|
||||
limits: {
|
||||
maxRenderedChars: number;
|
||||
maxEntries: number;
|
||||
};
|
||||
entries: LongTermMemoryEntry[];
|
||||
migrations?: string[];
|
||||
updatedAt: 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: 5200,
|
||||
targetRenderedChars: 4200,
|
||||
maxEntries: 28,
|
||||
maxEntryTextChars: 260,
|
||||
maxRationaleChars: 180,
|
||||
} as const;
|
||||
|
||||
export const HOT_STATE_LIMITS = {
|
||||
maxRenderedChars: 1200,
|
||||
maxActiveFilesStored: 20,
|
||||
maxActiveFilesRendered: 8,
|
||||
maxOpenErrorsStored: 5,
|
||||
maxOpenErrorsRendered: 3,
|
||||
maxRecentDecisionsStored: 8,
|
||||
maxPendingMemoriesStored: 12,
|
||||
maxPendingMemoriesRendered: 6,
|
||||
} as const;
|
||||
@@ -0,0 +1,630 @@
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
const MIN_ENVELOPE_LENGTH = 80;
|
||||
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
||||
|
||||
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
||||
|
||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||
|
||||
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+(?![是=::])))`;
|
||||
|
||||
export type MemoryConsolidationReason =
|
||||
| "promoted"
|
||||
| "absorbed_exact"
|
||||
| "absorbed_identity"
|
||||
| "superseded_existing"
|
||||
| "rejected_capacity"
|
||||
| "rejected_stale";
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||||
store: WorkspaceMemoryStore;
|
||||
events: MemoryConsolidationEvent[];
|
||||
};
|
||||
|
||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
return {
|
||||
version: 1,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
limits: {
|
||||
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: [],
|
||||
migrations: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// Always normalize on load so redaction/migrations are always-on.
|
||||
const normalized = await normalizeWorkspaceMemory(root, store);
|
||||
|
||||
// Persist only when meaningful content changed (ignore timestamps).
|
||||
if (didStoreMeaningfullyChange(store, normalized)) {
|
||||
await atomicWriteJSON(path, normalized);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function didStoreMeaningfullyChange(
|
||||
before: WorkspaceMemoryStore,
|
||||
after: WorkspaceMemoryStore,
|
||||
): boolean {
|
||||
const sanitize = (store: WorkspaceMemoryStore) => ({
|
||||
...store,
|
||||
updatedAt: "",
|
||||
entries: store.entries.map(entry => ({
|
||||
...entry,
|
||||
updatedAt: "",
|
||||
})),
|
||||
});
|
||||
|
||||
return JSON.stringify(sanitize(before)) !== JSON.stringify(sanitize(after));
|
||||
}
|
||||
|
||||
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: [],
|
||||
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 migration for legacy snapshot violations
|
||||
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);
|
||||
|
||||
const normalizedStore = {
|
||||
...result,
|
||||
entries: [...accounting.kept, ...supersededEntries],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
return {
|
||||
store: normalizedStore,
|
||||
kept: accounting.kept,
|
||||
dropped: accounting.dropped,
|
||||
absorbed: accounting.absorbed,
|
||||
superseded: accounting.superseded,
|
||||
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
||||
};
|
||||
}
|
||||
|
||||
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]",
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isProjectSnapshotViolation(text: string): boolean {
|
||||
// Test/suite counts
|
||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||
|
||||
// File counts with snapshot context, excluding limit statements
|
||||
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;
|
||||
}
|
||||
|
||||
// Phase/Wave/Sprint/Milestone/Task progress
|
||||
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;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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 === "explicit") return entry;
|
||||
if (entry.type !== "project") return entry;
|
||||
|
||||
if (isProjectSnapshotViolation(entry.text)) {
|
||||
return {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
return {
|
||||
...store,
|
||||
entries,
|
||||
migrations: [...(store.migrations || []), MIGRATION_ID],
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
/** Extract entity/destination keys for project and reference dedup */
|
||||
function extractEntityKey(text: string): string | null {
|
||||
const normalized = canonicalMemoryText(text);
|
||||
// Check known key phrases (bilingual-friendly)
|
||||
// opencode + agenthub plugin system
|
||||
if (/opencode.*agenthub/i.test(normalized)) {
|
||||
return "opencode-agenthub plugin system";
|
||||
}
|
||||
// For generic config references, fall back to canonical text dedup — no entity key
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract decision topic key for supersession detection */
|
||||
function decisionTopicKey(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
// Parser format versions
|
||||
if (/parser.*formats?|supports?\s*\d+\s*format/i.test(normalized)) {
|
||||
return "parser-supported-formats";
|
||||
}
|
||||
// Compaction template replacement
|
||||
if (/compaction.*template|output\.prompt|template.*replace/i.test(normalized)) {
|
||||
return "compaction-template-replacement";
|
||||
}
|
||||
// Plugin loading
|
||||
if (/plugin.*load|npm.*cache|plugin.*config/i.test(normalized)) {
|
||||
return "plugin-loading-config";
|
||||
}
|
||||
// Output format changes (purple/italic, YAML frontmatter, etc)
|
||||
if (/purple.*italic|markup|markdown.*render|frontmatter/i.test(normalized)) {
|
||||
return "output-format-rendering";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract feedback topic key for supersession detection */
|
||||
function feedbackTopicKey(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
// Purple/italic rendering issue
|
||||
if (/purple.*italic/i.test(normalized)) {
|
||||
return "purple-italic-rendering";
|
||||
}
|
||||
// Browser login/server errors (500 internal_error)
|
||||
if (/login.*500|500.*internal|internal_error|server.*error/i.test(normalized)) {
|
||||
return "server-error";
|
||||
}
|
||||
// Port occupied / environment issues
|
||||
if (/port.*occup|9473|端口|舊進程|旧进程/i.test(normalized)) {
|
||||
return "port-occupied-environment";
|
||||
}
|
||||
// Theme preferences
|
||||
if (/theme|dark.*light|prefer.*theme/i.test(normalized)) {
|
||||
return "theme-preference";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function workspaceMemoryIdentityKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
|
||||
if (entry.type === "project" || entry.type === "reference") {
|
||||
return `${entry.type}:${extractEntityKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
if (entry.type === "feedback") {
|
||||
return `${entry.type}:${feedbackTopicKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
return `decision:${decisionTopicKey(entry.text) ?? canonicalMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
function consolidationEvent(
|
||||
memory: LongTermMemoryEntry,
|
||||
reason: MemoryConsolidationReason,
|
||||
retained?: LongTermMemoryEntry,
|
||||
): MemoryConsolidationEvent {
|
||||
return {
|
||||
memoryKey: workspaceMemoryExactKey(memory),
|
||||
identityKey: workspaceMemoryIdentityKey(memory),
|
||||
memory,
|
||||
reason,
|
||||
retainedId: retained?.id,
|
||||
supersededId: reason === "superseded_existing" ? memory.id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if entry should be pruned by age (for compaction/manual entries only) */
|
||||
function isPrunableByAge(entry: LongTermMemoryEntry, now: number): boolean {
|
||||
// Never prune feedback or explicit entries
|
||||
if (entry.type === "feedback") return false;
|
||||
if (entry.source === "explicit") return false;
|
||||
if (!entry.staleAfterDays) return false;
|
||||
|
||||
const createdAt = new Date(entry.createdAt).getTime();
|
||||
const ageDays = (now - createdAt) / 86400000;
|
||||
const grace = 30; // 30-day grace period
|
||||
return ageDays > entry.staleAfterDays + grace;
|
||||
}
|
||||
|
||||
/** Choose better memory when identity/topic keys conflict */
|
||||
function chooseBetterMemory(
|
||||
a: LongTermMemoryEntry,
|
||||
b: LongTermMemoryEntry,
|
||||
mode: "entity" | "supersession" = "entity",
|
||||
): LongTermMemoryEntry {
|
||||
// Source priority: explicit > manual > compaction
|
||||
if (sourcePriority(a.source) !== sourcePriority(b.source)) {
|
||||
return sourcePriority(a.source) > sourcePriority(b.source) ? a : b;
|
||||
}
|
||||
// Higher confidence wins
|
||||
if (a.confidence !== b.confidence) {
|
||||
return a.confidence > b.confidence ? a : b;
|
||||
}
|
||||
// For entity dedup: longer (more specific) beats shorter
|
||||
// For supersession: newer beats older (and thus longer is not preferred)
|
||||
if (mode === "supersession") {
|
||||
// Newer wins for same-topic supersession
|
||||
if (new Date(a.createdAt).getTime() !== new Date(b.createdAt).getTime()) {
|
||||
return new Date(a.createdAt) > new Date(b.createdAt) ? a : b;
|
||||
}
|
||||
return a.text.length > b.text.length ? a : b;
|
||||
}
|
||||
// Entity mode: longer text means more specific
|
||||
if (Math.abs(a.text.length - b.text.length) > 10) {
|
||||
return a.text.length > b.text.length ? a : b;
|
||||
}
|
||||
// Freshness tie-breaker
|
||||
return new Date(a.createdAt) > new Date(b.createdAt) ? a : b;
|
||||
}
|
||||
|
||||
export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
return enforceLongTermLimitsWithAccounting(entries).kept;
|
||||
}
|
||||
|
||||
export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
|
||||
const now = Date.now();
|
||||
const staleDropped: MemoryConsolidationEvent[] = [];
|
||||
|
||||
// Phase 1: filter active, prune by age
|
||||
const phase1: LongTermMemoryEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.status === "superseded") continue;
|
||||
if (isPrunableByAge(entry, now)) {
|
||||
staleDropped.push(consolidationEvent(entry, "rejected_stale"));
|
||||
continue;
|
||||
}
|
||||
phase1.push({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) });
|
||||
}
|
||||
|
||||
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
|
||||
const sorted = [...dedupeResult.kept].sort(compareLongTermMemoryForRetention);
|
||||
const kept = sorted.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||
const keptIds = new Set(kept.map(entry => entry.id));
|
||||
const capacityDropped = sorted
|
||||
.filter(entry => !keptIds.has(entry.id))
|
||||
.map(entry => consolidationEvent(entry, "rejected_capacity"));
|
||||
|
||||
return {
|
||||
kept,
|
||||
dropped: [...staleDropped, ...dedupeResult.dropped, ...capacityDropped],
|
||||
absorbed: dedupeResult.absorbed,
|
||||
superseded: dedupeResult.superseded,
|
||||
};
|
||||
}
|
||||
|
||||
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
|
||||
const absorbed: MemoryConsolidationEvent[] = [];
|
||||
const superseded: MemoryConsolidationEvent[] = [];
|
||||
|
||||
// For project/reference/feedback: detect entity keys FIRST, then dedupe by entity OR canonical
|
||||
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
||||
|
||||
// Build entity key dedup for project/reference/feedback
|
||||
const entityDeduped = new Map<string, LongTermMemoryEntry>();
|
||||
for (const entry of projectRefEntries) {
|
||||
const key = workspaceMemoryIdentityKey(entry);
|
||||
const hasTopicIdentity = key !== workspaceMemoryExactKey(entry);
|
||||
|
||||
const existing = entityDeduped.get(key);
|
||||
if (!existing) {
|
||||
entityDeduped.set(key, entry);
|
||||
} else {
|
||||
// Feedback topic conflicts use supersession mode (newer beats longer)
|
||||
const mode = entry.type === "feedback" && hasTopicIdentity ? "supersession" as const : "entity" as const;
|
||||
const retained = chooseBetterMemory(entry, existing, mode);
|
||||
const dropped = retained === entry ? existing : entry;
|
||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||
? "absorbed_exact" as const
|
||||
: mode === "supersession"
|
||||
? "superseded_existing" as const
|
||||
: "absorbed_identity" as const;
|
||||
|
||||
if (reason === "superseded_existing") {
|
||||
superseded.push(consolidationEvent(dropped, reason, retained));
|
||||
} else {
|
||||
absorbed.push(consolidationEvent(dropped, reason, retained));
|
||||
}
|
||||
|
||||
if (retained === entry) {
|
||||
entityDeduped.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For decisions: detect topic keys for supersession, or use canonical
|
||||
const decisionEntries = entries.filter(e => e.type === "decision");
|
||||
const decisionDeduped = new Map<string, LongTermMemoryEntry>();
|
||||
for (const entry of decisionEntries) {
|
||||
const key = workspaceMemoryIdentityKey(entry);
|
||||
|
||||
const existing = decisionDeduped.get(key);
|
||||
if (!existing) {
|
||||
decisionDeduped.set(key, entry);
|
||||
} else {
|
||||
const retained = chooseBetterMemory(entry, existing, "supersession");
|
||||
const dropped = retained === entry ? existing : entry;
|
||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||
? "absorbed_exact" as const
|
||||
: "superseded_existing" as const;
|
||||
|
||||
if (reason === "superseded_existing") {
|
||||
superseded.push(consolidationEvent(dropped, reason, retained));
|
||||
} else {
|
||||
absorbed.push(consolidationEvent(dropped, reason, retained));
|
||||
}
|
||||
|
||||
if (retained === entry) {
|
||||
decisionDeduped.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge deduped entries
|
||||
const phaseFinal = new Map<string, LongTermMemoryEntry>();
|
||||
for (const entry of [...entityDeduped.values(), ...decisionDeduped.values()]) {
|
||||
phaseFinal.set(entry.id, entry);
|
||||
}
|
||||
|
||||
return {
|
||||
kept: [...phaseFinal.values()],
|
||||
dropped: [],
|
||||
absorbed,
|
||||
superseded,
|
||||
};
|
||||
}
|
||||
|
||||
function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMemoryEntry): number {
|
||||
const pA = priorityWithFreshness(a);
|
||||
const pB = priorityWithFreshness(b);
|
||||
if (pB !== pA) return pB - pA;
|
||||
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
|
||||
if (sourceDiff !== 0) return sourceDiff;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
|
||||
function priority(entry: LongTermMemoryEntry): number {
|
||||
const typeWeight = {
|
||||
feedback: 400,
|
||||
decision: 300,
|
||||
project: 200,
|
||||
reference: 100,
|
||||
}[entry.type];
|
||||
|
||||
const sourceWeight = entry.source === "explicit" ? 1000 : 0;
|
||||
return sourceWeight + typeWeight + entry.confidence * 10;
|
||||
}
|
||||
|
||||
/** Extended priority including freshness for tie-breaking */
|
||||
function priorityWithFreshness(entry: LongTermMemoryEntry): number {
|
||||
return priority(entry);
|
||||
}
|
||||
|
||||
function wouldFit(
|
||||
lines: string[],
|
||||
nextLine: string,
|
||||
closingLine: string,
|
||||
maxChars: number
|
||||
): boolean {
|
||||
return [...lines, nextLine, closingLine].join("\n").length <= maxChars;
|
||||
}
|
||||
|
||||
export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
|
||||
const active = enforceLongTermLimits(store.entries);
|
||||
if (active.length === 0) return "";
|
||||
|
||||
const maxChars = Math.min(
|
||||
store.limits.maxRenderedChars,
|
||||
LONG_TERM_LIMITS.maxRenderedChars
|
||||
);
|
||||
|
||||
// If maxChars smaller than minimum envelope, return empty string
|
||||
if (maxChars < MIN_ENVELOPE_LENGTH) return "";
|
||||
|
||||
const lines: string[] = [
|
||||
"Workspace memory (cross-session, verify if stale):",
|
||||
];
|
||||
|
||||
for (const type of ["feedback", "project", "decision", "reference"] as const) {
|
||||
const items = active.filter(entry => entry.type === type);
|
||||
if (items.length === 0) continue;
|
||||
|
||||
const sectionLines: string[] = [`${type}:`];
|
||||
|
||||
for (const item of items) {
|
||||
const line = `- ${renderEntry(item)}`;
|
||||
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
|
||||
sectionLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionLines.length > 1) {
|
||||
lines.push(...sectionLines);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderEntry(entry: LongTermMemoryEntry): string {
|
||||
const ageDays = Math.floor((Date.now() - new Date(entry.createdAt).getTime()) / 86_400_000);
|
||||
const stale = entry.staleAfterDays && ageDays > entry.staleAfterDays ? ` [${ageDays}d old, verify]` : "";
|
||||
const rationale = entry.rationale
|
||||
? ` Why: ${entry.rationale.slice(0, LONG_TERM_LIMITS.maxRationaleChars)}`
|
||||
: "";
|
||||
return `${entry.text}${rationale}${stale}`;
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { extractErrorsFromBash, extractExplicitMemories } from "../src/extractors.ts";
|
||||
|
||||
// ============================================
|
||||
// Task 1: extractErrorsFromBash tests
|
||||
// ============================================
|
||||
|
||||
test("git log output mentioning errors is ignored", () => {
|
||||
const errors = extractErrorsFromBash(
|
||||
"cd /repo && rtk git log --oneline -5",
|
||||
"4832b38 fix: silence memory load errors in working-memory"
|
||||
);
|
||||
assert.equal(errors.length, 0);
|
||||
});
|
||||
|
||||
test("cat session json with openErrors is ignored", () => {
|
||||
const errors = extractErrorsFromBash(
|
||||
"rtk cat ~/.local/share/opencode-working-memory/session.json",
|
||||
'"openErrors": []'
|
||||
);
|
||||
assert.equal(errors.length, 0);
|
||||
});
|
||||
|
||||
test("typecheck failure is captured", () => {
|
||||
const errors = extractErrorsFromBash(
|
||||
"npm run typecheck",
|
||||
"src/index.ts(10,3): error TS2345: bad type"
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(errors[0].category, "typecheck");
|
||||
});
|
||||
|
||||
test("runtime Error prefix is captured for failed unknown command", () => {
|
||||
const errors = extractErrorsFromBash(
|
||||
"node script.js",
|
||||
"Error: Cannot find module './missing'"
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(errors[0].category, "runtime");
|
||||
});
|
||||
|
||||
test("unknown command with loose error words is ignored", () => {
|
||||
const errors = extractErrorsFromBash(
|
||||
"some-unknown-command",
|
||||
"this output has errors in it but no clear signal"
|
||||
);
|
||||
assert.equal(errors.length, 0);
|
||||
});
|
||||
|
||||
test("TypeError prefix is captured", () => {
|
||||
const errors = extractErrorsFromBash(
|
||||
"node script.js",
|
||||
"TypeError: Cannot read property 'x' of undefined"
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
});
|
||||
|
||||
test("TS error pattern is always captured", () => {
|
||||
const errors = extractErrorsFromBash(
|
||||
"cat some-file.txt", // unknown command, but TS error is strong signal
|
||||
"src/index.ts(10,3): error TS2345: Argument of type 'string' is not assignable"
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(errors[0].category, "runtime");
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Task 3: extractExplicitMemories tests
|
||||
// ============================================
|
||||
|
||||
test("extractExplicitMemories does not treat always as memory trigger", () => {
|
||||
const items = extractExplicitMemories("tests always fail on CI when cache is stale");
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("extractExplicitMemories still captures going forward", () => {
|
||||
const items = extractExplicitMemories("going forward: use pnpm instead of npm");
|
||||
assert.equal(items.length, 1);
|
||||
assert.match(items[0].text, /pnpm/);
|
||||
});
|
||||
|
||||
test("extractExplicitMemories captures from now on", () => {
|
||||
const items = extractExplicitMemories("from now on: reply in Traditional Chinese");
|
||||
assert.equal(items.length, 1);
|
||||
assert.match(items[0].text, /Traditional Chinese/);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Task 6: Negative memory request tests
|
||||
// ============================================
|
||||
|
||||
test("extractExplicitMemories ignores Chinese negative request", () => {
|
||||
const items = extractExplicitMemories("不要記住:這個 repo 使用 npm cache");
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("extractExplicitMemories ignores English negative request", () => {
|
||||
const items = extractExplicitMemories("please don't remember this: use npm cache");
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("extractExplicitMemories does not false positive on 'not forget'", () => {
|
||||
// "remember this" in middle of sentence should NOT match (not a directive)
|
||||
const items = extractExplicitMemories("I will not forget to remember this: use pnpm");
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("extractExplicitMemories captures remember at line start", () => {
|
||||
// "remember this" at line start IS a directive
|
||||
const items = extractExplicitMemories("remember this: use pnpm for all packages");
|
||||
assert.equal(items.length, 1);
|
||||
assert.match(items[0].text, /pnpm/);
|
||||
});
|
||||
|
||||
test("extractExplicitMemories still captures positive request after negative", () => {
|
||||
// Ensure negative guard doesn't block positive requests
|
||||
const items = extractExplicitMemories("記住:使用 pnpm 來管理套件");
|
||||
assert.equal(items.length, 1);
|
||||
assert.match(items[0].text, /pnpm/);
|
||||
});
|
||||
|
||||
test("extractExplicitMemories captures multiple memories in same message", () => {
|
||||
const items = extractExplicitMemories("請記住:使用 pnpm 管理套件\n記住這點:用 TypeScript 撰寫程式碼");
|
||||
assert.equal(items.length, 2);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Task 7: Compaction quality gate tests
|
||||
// ============================================
|
||||
|
||||
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects short text", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [decision] short text
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects git commit hash", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [project] abc123def456 is the commit hash
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects raw error", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [feedback] TypeError: Cannot read property 'x' of undefined
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects stack trace", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [reference] at foo (bar.ts:10:5)
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects commit prefix", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [project] fix: add new feature
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [project] files at /src/a.ts /src/b.ts /src/c.ts are important
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts valid decision", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [decision] Use pnpm instead of npm for package management
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].type, "decision");
|
||||
assert.match(items[0].text, /pnpm/);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts valid project info", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
- [project] This project uses TypeScript for all source files
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].type, "project");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts plain text label format (no Markdown)", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- [decision] Use plain text labels to avoid purple Markdown headers
|
||||
- [project] This repo uses pnpm for package management
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].type, "decision");
|
||||
assert.equal(items[1].type, "project");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts bracketless candidate format", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project Backend health improvements organized into phased milestones
|
||||
- reference Scrypt 參數必須是 N=16384, r=8, p=1
|
||||
- feedback 端口 9473 可能被舊進程佔用,需殺掉後重啟
|
||||
- decision Use output.prompt to replace the default compaction template
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
|
||||
assert.equal(items.length, 4, "Should parse all 4 bracketless candidates");
|
||||
assert.deepEqual(items.map(i => i.type), [
|
||||
"project",
|
||||
"reference",
|
||||
"feedback",
|
||||
"decision",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects unknown bracketless candidate type", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- note this should not be parsed as memory
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects bracketless very short body", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project short
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates does not match bracketless type as substring", () => {
|
||||
// "projectile" should NOT match "project"
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- projectile launcher should not be parsed as a project memory
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects exact test count snapshots", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project 1237 tests pass, 226 suites
|
||||
- project 500 tests pass today
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Exact test counts are session snapshots, not durable memory");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project USB 同步 37 個文件
|
||||
- project 42 files synced
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Exact file counts are session snapshots");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects phase progress snapshots", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project Phase 1-4 已完成
|
||||
- project Phase 3 completed
|
||||
- project Completed phase 1
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Phase progress is session snapshot, not durable milestone");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects wave/sprint/milestone/task progress snapshots", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project Waves 1-5 已完成,Wave 6 deferred
|
||||
- project Sprint 3 completed
|
||||
- project Milestone 2 done
|
||||
- project Task 8 finished
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Wave/Sprint/Milestone/Task progress should be rejected as snapshots");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates keeps file limits but rejects file sync snapshots", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project Upload limit is 10 files per request
|
||||
- project USB uploaded 37 files for sync verification
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 1, "Should keep static file-limit facts and reject processed file-count snapshots");
|
||||
assert.match(items[0].text, /Upload limit is 10 files/);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts durable project facts", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project Backend health improvements organized into phased milestones
|
||||
- project USB sync covers bundles, server, frontend, tests, and docs
|
||||
- project Test suite expected to pass before handoff
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 3, "Durable project facts should pass");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts short Admin PIN reference entry", () => {
|
||||
// Real Admin PIN is <20 chars — should pass via config value allowlist
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- reference Admin PIN 是 456123
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 1, "Short config reference should pass via allowlist");
|
||||
assert.equal(items[0].type, "reference");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates accepts Scrypt config reference", () => {
|
||||
// Scrypt parameters with numbers should pass
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- reference Scrypt 參數必須是 N=16384, r=8, p=1
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 1, "Scrypt config values should pass");
|
||||
assert.equal(items[0].type, "reference");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects Chinese file count snapshot", () => {
|
||||
// Real Chinese file count with counter word 個
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project USB 同步:37 個文件(bundles, server, frontend, tests, docs)
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Chinese file count with 個 should be rejected");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects real phase snapshot mid-description", () => {
|
||||
// Real phase snapshot where Phase appears deep in the string
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project pathology-playground 後端健康改進計劃已完成 Phase 1-4
|
||||
`;
|
||||
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Phase snapshot mid-description should still be rejected");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates extracts Japanese triggers", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project 覚えて: このプロジェクトは pnpm を使う
|
||||
- project 覚えておいて: 日本語でメモ
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 2);
|
||||
assert.match(items[0].text, /pnpm/);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates extracts Korean triggers", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project 기억해: 이 프로젝트는 pnpm을 사용한다
|
||||
- project 메모해줘: 한국어 메모
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 2);
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects negated Japanese triggers", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project 覚えないで 覚えて: 一時的なメモ
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Negated Japanese trigger should be rejected");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects negated Korean triggers", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project 기억하지 마 기억해: 일시적인 메모
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items.length, 0, "Negated Korean trigger should be rejected");
|
||||
});
|
||||
|
||||
test("parseWorkspaceMemoryCandidates body extraction excludes trigger suffix", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
- project 覚えておいて: このプロジェクトは pnpm を使う
|
||||
`;
|
||||
const items = parseWorkspaceMemoryCandidates(summary);
|
||||
assert.equal(items[0].text, "このプロジェクトは pnpm を使う");
|
||||
assert.equal(items[0].text.includes("おいて"), false);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||
|
||||
const acceptedCases = [
|
||||
{
|
||||
name: "durable user language preference",
|
||||
line: "- [feedback] User prefers architecture reviews in Traditional Chinese",
|
||||
expectedType: "feedback",
|
||||
expectedText: /Traditional Chinese/,
|
||||
},
|
||||
{
|
||||
name: "stable cache architecture decision",
|
||||
line: "- [decision] Use frozen workspace memory snapshots plus ephemeral hot state for cache stability",
|
||||
expectedType: "decision",
|
||||
expectedText: /frozen workspace memory/,
|
||||
},
|
||||
{
|
||||
name: "stable zero API call constraint",
|
||||
line: "- [project] The plugin piggybacks memory extraction on OpenCode compaction and should not add extra LLM calls",
|
||||
expectedType: "project",
|
||||
expectedText: /extra LLM calls/,
|
||||
},
|
||||
{
|
||||
name: "hard to rediscover reference",
|
||||
line: "- [reference] Workspace memory uses a frozen system[1] snapshot and pending memories remain in hot session state until compaction",
|
||||
expectedType: "reference",
|
||||
expectedText: /system\[1\]/,
|
||||
},
|
||||
{
|
||||
name: "short stable config reference",
|
||||
line: "- [reference] Config parser supports bracketless format",
|
||||
expectedType: "reference",
|
||||
expectedText: /bracketless/,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const rejectedCases = [
|
||||
{
|
||||
name: "test count snapshot",
|
||||
line: "- [project] 42 tests passed after the latest implementation",
|
||||
},
|
||||
{
|
||||
name: "suite count snapshot",
|
||||
line: "- [project] 3 suites pass and 0 suites fail right now",
|
||||
},
|
||||
{
|
||||
name: "phase progress snapshot",
|
||||
line: "- [project] Wave 2 completed successfully",
|
||||
},
|
||||
{
|
||||
name: "commit hash",
|
||||
line: "- [reference] Commit 4309cb8 contains the promotion accounting fix",
|
||||
},
|
||||
{
|
||||
name: "raw transient error",
|
||||
line: "- [feedback] TypeError: Cannot read properties of undefined",
|
||||
},
|
||||
{
|
||||
name: "path heavy rediscoverable fact",
|
||||
line: "- [project] Important files are /src/plugin.ts /src/workspace-memory.ts /src/session-state.ts",
|
||||
},
|
||||
{
|
||||
name: "temporary pending task",
|
||||
line: "- [decision] currently: run npm test before the next reply",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const item of acceptedCases) {
|
||||
test(`memory quality accepts ${item.name}`, () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
${item.line}
|
||||
`;
|
||||
const entries = parseWorkspaceMemoryCandidates(summary);
|
||||
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].type, item.expectedType);
|
||||
assert.match(entries[0].text, item.expectedText);
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of rejectedCases) {
|
||||
test(`memory quality rejects ${item.name}`, () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
${item.line}
|
||||
`;
|
||||
const entries = parseWorkspaceMemoryCandidates(summary);
|
||||
|
||||
assert.equal(entries.length, 0);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { LongTermMemoryEntry } from "../src/types.ts";
|
||||
import { accountPendingPromotions } from "../src/promotion-accounting.ts";
|
||||
import { memoryKey } from "../src/pending-journal.ts";
|
||||
import type { MemoryConsolidationEvent } from "../src/workspace-memory.ts";
|
||||
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts";
|
||||
|
||||
function mem(
|
||||
id: string,
|
||||
text: string,
|
||||
opts: Partial<LongTermMemoryEntry> = {},
|
||||
): LongTermMemoryEntry {
|
||||
const now = opts.createdAt ?? new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
type: opts.type ?? "decision",
|
||||
text,
|
||||
source: opts.source ?? "compaction",
|
||||
confidence: opts.confidence ?? 0.75,
|
||||
status: opts.status ?? "active",
|
||||
createdAt: now,
|
||||
updatedAt: opts.updatedAt ?? now,
|
||||
staleAfterDays: opts.staleAfterDays,
|
||||
rationale: opts.rationale,
|
||||
supersedes: opts.supersedes,
|
||||
tags: opts.tags,
|
||||
};
|
||||
}
|
||||
|
||||
function event(
|
||||
memory: LongTermMemoryEntry,
|
||||
reason: MemoryConsolidationEvent["reason"],
|
||||
): MemoryConsolidationEvent {
|
||||
return {
|
||||
memoryKey: workspaceMemoryExactKey(memory),
|
||||
identityKey: workspaceMemoryIdentityKey(memory),
|
||||
memory,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
test("accountPendingPromotions marks exact retained pending memory as promoted", () => {
|
||||
const pending = [mem("pending", "Use frozen rendered snapshots for cache stability.")];
|
||||
const before: LongTermMemoryEntry[] = [];
|
||||
const after = [pending[0]];
|
||||
|
||||
const result = accountPendingPromotions({ pending, before, after });
|
||||
|
||||
assert.deepEqual([...result.promotedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.absorbedKeys.size, 0);
|
||||
assert.equal(result.rejectedKeys.size, 0);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions marks exact duplicate already represented before promotion as absorbed", () => {
|
||||
const existing = mem("existing", "Prefer stable cache boundaries.", { source: "explicit" });
|
||||
const pending = [mem("pending", "prefer stable cache boundaries.", { source: "explicit" })];
|
||||
const before = [existing];
|
||||
const after = [existing];
|
||||
|
||||
const result = accountPendingPromotions({ pending, before, after });
|
||||
|
||||
assert.equal(result.promotedKeys.size, 0);
|
||||
assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.rejectedKeys.size, 0);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions marks same exact key present before promotion as absorbed, not promoted", () => {
|
||||
const existing = mem("existing", "Use stable cache boundaries.", { source: "explicit" });
|
||||
const pending = [mem("pending", "Use stable cache boundaries.", { source: "explicit" })];
|
||||
const before = [existing];
|
||||
const after = [existing];
|
||||
|
||||
const result = accountPendingPromotions({ pending, before, after });
|
||||
|
||||
assert.equal(result.promotedKeys.size, 0,
|
||||
"a pending memory whose exact key already existed before promotion is absorbed, not newly promoted");
|
||||
assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.rejectedKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions ignores superseded exact keys when detecting existing absorption", () => {
|
||||
const superseded = mem("superseded", "Revive this memory when it is remembered again.", {
|
||||
source: "explicit",
|
||||
status: "superseded",
|
||||
});
|
||||
const pending = [mem("pending", "Revive this memory when it is remembered again.", {
|
||||
source: "explicit",
|
||||
})];
|
||||
const before = [superseded];
|
||||
const after = [superseded, pending[0]];
|
||||
|
||||
const result = accountPendingPromotions({ pending, before, after });
|
||||
|
||||
assert.deepEqual([...result.promotedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.absorbedKeys.size, 0);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions marks same-topic decision represented after normalization as absorbed", () => {
|
||||
const existing = mem("existing", "Parser supports 2 candidate formats.", {
|
||||
type: "decision",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
createdAt: "2026-04-27T10:00:00.000Z",
|
||||
updatedAt: "2026-04-27T10:00:00.000Z",
|
||||
});
|
||||
const pending = [mem("pending", "Parser supports 3 candidate formats.", {
|
||||
type: "decision",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
createdAt: "2026-04-27T09:00:00.000Z",
|
||||
updatedAt: "2026-04-27T09:00:00.000Z",
|
||||
})];
|
||||
const before = [existing];
|
||||
const after = [existing];
|
||||
|
||||
const result = accountPendingPromotions({ pending, before, after });
|
||||
|
||||
assert.equal(result.promotedKeys.size, 0);
|
||||
assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.rejectedKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions keeps pending memory rejected when no equivalent survived", () => {
|
||||
const pending = [mem("pending", "Low priority memory that did not fit the workspace cap.", {
|
||||
type: "reference",
|
||||
source: "compaction",
|
||||
})];
|
||||
const before: LongTermMemoryEntry[] = [];
|
||||
const after: LongTermMemoryEntry[] = [];
|
||||
|
||||
const result = accountPendingPromotions({ pending, before, after });
|
||||
|
||||
assert.equal(result.promotedKeys.size, 0);
|
||||
assert.equal(result.absorbedKeys.size, 0);
|
||||
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.clearableKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions clears accounting absorbed identity events", () => {
|
||||
const pending = [mem("pending_identity", "This repo uses opencode-agenthub plugin system", {
|
||||
type: "project",
|
||||
source: "compaction",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "absorbed_identity")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.rejectedKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions separates accounting superseded events", () => {
|
||||
const pending = [mem("pending_topic", "Parser supports 3 candidate formats.", {
|
||||
type: "decision",
|
||||
source: "compaction",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "superseded_existing")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.supersededKeys], [memoryKey(pending[0])]);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.absorbedKeys.size, 0);
|
||||
assert.equal(result.rejectedKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions clears compaction capacity rejection from accounting", () => {
|
||||
const pending = [mem("pending_capacity", "Weak compaction reference that should lose capacity review.", {
|
||||
type: "reference",
|
||||
source: "compaction",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "rejected_capacity")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions keeps explicit capacity rejection pending", () => {
|
||||
const pending = [mem("pending_explicit_capacity", "Explicit reference should retry if capacity rejected.", {
|
||||
type: "reference",
|
||||
source: "explicit",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "rejected_capacity")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
|
||||
assert.equal(result.clearableKeys.size, 0);
|
||||
});
|
||||
|
||||
test("accountPendingPromotions clears compaction stale rejection from accounting", () => {
|
||||
const pending = [mem("pending_stale", "Stale compaction reference should be terminal.", {
|
||||
type: "reference",
|
||||
source: "compaction",
|
||||
})];
|
||||
|
||||
const result = accountPendingPromotions({
|
||||
pending,
|
||||
before: [],
|
||||
after: [],
|
||||
events: [event(pending[0], "rejected_stale")],
|
||||
});
|
||||
|
||||
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
|
||||
assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]);
|
||||
});
|
||||
@@ -0,0 +1,895 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
||||
import { LONG_TERM_LIMITS } from "../src/types.ts";
|
||||
import { workspaceMemoryPath } from "../src/paths.ts";
|
||||
import {
|
||||
renderWorkspaceMemory,
|
||||
enforceLongTermLimits,
|
||||
dedupeLongTermEntriesWithAccounting,
|
||||
enforceLongTermLimitsWithAccounting,
|
||||
normalizeWorkspaceMemoryWithAccounting,
|
||||
workspaceMemoryExactKey,
|
||||
redactCredentials,
|
||||
isProjectSnapshotViolation,
|
||||
runMigrationP0Cleanup,
|
||||
loadWorkspaceMemory,
|
||||
saveWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
} from "../src/workspace-memory.ts";
|
||||
|
||||
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
text,
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create an entry with a createdAt offset from now (negative = in the past) */
|
||||
function agedEntry(
|
||||
id: string,
|
||||
text: string,
|
||||
type: LongTermMemoryEntry["type"] = "decision",
|
||||
opts: { daysAgo: number; source?: "compaction" | "explicit" | "manual"; staleAfterDays?: number } = { daysAgo: 0, source: "compaction" },
|
||||
): LongTermMemoryEntry {
|
||||
const createdAt = new Date(Date.now() - opts.daysAgo * 86400000).toISOString();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
text,
|
||||
source: opts.source ?? "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
staleAfterDays: opts.staleAfterDays,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Task 2: renderWorkspaceMemory tests
|
||||
// ============================================
|
||||
|
||||
test("renderWorkspaceMemory respects budget and fits entries", () => {
|
||||
const entries = Array.from({ length: 28 }, (_, i) =>
|
||||
entry(`mem_${i}`, `Long durable memory entry ${i} `.repeat(20))
|
||||
);
|
||||
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: 700, maxEntries: 28 },
|
||||
entries,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const rendered = renderWorkspaceMemory(store);
|
||||
|
||||
assert.ok(!rendered.includes("<workspace_memory>"),
|
||||
"Should not contain XML tags");
|
||||
assert.ok(rendered.length <= 700,
|
||||
`Rendered memory must not exceed maxChars. Got: ${rendered.length}`);
|
||||
});
|
||||
|
||||
test("renderWorkspaceMemory returns empty string when maxChars too small", () => {
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: 50, maxEntries: 28 },
|
||||
entries: [entry("test", "test memory")],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const rendered = renderWorkspaceMemory(store);
|
||||
assert.equal(rendered, "",
|
||||
"When maxChars too small for even minimal envelope, return empty string");
|
||||
});
|
||||
|
||||
test("renderWorkspaceMemory respects small budget", () => {
|
||||
// Create entries that would overflow a small budget
|
||||
const entries = [
|
||||
entry("a", "First memory entry that is reasonably long"),
|
||||
entry("b", "Second memory entry that is also reasonably long"),
|
||||
entry("c", "Third memory entry that is also reasonably long"),
|
||||
];
|
||||
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: 200, maxEntries: 28 },
|
||||
entries,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const rendered = renderWorkspaceMemory(store);
|
||||
|
||||
assert.ok(!rendered.includes("<workspace_memory>"),
|
||||
"Should not contain XML tags");
|
||||
assert.ok(rendered.length <= 200,
|
||||
`Must respect maxChars limit. Got: ${rendered.length}`);
|
||||
});
|
||||
|
||||
test("renderWorkspaceMemory returns empty for no entries", () => {
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: 5200, maxEntries: 28 },
|
||||
entries: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const rendered = renderWorkspaceMemory(store);
|
||||
assert.equal(rendered, "");
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// PR-2 Task 5 tests (for enforceLongTermLimits)
|
||||
// ============================================
|
||||
|
||||
test("enforceLongTermLimits dedupes with canonical text", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const a: LongTermMemoryEntry = {
|
||||
id: "a",
|
||||
type: "decision",
|
||||
text: "OpenCode uses NPM CACHE for plugin loading",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const b: LongTermMemoryEntry = {
|
||||
id: "b",
|
||||
type: "decision",
|
||||
text: "opencode uses npm cache for plugin loading!!!",
|
||||
source: "compaction",
|
||||
confidence: 0.8,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const kept = enforceLongTermLimits([a, b]);
|
||||
|
||||
assert.equal(kept.length, 1, "Should dedupe similar texts");
|
||||
assert.equal(kept[0].confidence, 0.8, "Higher confidence should win for same source");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits preserves explicit over compaction", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const explicit: LongTermMemoryEntry = {
|
||||
id: "explicit",
|
||||
type: "decision",
|
||||
text: "Use pnpm for this project",
|
||||
source: "explicit",
|
||||
confidence: 0.5,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const compaction: LongTermMemoryEntry = {
|
||||
id: "compaction",
|
||||
type: "decision",
|
||||
text: "Use pnpm for this project",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const kept = enforceLongTermLimits([explicit, compaction]);
|
||||
|
||||
assert.equal(kept.length, 1);
|
||||
assert.equal(kept[0].source, "explicit",
|
||||
"Explicit source should win over compaction even with lower confidence");
|
||||
assert.equal(kept[0].confidence, 0.5, "Original explicit confidence preserved");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits same source higher confidence wins", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const a: LongTermMemoryEntry = {
|
||||
id: "a",
|
||||
type: "decision",
|
||||
text: "Project uses TypeScript",
|
||||
source: "compaction",
|
||||
confidence: 0.7,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const b: LongTermMemoryEntry = {
|
||||
id: "b",
|
||||
type: "decision",
|
||||
text: "Project uses TypeScript",
|
||||
source: "compaction",
|
||||
confidence: 0.9,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const kept = enforceLongTermLimits([a, b]);
|
||||
|
||||
assert.equal(kept.length, 1);
|
||||
assert.equal(kept[0].confidence, 0.9, "Higher confidence wins for same source");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits respects maxEntries limit", () => {
|
||||
const now = new Date().toISOString();
|
||||
const entries = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `mem_${i}`,
|
||||
type: "decision" as const,
|
||||
text: `Unique memory entry number ${i}`,
|
||||
source: "compaction" as const,
|
||||
confidence: 0.75,
|
||||
status: "active" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`);
|
||||
});
|
||||
|
||||
test("dedupeLongTermEntriesWithAccounting reports exact duplicates as absorbed", () => {
|
||||
const now = new Date().toISOString();
|
||||
const lower: LongTermMemoryEntry = {
|
||||
id: "lower",
|
||||
type: "decision",
|
||||
text: "OpenCode uses NPM CACHE for plugin loading",
|
||||
source: "compaction",
|
||||
confidence: 0.7,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const higher: LongTermMemoryEntry = {
|
||||
id: "higher",
|
||||
type: "decision",
|
||||
text: "opencode uses npm cache for plugin loading!!!",
|
||||
source: "compaction",
|
||||
confidence: 0.8,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([lower, higher]);
|
||||
|
||||
assert.equal(result.kept.length, 1);
|
||||
assert.equal(result.kept[0].id, "higher");
|
||||
assert.deepEqual(result.absorbed.map(event => event.reason), ["absorbed_exact"]);
|
||||
assert.equal(result.absorbed[0].memory.id, "lower");
|
||||
});
|
||||
|
||||
test("dedupeLongTermEntriesWithAccounting reports identity duplicates as absorbed", () => {
|
||||
const older = agedEntry(
|
||||
"older",
|
||||
"This repo uses opencode-agenthub plugin system at /Users/sd_wo/work/opencode-working-memory/",
|
||||
"project",
|
||||
{ daysAgo: 5 },
|
||||
);
|
||||
const newer = agedEntry(
|
||||
"newer",
|
||||
"此 repo 在開發時使用 opencode-agenthub 插件系統,目錄位於 /Users/sd_wo/work/opencode-working-memory/.opencode-agenthub/",
|
||||
"project",
|
||||
{ daysAgo: 0 },
|
||||
);
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([older, newer]);
|
||||
|
||||
assert.equal(result.kept.length, 1);
|
||||
assert.equal(result.absorbed.length, 1);
|
||||
assert.equal(result.absorbed[0].reason, "absorbed_identity");
|
||||
assert.equal(result.absorbed[0].retainedId, result.kept[0].id);
|
||||
});
|
||||
|
||||
test("dedupeLongTermEntriesWithAccounting reports topic duplicates as superseded", () => {
|
||||
const older = agedEntry(
|
||||
"older",
|
||||
"Parser supports 3 formats: HTML comment, Markdown section, legacy XML",
|
||||
"decision",
|
||||
{ daysAgo: 5 },
|
||||
);
|
||||
const newer = agedEntry(
|
||||
"newer",
|
||||
"Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML",
|
||||
"decision",
|
||||
{ daysAgo: 0 },
|
||||
);
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([older, newer]);
|
||||
|
||||
assert.equal(result.kept.length, 1);
|
||||
assert.equal(result.kept[0].id, "newer");
|
||||
assert.equal(result.superseded.length, 1);
|
||||
assert.equal(result.superseded[0].reason, "superseded_existing");
|
||||
assert.equal(result.superseded[0].supersededId, "older");
|
||||
assert.equal(result.superseded[0].retainedId, "newer");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimitsWithAccounting reports capacity drops", () => {
|
||||
const now = new Date().toISOString();
|
||||
const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, i) => ({
|
||||
id: `mem_${i}`,
|
||||
type: "reference" as const,
|
||||
text: `Unique low priority reference ${i}`,
|
||||
source: "compaction" as const,
|
||||
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
|
||||
status: "active" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
const result = enforceLongTermLimitsWithAccounting(entries);
|
||||
|
||||
assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 2);
|
||||
assert.ok(result.dropped.every(event => event.memory.source === "compaction"));
|
||||
});
|
||||
|
||||
test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () => {
|
||||
const now = new Date().toISOString();
|
||||
const entry: LongTermMemoryEntry = {
|
||||
id: "key_alignment",
|
||||
type: "decision",
|
||||
text: "OpenCode uses NPM CACHE for plugin loading!!!",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading");
|
||||
});
|
||||
|
||||
test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => {
|
||||
const root = "/repo";
|
||||
const now = new Date().toISOString();
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root, key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
migrations: ["2026-04-26-p0-cleanup"],
|
||||
entries: [{
|
||||
id: "cred",
|
||||
type: "reference",
|
||||
text: "Admin PIN 是 456123",
|
||||
rationale: "password: sushi",
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||||
|
||||
assert.equal(result.kept.length, 1);
|
||||
assert.equal(result.kept[0].text, "Admin PIN 是 [REDACTED]");
|
||||
assert.equal(result.kept[0].rationale, "password: [REDACTED]");
|
||||
assert.equal(result.store.entries[0].text, "Admin PIN 是 [REDACTED]");
|
||||
});
|
||||
|
||||
test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => {
|
||||
const root = "/repo";
|
||||
const now = new Date().toISOString();
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root, key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
migrations: ["2026-04-26-p0-cleanup"],
|
||||
entries: Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
|
||||
id: `overflow_${i}`,
|
||||
type: "reference" as const,
|
||||
text: `Overflow reference ${i}`,
|
||||
source: "compaction" as const,
|
||||
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
|
||||
status: "active" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||||
|
||||
assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 1);
|
||||
});
|
||||
|
||||
test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async () => {
|
||||
const root = "/repo";
|
||||
const now = new Date().toISOString();
|
||||
const stale = agedEntry(
|
||||
"stale_normalize",
|
||||
"Old compaction decision should be removed by normalization accounting",
|
||||
"decision",
|
||||
{ daysAgo: 90, staleAfterDays: 1 },
|
||||
);
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root, key: "abc" },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
migrations: ["2026-04-26-p0-cleanup"],
|
||||
entries: [stale],
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||||
|
||||
assert.equal(result.kept.length, 0);
|
||||
assert.equal(result.store.entries.length, 0);
|
||||
assert.deepEqual(result.dropped.map(event => event.reason), ["rejected_stale"]);
|
||||
assert.equal(result.dropped[0].memory.id, "stale_normalize");
|
||||
});
|
||||
|
||||
test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted updates", async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-update-"));
|
||||
const dataHome = join(sandbox, "xdg-data-home");
|
||||
const root = join(sandbox, "workspace");
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const result = await updateWorkspaceMemoryWithAccounting(root, store => {
|
||||
store.entries.push(...Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
|
||||
id: `persisted_${i}`,
|
||||
type: "reference" as const,
|
||||
text: `Persisted accounting reference ${i}`,
|
||||
source: "compaction" as const,
|
||||
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
|
||||
status: "active" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})));
|
||||
return store;
|
||||
});
|
||||
|
||||
assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries);
|
||||
assert.equal(result.events.filter(event => event.reason === "rejected_capacity").length, 1);
|
||||
|
||||
const persisted = await loadWorkspaceMemory(root);
|
||||
assert.equal(persisted.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries);
|
||||
} finally {
|
||||
if (previousXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME;
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
}
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// P0d: identity-key dedup, supersession, staleness
|
||||
// ============================================
|
||||
|
||||
test("enforceLongTermLimits project: bilingual variants collapse to one", () => {
|
||||
// All three mention opencode-agenthub plugin system - should merge
|
||||
const entries = [
|
||||
agedEntry("p1", "此 repo 在開發時使用 opencode-agenthub 插件系統,目錄位於 /Users/sd_wo/work/opencode-working-memory/.opencode-agenthub/", "project", { daysAgo: 2 }),
|
||||
agedEntry("p2", "此 repo 在開發時使用 opencode-agenthub 插件系統", "project", { daysAgo: 1 }),
|
||||
agedEntry("p3", "This repo uses opencode-agenthub plugin system at /Users/sd_wo/work/opencode-working-memory/", "project", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const projectEntries = kept.filter(e => e.type === "project");
|
||||
assert.equal(projectEntries.length, 1, "All three project variants should merge to one");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits reference: same config path variants collapse to one", () => {
|
||||
const entries = [
|
||||
agedEntry("r1", "OpenCode plugin config location: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 1 }),
|
||||
agedEntry("r2", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const refEntries = kept.filter(e => e.type === "reference");
|
||||
assert.equal(refEntries.length, 1, "Both reference variants should merge to one");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits decision: newer supersedes older on same topic", () => {
|
||||
// "4 formats" supersedes "3 formats" on the same parser topic
|
||||
const entries = [
|
||||
agedEntry("d1", "Parser supports 3 formats: HTML comment, Markdown section, legacy XML", "decision", { daysAgo: 2 }),
|
||||
agedEntry("d2", "Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML", "decision", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const decisionEntries = kept.filter(e => e.text.includes("formats"));
|
||||
assert.equal(decisionEntries.length, 1, "Newer 4-formats should supersede older 3-formats");
|
||||
assert.ok(decisionEntries[0].text.includes("4 formats"), "Kept entry should be the 4-formats one");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits feedback: newer supersedes older on same issue", () => {
|
||||
const entries = [
|
||||
agedEntry("f1", "Purple/italic text issue resolved by using plain text labels instead of any special markup syntax", "feedback", { daysAgo: 2 }),
|
||||
agedEntry("f2", "Purple/italic text issue resolved by replacing default compaction template with ---free version using only Markdown headings", "feedback", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const feedbackEntries = kept.filter(e => e.type === "feedback");
|
||||
assert.equal(feedbackEntries.length, 1, "Newer purple/italic fix should supersede older");
|
||||
assert.ok(feedbackEntries[0].text.includes("replacing default compaction template"), "Kept entry should be the newer fix");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits stale: compaction entry older than staleAfterDays+grace is pruned", () => {
|
||||
// decision with staleAfterDays=45, 76 days old (> 45+30 grace=75)
|
||||
const entries = [
|
||||
agedEntry("stale", "Compaction output contract changed from XML to HTML comments to avoid Markdown rendering issues", "decision", { daysAgo: 76, staleAfterDays: 45 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 0, "Stale compaction entry should be pruned");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits stale: explicit entry is retained even if old", () => {
|
||||
// explicit entry - never auto-pruned regardless of age
|
||||
const entries = [
|
||||
agedEntry("old_explicit", "User explicitly set Admin PIN 456123 for the system", "reference", { daysAgo: 500, source: "explicit", staleAfterDays: 90 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 1, "Explicit entry should never be age-pruned");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits stale: feedback entry is retained regardless of age", () => {
|
||||
// feedback - never age-pruned (only superseded)
|
||||
const entries = [
|
||||
agedEntry("old_feedback", "Users prefer darker themes over light themes", "feedback", { daysAgo: 300, staleAfterDays: 30 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 1, "Feedback entry should never be age-pruned");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits stale: compaction entry within grace period is retained", () => {
|
||||
// decision staleAfterDays=45, 60 days old (< 45+30=75 grace) - should keep
|
||||
const entries = [
|
||||
agedEntry("within_grace", "Some compaction decision made two months ago", "decision", { daysAgo: 60, staleAfterDays: 45 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 1, "Entry within grace period should be retained");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits dedup before trim: cleanup runs before maxEntries slice", () => {
|
||||
// 30 entries that should dedupe to < 28, confirming trim doesn't run before dedupe
|
||||
const entries = [
|
||||
...Array.from({ length: 15 }, (_, i) =>
|
||||
agedEntry(`a${i}`, "opencode uses npm cache for plugin loading", "decision", { daysAgo: 0 })
|
||||
),
|
||||
...Array.from({ length: 15 }, (_, i) =>
|
||||
agedEntry(`b${i}`, "opencode uses npm cache for plugin loading", "decision", { daysAgo: 0 })
|
||||
),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
assert.equal(kept.length, 1, "All duplicates should merge to 1 entry, far below maxEntries");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits priority: freshness used as tie-breaker among same priority entries", () => {
|
||||
// Same type, same source, same confidence — newer should win
|
||||
const older = agedEntry("older", "Some durable configuration fact about the workspace", "reference", { daysAgo: 30, source: "compaction", staleAfterDays: 90 });
|
||||
const newer = agedEntry("newer", "Some durable configuration fact about the workspace", "reference", { daysAgo: 5, source: "compaction", staleAfterDays: 90 });
|
||||
|
||||
const kept = enforceLongTermLimits([older, newer]);
|
||||
assert.equal(kept.length, 1);
|
||||
assert.equal(kept[0].id, "newer", "Newer entry should win as tie-breaker");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits feedback: 500 error and port issue are NOT collapsed", () => {
|
||||
// Distinct feedback entries should remain separate
|
||||
const entries = [
|
||||
agedEntry("f1", "瀏覽器登入出現 500 internal_error,代碼邏輯正確但原因不明", "feedback", { daysAgo: 0 }),
|
||||
agedEntry("f2", "端口 9473 可能被舊進程佔用,需殺掉後重啟", "feedback", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const feedbackEntries = kept.filter(e => e.type === "feedback");
|
||||
assert.equal(feedbackEntries.length, 2, "Distinct feedback items should not collapse");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits config: unrelated plugin configs are NOT collapsed", () => {
|
||||
const entries = [
|
||||
agedEntry("c1", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference", { daysAgo: 0 }),
|
||||
agedEntry("c2", "Vite plugin config location: vite.config.ts at project root", "reference", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const refEntries = kept.filter(e => e.type === "reference");
|
||||
assert.equal(refEntries.length, 2, "Unrelated plugin configs should remain separate");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits supersession: newer shorter decision beats older longer one", () => {
|
||||
// Same topic, same source, same confidence — newer wins even if shorter
|
||||
const older = agedEntry("d1", "Parser supports 3 formats: HTML comment, Markdown section, legacy XML with backward compatibility", "decision", { daysAgo: 5 });
|
||||
const newer = agedEntry("d2", "Parser supports 4 formats", "decision", { daysAgo: 0 });
|
||||
|
||||
const kept = enforceLongTermLimits([older, newer]);
|
||||
const decisions = kept.filter(e => e.type === "decision" && /parser.*format/i.test(e.text));
|
||||
assert.equal(decisions.length, 1, "Newer shorter decision should supersede older longer one");
|
||||
assert.ok(decisions[0].text.includes("4 formats"), "Kept entry should be the newer 4-formats");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits feedback: English port issue does NOT collapse with server error", () => {
|
||||
const entries = [
|
||||
agedEntry("e1", "Browser login 500 internal_error, code correct but cause unknown", "feedback", { daysAgo: 0 }),
|
||||
agedEntry("e2", "Port 9473 occupied by old process, may need to kill and restart", "feedback", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const feedbackEntries = kept.filter(e => e.type === "feedback");
|
||||
assert.equal(feedbackEntries.length, 2, "English port issue and server error should remain separate");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits config: unrelated generic plugin configs do NOT collapse", () => {
|
||||
const entries = [
|
||||
agedEntry("c1", "Vite plugin config location: vite.config.ts at project root", "reference", { daysAgo: 0 }),
|
||||
agedEntry("c2", "ESLint plugin config location: eslint.config.js at project root", "reference", { daysAgo: 0 }),
|
||||
];
|
||||
|
||||
const kept = enforceLongTermLimits(entries);
|
||||
const refEntries = kept.filter(e => e.type === "reference");
|
||||
assert.equal(refEntries.length, 2, "Unrelated plugin configs without entity key should remain separate");
|
||||
});
|
||||
|
||||
test("enforceLongTermLimits feedback: supersession prefers newer shorter over older longer", () => {
|
||||
// Same purple/italic issue, newer shorter fix supersedes older verbose fix
|
||||
const older = agedEntry("f1", "Purple/italic text issue resolved by using plain text labels instead of any special markup syntax in the prompt", "feedback", { daysAgo: 5 });
|
||||
const newer = agedEntry("f2", "Purple/italic text fixed via template replacement", "feedback", { daysAgo: 0 });
|
||||
|
||||
const kept = enforceLongTermLimits([older, newer]);
|
||||
const feedbackEntries = kept.filter(e => e.type === "feedback");
|
||||
assert.equal(feedbackEntries.length, 1, "Newer shorter feedback should supersede older longer");
|
||||
assert.ok(feedbackEntries[0].text.includes("template replacement"), "Kept entry should be the newer fix");
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Workspace cleanup migration tests
|
||||
// ============================================
|
||||
|
||||
test("redactCredentials preserves PIN delimiter variants", () => {
|
||||
assert.equal(redactCredentials("Admin PIN 是 456123"), "Admin PIN 是 [REDACTED]");
|
||||
assert.equal(redactCredentials("Admin PIN = 456123"), "Admin PIN = [REDACTED]");
|
||||
assert.equal(redactCredentials("Admin PIN 456123"), "Admin PIN [REDACTED]");
|
||||
});
|
||||
|
||||
test("redactCredentials handles multilingual passwords", () => {
|
||||
assert.equal(redactCredentials("パスワード:secret"), "パスワード:[REDACTED]");
|
||||
assert.equal(redactCredentials("비밀번호: secret"), "비밀번호: [REDACTED]");
|
||||
assert.equal(redactCredentials("contraseña: secret"), "contraseña: [REDACTED]");
|
||||
});
|
||||
|
||||
test("redactCredentials handles username+password pair and punctuation boundary", () => {
|
||||
assert.equal(
|
||||
redactCredentials("測試用戶名:shihlab,密碼:sushi"),
|
||||
"測試用戶名:[REDACTED],密碼:[REDACTED]",
|
||||
);
|
||||
assert.equal(
|
||||
redactCredentials("密碼:sushi,用於測試"),
|
||||
"密碼:[REDACTED],用於測試",
|
||||
);
|
||||
});
|
||||
|
||||
test("redactCredentials is idempotent and also redacts rationale text", () => {
|
||||
assert.equal(redactCredentials("password: [REDACTED]"), "password: [REDACTED]");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: 5200, maxEntries: 28 },
|
||||
migrations: [],
|
||||
entries: [
|
||||
{
|
||||
id: "cred",
|
||||
type: "reference",
|
||||
text: "Admin PIN 是 456123",
|
||||
rationale: "password: sushi",
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const migrated = runMigrationP0Cleanup(
|
||||
{
|
||||
...store,
|
||||
entries: store.entries.map(entry => ({
|
||||
...entry,
|
||||
text: redactCredentials(entry.text),
|
||||
rationale: entry.rationale ? redactCredentials(entry.rationale) : undefined,
|
||||
})),
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]");
|
||||
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
|
||||
});
|
||||
|
||||
test("isProjectSnapshotViolation detects wave progress and avoids limit context false positives", () => {
|
||||
assert.equal(isProjectSnapshotViolation("1237 tests pass, 226 suites"), true);
|
||||
assert.equal(isProjectSnapshotViolation("USB 同步:37 個文件"), true);
|
||||
assert.equal(isProjectSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true);
|
||||
|
||||
assert.equal(isProjectSnapshotViolation("Upload limit is 10 files"), false);
|
||||
assert.equal(isProjectSnapshotViolation("Project supports 5 test suites"), false);
|
||||
});
|
||||
|
||||
test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs once", () => {
|
||||
const now = new Date().toISOString();
|
||||
const later = new Date(Date.now() + 1000).toISOString();
|
||||
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: 5200, maxEntries: 28 },
|
||||
migrations: [],
|
||||
entries: [
|
||||
{
|
||||
id: "project-snapshot",
|
||||
type: "project",
|
||||
text: "Phase 1-4 已完成",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "project-explicit",
|
||||
type: "project",
|
||||
text: "Waves 1-5 已完成",
|
||||
source: "explicit",
|
||||
confidence: 1,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "feedback-snapshot-like",
|
||||
type: "feedback",
|
||||
text: "1237 tests pass",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const once = runMigrationP0Cleanup(store, now);
|
||||
assert.deepEqual(once.migrations, ["2026-04-26-p0-cleanup"]);
|
||||
assert.equal(once.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
|
||||
assert.equal(once.entries.find(e => e.id === "project-explicit")?.status, "active");
|
||||
assert.equal(once.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
|
||||
|
||||
const twice = runMigrationP0Cleanup(once, later);
|
||||
assert.deepEqual(twice.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
|
||||
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
|
||||
});
|
||||
|
||||
test("renderWorkspaceMemory excludes superseded entries", () => {
|
||||
const now = new Date().toISOString();
|
||||
const store: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root: "/repo", key: "abc" },
|
||||
limits: { maxRenderedChars: 5200, maxEntries: 28 },
|
||||
migrations: ["2026-04-26-p0-cleanup"],
|
||||
entries: [
|
||||
{
|
||||
id: "active-1",
|
||||
type: "decision",
|
||||
text: "Use pnpm for this workspace",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "sup-1",
|
||||
type: "project",
|
||||
text: "Waves 1-5 已完成",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "superseded",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const rendered = renderWorkspaceMemory(store);
|
||||
assert.match(rendered, /Use pnpm/);
|
||||
assert.doesNotMatch(rendered, /Waves 1-5 已完成/);
|
||||
});
|
||||
|
||||
test("loadWorkspaceMemory normalizes and persists credentials from legacy unredacted store", async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), "wm-redact-"));
|
||||
const dataHome = join(sandbox, "xdg-data-home");
|
||||
const root = join(sandbox, "workspace");
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
// Write UNREDACTED JSON directly to disk (simulating legacy store)
|
||||
const unredactedStore: WorkspaceMemoryStore = {
|
||||
version: 1,
|
||||
workspace: { root, key: "test" },
|
||||
limits: {
|
||||
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
|
||||
maxEntries: LONG_TERM_LIMITS.maxEntries,
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
id: "cred-1",
|
||||
text: "Admin PIN 是 456123",
|
||||
type: "project",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
migrations: [],
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Write directly to disk WITHOUT using saveWorkspaceMemory (which would redact)
|
||||
const { mkdir, writeFile } = await import("node:fs/promises");
|
||||
const storePath = await workspaceMemoryPath(root);
|
||||
await mkdir(dirname(storePath), { recursive: true });
|
||||
await writeFile(storePath, JSON.stringify(unredactedStore, null, 2), "utf-8");
|
||||
|
||||
// Load should normalize and redact
|
||||
const loaded = await loadWorkspaceMemory(root);
|
||||
assert.equal(loaded.entries[0].text, "Admin PIN 是 [REDACTED]");
|
||||
|
||||
// Verify persisted to disk (not just in-memory)
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const persistedRaw = await readFile(storePath, "utf-8");
|
||||
const persisted = JSON.parse(persistedRaw);
|
||||
assert.equal(persisted.entries[0].text, "Admin PIN 是 [REDACTED]");
|
||||
} finally {
|
||||
if (previousXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME;
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
}
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+16
-13
@@ -1,27 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node", "bun-types"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": false,
|
||||
"strictFunctionTypes": false,
|
||||
"strictBindCallApply": false,
|
||||
"strictPropertyInitialization": false,
|
||||
"noImplicitThis": false,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["index.ts"],
|
||||
"include": ["index.ts", "src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user