opencode-working-memory 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +340 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/docs/architecture.md +375 -0
- package/docs/configuration.md +376 -0
- package/docs/installation.md +131 -0
- package/index.ts +2080 -0
- package/package.json +40 -0
- package/tsconfig.json +28 -0
package/index.ts
ADDED
|
@@ -0,0 +1,2080 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working Memory Plugin for OpenCode
|
|
3
|
+
*
|
|
4
|
+
* Provides a three-tier memory system to delay/avoid compaction:
|
|
5
|
+
* 1. Core Memory - Persistent goal/progress/context blocks (always in-context)
|
|
6
|
+
* 2. Working Memory - Auto-managed session-relevant information
|
|
7
|
+
* 3. Smart Pruning - Content-aware tool output compression
|
|
8
|
+
* 4. Memory Pressure Monitoring - Context usage tracking with adaptive warnings
|
|
9
|
+
*
|
|
10
|
+
* Phase 1: Core Memory Foundation (MVP) - ✅ COMPLETED
|
|
11
|
+
* Phase 2: Smart Pruning System - ✅ COMPLETED
|
|
12
|
+
* Phase 3: Working Memory Auto-Management - ✅ COMPLETED
|
|
13
|
+
* Phase 4: Memory Pressure Monitoring - ✅ COMPLETED
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
17
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
18
|
+
import { existsSync } from "fs";
|
|
19
|
+
import { mkdir, readFile, writeFile, readdir, stat, unlink, rm } from "fs/promises";
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Types & Schemas
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
type CoreMemory = {
|
|
27
|
+
sessionID: string;
|
|
28
|
+
blocks: {
|
|
29
|
+
goal: CoreBlock;
|
|
30
|
+
progress: CoreBlock;
|
|
31
|
+
context: CoreBlock;
|
|
32
|
+
};
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type CoreBlock = {
|
|
37
|
+
value: string;
|
|
38
|
+
charLimit: number;
|
|
39
|
+
lastModified: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const CORE_MEMORY_LIMITS = {
|
|
43
|
+
goal: 1000, // ~250 tokens
|
|
44
|
+
progress: 2000, // ~500 tokens
|
|
45
|
+
context: 1500, // ~375 tokens
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Phase 2: Smart Pruning Types
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
type PruningStrategy =
|
|
53
|
+
| "keep-all"
|
|
54
|
+
| "keep-ends"
|
|
55
|
+
| "keep-last"
|
|
56
|
+
| "summarize"
|
|
57
|
+
| "discard";
|
|
58
|
+
|
|
59
|
+
type PruningRule = {
|
|
60
|
+
strategy: PruningStrategy;
|
|
61
|
+
firstChars?: number;
|
|
62
|
+
lastChars?: number;
|
|
63
|
+
maxChars?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type CachedToolOutput = {
|
|
67
|
+
callID: string;
|
|
68
|
+
sessionID: string;
|
|
69
|
+
tool: string;
|
|
70
|
+
fullOutput: string;
|
|
71
|
+
timestamp: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Phase 3: Working Memory Types (Slot-based Architecture)
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
type WorkingMemory = {
|
|
79
|
+
sessionID: string;
|
|
80
|
+
slots: {
|
|
81
|
+
error: WorkingMemoryItem[]; // FIFO queue, max 3 items
|
|
82
|
+
decision: WorkingMemoryItem[]; // FIFO queue, max 5 items
|
|
83
|
+
todo: WorkingMemoryItem[]; // FIFO queue, max 3 items
|
|
84
|
+
dependency: WorkingMemoryItem[]; // FIFO queue, max 3 items
|
|
85
|
+
};
|
|
86
|
+
pool: WorkingMemoryItem[]; // file-path, finding, other (exponential decay)
|
|
87
|
+
eventCounter: number; // Increments on every add operation
|
|
88
|
+
updatedAt: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type WorkingMemoryItem = {
|
|
92
|
+
id: string;
|
|
93
|
+
type: WorkingMemoryItemType;
|
|
94
|
+
content: string;
|
|
95
|
+
source: string; // e.g., "tool:read", "tool:bash", "manual"
|
|
96
|
+
timestamp: number;
|
|
97
|
+
relevanceScore: number; // Only used for pool items (decay-based scoring)
|
|
98
|
+
mentions: number; // How many times referenced
|
|
99
|
+
lastEventCounter?: number; // For pool items: tracks when score was last updated
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type WorkingMemoryItemType =
|
|
103
|
+
| "file-path" // Important file paths discovered (pool)
|
|
104
|
+
| "error" // Errors encountered (slot)
|
|
105
|
+
| "decision" // Key decisions made (slot)
|
|
106
|
+
| "other"; // Misc important info (pool)
|
|
107
|
+
|
|
108
|
+
// Slot-based types: guaranteed retention (FIFO)
|
|
109
|
+
type SlotType = "error" | "decision";
|
|
110
|
+
|
|
111
|
+
// Pool-based types: exponential decay
|
|
112
|
+
type PoolType = "file-path" | "other";
|
|
113
|
+
|
|
114
|
+
const SLOT_CONFIG: Record<SlotType, number> = {
|
|
115
|
+
error: 3, // Keep last 3 errors
|
|
116
|
+
decision: 3, // Keep last 3 decisions (FIFO, no human approval needed)
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const POOL_CONFIG = {
|
|
120
|
+
maxItems: 50, // Maximum pool items
|
|
121
|
+
gamma: 0.85, // Decay rate (15% decay per event)
|
|
122
|
+
minScore: 0.01, // Remove items below this score
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const WORKING_MEMORY_LIMITS = {
|
|
126
|
+
maxCharsPerItem: 200, // Max chars for each item
|
|
127
|
+
systemPromptBudget: 1600, // ~400 tokens for system prompt injection (doubled to show more items)
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// Storage Governance (Layer 1 + Layer 2)
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
const STORAGE_GOVERNANCE = {
|
|
135
|
+
toolOutputMaxFiles: 300, // Max tool-output files per session
|
|
136
|
+
toolOutputMaxAgeMs: 7 * 24 * 60 * 60 * 1000, // 7 days TTL
|
|
137
|
+
sweepInterval: 20, // Sweep every N tool calls
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// ============================================================================
|
|
141
|
+
// Phase 4: Memory Pressure Monitoring
|
|
142
|
+
// ============================================================================
|
|
143
|
+
|
|
144
|
+
type PressureLevel = "safe" | "moderate" | "high";
|
|
145
|
+
|
|
146
|
+
type ModelPressureInfo = {
|
|
147
|
+
sessionID: string;
|
|
148
|
+
modelID: string;
|
|
149
|
+
providerID: string;
|
|
150
|
+
limits: {
|
|
151
|
+
context: number;
|
|
152
|
+
input?: number;
|
|
153
|
+
output: number;
|
|
154
|
+
};
|
|
155
|
+
calculated: {
|
|
156
|
+
maxOutputTokens: number; // min(model.limit.output, 32000)
|
|
157
|
+
reserved: number; // min(20000, maxOutputTokens)
|
|
158
|
+
usable: number; // input - reserved OR context - maxOutputTokens
|
|
159
|
+
};
|
|
160
|
+
current: {
|
|
161
|
+
totalTokens: number; // sum of all message tokens
|
|
162
|
+
pressure: number; // totalTokens / usable (0.0 - 1.0+)
|
|
163
|
+
level: PressureLevel;
|
|
164
|
+
};
|
|
165
|
+
thresholds: {
|
|
166
|
+
moderate: number; // usable * 0.75
|
|
167
|
+
high: number; // usable * 0.90
|
|
168
|
+
};
|
|
169
|
+
updatedAt: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Compaction tracking (preserved from Phase 4 initial work)
|
|
173
|
+
type CompactionLog = {
|
|
174
|
+
sessionID: string;
|
|
175
|
+
compactionCount: number;
|
|
176
|
+
lastCompaction: number | null; // timestamp
|
|
177
|
+
preservedItems: number; // how many working memory items were preserved
|
|
178
|
+
updatedAt: string;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// Storage Management
|
|
183
|
+
// ============================================================================
|
|
184
|
+
|
|
185
|
+
function getCoreMemoryPath(directory: string, sessionID: string): string {
|
|
186
|
+
return join(directory, ".opencode", "memory-core", `${sessionID}.json`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getToolOutputCachePath(
|
|
190
|
+
directory: string,
|
|
191
|
+
sessionID: string,
|
|
192
|
+
callID: string
|
|
193
|
+
): string {
|
|
194
|
+
return join(
|
|
195
|
+
directory,
|
|
196
|
+
".opencode",
|
|
197
|
+
"memory-working",
|
|
198
|
+
"tool-outputs",
|
|
199
|
+
sessionID,
|
|
200
|
+
`${callID}.json`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function ensureToolOutputCacheDir(
|
|
205
|
+
directory: string,
|
|
206
|
+
sessionID: string
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
const dir = join(
|
|
209
|
+
directory,
|
|
210
|
+
".opencode",
|
|
211
|
+
"memory-working",
|
|
212
|
+
"tool-outputs",
|
|
213
|
+
sessionID
|
|
214
|
+
);
|
|
215
|
+
if (!existsSync(dir)) {
|
|
216
|
+
await mkdir(dir, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function ensureCoreMemoryDir(directory: string): Promise<void> {
|
|
221
|
+
const dir = join(directory, ".opencode", "memory-core");
|
|
222
|
+
if (!existsSync(dir)) {
|
|
223
|
+
await mkdir(dir, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function getWorkingMemoryPath(directory: string, sessionID: string): string {
|
|
228
|
+
return join(directory, ".opencode", "memory-working", `${sessionID}.json`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function ensureWorkingMemoryDir(directory: string): Promise<void> {
|
|
232
|
+
const dir = join(directory, ".opencode", "memory-working");
|
|
233
|
+
if (!existsSync(dir)) {
|
|
234
|
+
await mkdir(dir, { recursive: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getCompactionLogPath(directory: string, sessionID: string): string {
|
|
239
|
+
return join(directory, ".opencode", "memory-working", `${sessionID}_compaction.json`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getModelPressurePath(directory: string, sessionID: string): string {
|
|
243
|
+
return join(directory, ".opencode", "memory-working", `${sessionID}_pressure.json`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function loadCoreMemory(
|
|
247
|
+
directory: string,
|
|
248
|
+
sessionID: string
|
|
249
|
+
): Promise<CoreMemory | null> {
|
|
250
|
+
const path = getCoreMemoryPath(directory, sessionID);
|
|
251
|
+
if (!existsSync(path)) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const content = await readFile(path, "utf-8");
|
|
257
|
+
return JSON.parse(content) as CoreMemory;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error("Failed to load core memory:", error);
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function saveCoreMemory(
|
|
265
|
+
directory: string,
|
|
266
|
+
memory: CoreMemory
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
await ensureCoreMemoryDir(directory);
|
|
269
|
+
const path = getCoreMemoryPath(directory, memory.sessionID);
|
|
270
|
+
await writeFile(path, JSON.stringify(memory, null, 2), "utf-8");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function createEmptyCoreMemory(sessionID: string): CoreMemory {
|
|
274
|
+
const now = new Date().toISOString();
|
|
275
|
+
return {
|
|
276
|
+
sessionID,
|
|
277
|
+
blocks: {
|
|
278
|
+
goal: {
|
|
279
|
+
value: "",
|
|
280
|
+
charLimit: CORE_MEMORY_LIMITS.goal,
|
|
281
|
+
lastModified: now,
|
|
282
|
+
},
|
|
283
|
+
progress: {
|
|
284
|
+
value: "",
|
|
285
|
+
charLimit: CORE_MEMORY_LIMITS.progress,
|
|
286
|
+
lastModified: now,
|
|
287
|
+
},
|
|
288
|
+
context: {
|
|
289
|
+
value: "",
|
|
290
|
+
charLimit: CORE_MEMORY_LIMITS.context,
|
|
291
|
+
lastModified: now,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
updatedAt: now,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function loadWorkingMemory(
|
|
299
|
+
directory: string,
|
|
300
|
+
sessionID: string
|
|
301
|
+
): Promise<WorkingMemory | null> {
|
|
302
|
+
const path = getWorkingMemoryPath(directory, sessionID);
|
|
303
|
+
if (!existsSync(path)) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const content = await readFile(path, "utf-8");
|
|
309
|
+
const data = JSON.parse(content);
|
|
310
|
+
|
|
311
|
+
// Migration: Convert old format (items array) to new format (slots + pool)
|
|
312
|
+
if (data.items && !data.slots) {
|
|
313
|
+
console.log("[Working Memory] Migrating from old format to slot-based architecture...");
|
|
314
|
+
const migrated: WorkingMemory = {
|
|
315
|
+
sessionID: data.sessionID,
|
|
316
|
+
slots: {
|
|
317
|
+
error: [],
|
|
318
|
+
decision: [],
|
|
319
|
+
todo: [],
|
|
320
|
+
dependency: [],
|
|
321
|
+
},
|
|
322
|
+
pool: [],
|
|
323
|
+
eventCounter: 0,
|
|
324
|
+
updatedAt: new Date().toISOString(),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Route each item to slot or pool
|
|
328
|
+
for (const item of data.items) {
|
|
329
|
+
const slotType = item.type as SlotType;
|
|
330
|
+
if (slotType in SLOT_CONFIG) {
|
|
331
|
+
// Slot-based item: add to appropriate slot (FIFO will be applied later)
|
|
332
|
+
migrated.slots[slotType].push(item);
|
|
333
|
+
} else {
|
|
334
|
+
// Pool-based item: add to pool
|
|
335
|
+
migrated.pool.push(item);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Apply FIFO limits to slots
|
|
340
|
+
for (const slotType of Object.keys(SLOT_CONFIG) as SlotType[]) {
|
|
341
|
+
const limit = SLOT_CONFIG[slotType];
|
|
342
|
+
if (migrated.slots[slotType].length > limit) {
|
|
343
|
+
// Keep only the most recent items
|
|
344
|
+
migrated.slots[slotType] = migrated.slots[slotType]
|
|
345
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
346
|
+
.slice(0, limit);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Apply pool limits
|
|
351
|
+
if (migrated.pool.length > POOL_CONFIG.maxItems) {
|
|
352
|
+
migrated.pool = migrated.pool
|
|
353
|
+
.sort((a, b) => b.relevanceScore - a.relevanceScore)
|
|
354
|
+
.slice(0, POOL_CONFIG.maxItems);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
console.log(`[Working Memory] Migration complete: ${data.items.length} items -> ${Object.values(migrated.slots).flat().length} slot items + ${migrated.pool.length} pool items`);
|
|
358
|
+
|
|
359
|
+
// Save migrated version
|
|
360
|
+
await saveWorkingMemory(directory, migrated);
|
|
361
|
+
return migrated;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return data as WorkingMemory;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
console.error("Failed to load working memory:", error);
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function saveWorkingMemory(
|
|
372
|
+
directory: string,
|
|
373
|
+
memory: WorkingMemory
|
|
374
|
+
): Promise<void> {
|
|
375
|
+
await ensureWorkingMemoryDir(directory);
|
|
376
|
+
const path = getWorkingMemoryPath(directory, memory.sessionID);
|
|
377
|
+
await writeFile(path, JSON.stringify(memory, null, 2), "utf-8");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function createEmptyWorkingMemory(sessionID: string): WorkingMemory {
|
|
381
|
+
return {
|
|
382
|
+
sessionID,
|
|
383
|
+
slots: {
|
|
384
|
+
error: [],
|
|
385
|
+
decision: [],
|
|
386
|
+
todo: [],
|
|
387
|
+
dependency: [],
|
|
388
|
+
},
|
|
389
|
+
pool: [],
|
|
390
|
+
eventCounter: 0,
|
|
391
|
+
updatedAt: new Date().toISOString(),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function loadCompactionLog(
|
|
396
|
+
directory: string,
|
|
397
|
+
sessionID: string
|
|
398
|
+
): Promise<CompactionLog | null> {
|
|
399
|
+
const path = getCompactionLogPath(directory, sessionID);
|
|
400
|
+
if (!existsSync(path)) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const content = await Bun.file(path).text();
|
|
406
|
+
return JSON.parse(content) as CompactionLog;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function saveCompactionLog(
|
|
413
|
+
directory: string,
|
|
414
|
+
log: CompactionLog
|
|
415
|
+
): Promise<void> {
|
|
416
|
+
await ensureWorkingMemoryDir(directory);
|
|
417
|
+
const path = getCompactionLogPath(directory, log.sessionID);
|
|
418
|
+
await Bun.write(path, JSON.stringify(log, null, 2));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function createInitialCompactionLog(sessionID: string): CompactionLog {
|
|
422
|
+
return {
|
|
423
|
+
sessionID,
|
|
424
|
+
compactionCount: 0,
|
|
425
|
+
lastCompaction: null,
|
|
426
|
+
preservedItems: 0,
|
|
427
|
+
updatedAt: new Date().toISOString(),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ============================================================================
|
|
432
|
+
// Core Memory Operations
|
|
433
|
+
// ============================================================================
|
|
434
|
+
|
|
435
|
+
function validateBlockContent(
|
|
436
|
+
block: keyof CoreMemory["blocks"],
|
|
437
|
+
content: string
|
|
438
|
+
): { valid: boolean; error?: string; truncated?: string } {
|
|
439
|
+
const limit = CORE_MEMORY_LIMITS[block];
|
|
440
|
+
|
|
441
|
+
if (content.length === 0) {
|
|
442
|
+
return { valid: true };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (content.length <= limit) {
|
|
446
|
+
return { valid: true };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Auto-truncate with warning
|
|
450
|
+
const truncated = content.slice(0, limit);
|
|
451
|
+
const charsRemoved = content.length - limit;
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
valid: false,
|
|
455
|
+
error: `Content exceeds ${block} block limit (${limit} chars). Truncated ${charsRemoved} chars.`,
|
|
456
|
+
truncated,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function updateCoreMemoryBlock(
|
|
461
|
+
directory: string,
|
|
462
|
+
sessionID: string,
|
|
463
|
+
block: keyof CoreMemory["blocks"],
|
|
464
|
+
operation: "replace" | "append",
|
|
465
|
+
content: string
|
|
466
|
+
): Promise<{ success: boolean; message: string; memory?: CoreMemory }> {
|
|
467
|
+
let memory = await loadCoreMemory(directory, sessionID);
|
|
468
|
+
|
|
469
|
+
if (!memory) {
|
|
470
|
+
memory = createEmptyCoreMemory(sessionID);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let newValue: string;
|
|
474
|
+
if (operation === "replace") {
|
|
475
|
+
newValue = content;
|
|
476
|
+
} else {
|
|
477
|
+
// append
|
|
478
|
+
const currentValue = memory.blocks[block].value;
|
|
479
|
+
newValue = currentValue
|
|
480
|
+
? `${currentValue}\n${content}`
|
|
481
|
+
: content;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const validation = validateBlockContent(block, newValue);
|
|
485
|
+
|
|
486
|
+
if (!validation.valid && validation.truncated) {
|
|
487
|
+
newValue = validation.truncated;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
memory.blocks[block].value = newValue;
|
|
491
|
+
memory.blocks[block].lastModified = new Date().toISOString();
|
|
492
|
+
memory.updatedAt = new Date().toISOString();
|
|
493
|
+
|
|
494
|
+
await saveCoreMemory(directory, memory);
|
|
495
|
+
|
|
496
|
+
const message = validation.error
|
|
497
|
+
? `⚠️ ${validation.error}\n\nUpdated ${block} block (${operation}): ${newValue.length}/${CORE_MEMORY_LIMITS[block]} chars used.`
|
|
498
|
+
: `✅ Updated ${block} block (${operation}): ${newValue.length}/${CORE_MEMORY_LIMITS[block]} chars used.`;
|
|
499
|
+
|
|
500
|
+
return { success: true, message, memory };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ============================================================================
|
|
504
|
+
// Storage Governance Functions (Layer 1 + Layer 2)
|
|
505
|
+
// ============================================================================
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Layer 1: Clean up all artifacts for a deleted session
|
|
509
|
+
* Called when session.deleted event is received
|
|
510
|
+
*/
|
|
511
|
+
async function cleanupSessionArtifacts(
|
|
512
|
+
directory: string,
|
|
513
|
+
sessionID: string
|
|
514
|
+
): Promise<void> {
|
|
515
|
+
try {
|
|
516
|
+
const artifacts = [
|
|
517
|
+
join(directory, ".opencode", "memory-core", `${sessionID}.json`),
|
|
518
|
+
join(directory, ".opencode", "memory-working", `${sessionID}.json`),
|
|
519
|
+
join(directory, ".opencode", "memory-working", `${sessionID}_pressure.json`),
|
|
520
|
+
join(directory, ".opencode", "memory-working", `${sessionID}_compaction.json`),
|
|
521
|
+
join(directory, ".opencode", "memory-working", "tool-outputs", sessionID),
|
|
522
|
+
];
|
|
523
|
+
|
|
524
|
+
for (const path of artifacts) {
|
|
525
|
+
if (existsSync(path)) {
|
|
526
|
+
await rm(path, { recursive: true, force: true });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (error) {
|
|
530
|
+
// Silent failure - cleanup errors are non-critical
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Layer 2: Sweep tool-output cache for a session
|
|
536
|
+
* Remove files older than TTL and enforce max file count
|
|
537
|
+
* Returns number of files deleted
|
|
538
|
+
*/
|
|
539
|
+
async function sweepToolOutputCache(
|
|
540
|
+
directory: string,
|
|
541
|
+
sessionID: string
|
|
542
|
+
): Promise<number> {
|
|
543
|
+
const cacheDir = join(directory, ".opencode", "memory-working", "tool-outputs", sessionID);
|
|
544
|
+
|
|
545
|
+
if (!existsSync(cacheDir)) {
|
|
546
|
+
return 0;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
const files = await readdir(cacheDir);
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
const { toolOutputMaxFiles, toolOutputMaxAgeMs } = STORAGE_GOVERNANCE;
|
|
553
|
+
|
|
554
|
+
// Collect file stats
|
|
555
|
+
const fileStats: Array<{ name: string; mtime: number; path: string }> = [];
|
|
556
|
+
for (const file of files) {
|
|
557
|
+
const filePath = join(cacheDir, file);
|
|
558
|
+
try {
|
|
559
|
+
const stats = await stat(filePath);
|
|
560
|
+
if (stats.isFile()) {
|
|
561
|
+
fileStats.push({
|
|
562
|
+
name: file,
|
|
563
|
+
mtime: stats.mtimeMs,
|
|
564
|
+
path: filePath,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
// Skip files that can't be stat'd
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Identify files to delete
|
|
573
|
+
const toDelete: string[] = [];
|
|
574
|
+
|
|
575
|
+
// 1. Delete files older than TTL
|
|
576
|
+
for (const file of fileStats) {
|
|
577
|
+
if (now - file.mtime > toolOutputMaxAgeMs) {
|
|
578
|
+
toDelete.push(file.path);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 2. If still over limit, delete oldest files
|
|
583
|
+
const remaining = fileStats.filter(f => !toDelete.includes(f.path));
|
|
584
|
+
if (remaining.length > toolOutputMaxFiles) {
|
|
585
|
+
// Sort by mtime ascending (oldest first)
|
|
586
|
+
remaining.sort((a, b) => a.mtime - b.mtime);
|
|
587
|
+
const excess = remaining.length - toolOutputMaxFiles;
|
|
588
|
+
for (let i = 0; i < excess; i++) {
|
|
589
|
+
toDelete.push(remaining[i].path);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Delete files
|
|
594
|
+
for (const path of toDelete) {
|
|
595
|
+
try {
|
|
596
|
+
await unlink(path);
|
|
597
|
+
} catch (err) {
|
|
598
|
+
// Ignore unlink errors (file might already be gone)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return toDelete.length;
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return 0;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ============================================================================
|
|
609
|
+
// Phase 2: Smart Pruning System
|
|
610
|
+
// ============================================================================
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Get pruning rule for a specific tool
|
|
614
|
+
*/
|
|
615
|
+
function getPruningRule(toolName: string): PruningRule {
|
|
616
|
+
const rules: Record<string, PruningRule> = {
|
|
617
|
+
// Keep all - valuable outputs
|
|
618
|
+
grep: { strategy: "keep-all" },
|
|
619
|
+
glob: { strategy: "keep-all" },
|
|
620
|
+
memory_toast_retrieve: { strategy: "keep-all" },
|
|
621
|
+
skill: { strategy: "keep-all" },
|
|
622
|
+
|
|
623
|
+
// Keep ends - code files
|
|
624
|
+
read: { strategy: "keep-ends", firstChars: 500, lastChars: 300 },
|
|
625
|
+
|
|
626
|
+
// Keep last - command outputs
|
|
627
|
+
bash: { strategy: "keep-last", maxChars: 1000 },
|
|
628
|
+
|
|
629
|
+
// Keep first - task summaries
|
|
630
|
+
task: { strategy: "keep-last", maxChars: 1500 },
|
|
631
|
+
|
|
632
|
+
// Discard - confirmations
|
|
633
|
+
edit: { strategy: "discard" },
|
|
634
|
+
write: { strategy: "discard" },
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
return rules[toolName] || { strategy: "keep-last", maxChars: 1000 };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Apply smart pruning to tool output with pressure-aware limits
|
|
642
|
+
*/
|
|
643
|
+
function applySmartPruning(
|
|
644
|
+
output: string,
|
|
645
|
+
rule: PruningRule,
|
|
646
|
+
pressureConfig?: { maxLines: number; maxChars: number; aggressiveTruncation: boolean }
|
|
647
|
+
): string {
|
|
648
|
+
let result = output;
|
|
649
|
+
|
|
650
|
+
// Apply pressure-aware hard limits FIRST (if provided)
|
|
651
|
+
if (pressureConfig && pressureConfig.aggressiveTruncation) {
|
|
652
|
+
const lines = result.split('\n');
|
|
653
|
+
|
|
654
|
+
// HYPER-AGGRESSIVE: Enforce hard line limit
|
|
655
|
+
if (lines.length > pressureConfig.maxLines) {
|
|
656
|
+
const omittedLines = lines.length - pressureConfig.maxLines;
|
|
657
|
+
result = lines.slice(0, pressureConfig.maxLines).join('\n');
|
|
658
|
+
result += `\n\n[⚠️ MEMORY PRESSURE: ${omittedLines} lines truncated. Use Grep/Task tool instead of direct reads.]`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// HYPER-AGGRESSIVE: Enforce hard char limit
|
|
662
|
+
if (result.length > pressureConfig.maxChars) {
|
|
663
|
+
const omittedChars = result.length - pressureConfig.maxChars;
|
|
664
|
+
result = result.slice(0, pressureConfig.maxChars);
|
|
665
|
+
result += `\n\n[⚠️ MEMORY PRESSURE: ${omittedChars} chars truncated]`;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Then apply normal pruning strategy
|
|
670
|
+
switch (rule.strategy) {
|
|
671
|
+
case "keep-all":
|
|
672
|
+
return result;
|
|
673
|
+
|
|
674
|
+
case "keep-ends":
|
|
675
|
+
return keepFirstAndLast(
|
|
676
|
+
result,
|
|
677
|
+
rule.firstChars || 500,
|
|
678
|
+
rule.lastChars || 300
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
case "keep-last":
|
|
682
|
+
return keepLast(result, rule.maxChars || 1000);
|
|
683
|
+
|
|
684
|
+
case "summarize":
|
|
685
|
+
return extractSummary(result, rule.maxChars || 500);
|
|
686
|
+
|
|
687
|
+
case "discard":
|
|
688
|
+
return `[Tool completed successfully]`;
|
|
689
|
+
|
|
690
|
+
default:
|
|
691
|
+
return result;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Keep first N and last M chars, with omission notice
|
|
697
|
+
*/
|
|
698
|
+
function keepFirstAndLast(
|
|
699
|
+
text: string,
|
|
700
|
+
firstChars: number,
|
|
701
|
+
lastChars: number
|
|
702
|
+
): string {
|
|
703
|
+
if (text.length <= firstChars + lastChars) {
|
|
704
|
+
return text;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const firstPart = text.slice(0, firstChars);
|
|
708
|
+
const lastPart = text.slice(-lastChars);
|
|
709
|
+
const omitted = text.length - firstChars - lastChars;
|
|
710
|
+
|
|
711
|
+
return `${firstPart}\n\n[... ${omitted} chars omitted for brevity ...]\n\n${lastPart}`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Keep only last N chars
|
|
716
|
+
*/
|
|
717
|
+
function keepLast(text: string, maxChars: number): string {
|
|
718
|
+
if (text.length <= maxChars) {
|
|
719
|
+
return text;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const omitted = text.length - maxChars;
|
|
723
|
+
return `[... ${omitted} chars omitted ...]\n\n${text.slice(-maxChars)}`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Extract summary from output (simple implementation)
|
|
728
|
+
*/
|
|
729
|
+
function extractSummary(text: string, maxChars: number): string {
|
|
730
|
+
// Simple: just take first maxChars as "summary"
|
|
731
|
+
// Could be enhanced with LLM-based summarization
|
|
732
|
+
if (text.length <= maxChars) {
|
|
733
|
+
return text;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return `${text.slice(0, maxChars)}\n[... truncated at ${maxChars} chars ...]`;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Store full tool output for later smart pruning
|
|
741
|
+
*/
|
|
742
|
+
async function cacheToolOutput(
|
|
743
|
+
directory: string,
|
|
744
|
+
cached: CachedToolOutput
|
|
745
|
+
): Promise<void> {
|
|
746
|
+
await ensureToolOutputCacheDir(directory, cached.sessionID);
|
|
747
|
+
const path = getToolOutputCachePath(
|
|
748
|
+
directory,
|
|
749
|
+
cached.sessionID,
|
|
750
|
+
cached.callID
|
|
751
|
+
);
|
|
752
|
+
await writeFile(path, JSON.stringify(cached, null, 2), "utf-8");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Retrieve cached tool output
|
|
757
|
+
*/
|
|
758
|
+
async function getCachedToolOutput(
|
|
759
|
+
directory: string,
|
|
760
|
+
sessionID: string,
|
|
761
|
+
callID: string
|
|
762
|
+
): Promise<CachedToolOutput | null> {
|
|
763
|
+
const path = getToolOutputCachePath(directory, sessionID, callID);
|
|
764
|
+
if (!existsSync(path)) {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
const content = await readFile(path, "utf-8");
|
|
770
|
+
return JSON.parse(content) as CachedToolOutput;
|
|
771
|
+
} catch (error) {
|
|
772
|
+
console.error("Failed to load cached tool output:", error);
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ============================================================================
|
|
778
|
+
// Phase 3: Working Memory Auto-Management
|
|
779
|
+
// ============================================================================
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Generate unique ID for working memory item
|
|
783
|
+
*/
|
|
784
|
+
function generateItemID(): string {
|
|
785
|
+
return `wm_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Extract key information from tool output
|
|
790
|
+
*/
|
|
791
|
+
function extractFromToolOutput(
|
|
792
|
+
toolName: string,
|
|
793
|
+
output: string
|
|
794
|
+
): WorkingMemoryItem[] {
|
|
795
|
+
const items: WorkingMemoryItem[] = [];
|
|
796
|
+
const timestamp = Date.now();
|
|
797
|
+
|
|
798
|
+
switch (toolName) {
|
|
799
|
+
case "read":
|
|
800
|
+
case "glob": {
|
|
801
|
+
// Extract file paths
|
|
802
|
+
const pathMatches = output.match(/[\w\-\/\.]+\.(ts|js|json|md|tsx|jsx|py|java|go|rs)/g);
|
|
803
|
+
if (pathMatches) {
|
|
804
|
+
const uniquePaths = [...new Set(pathMatches)].slice(0, 5); // Top 5 unique paths
|
|
805
|
+
for (const path of uniquePaths) {
|
|
806
|
+
items.push({
|
|
807
|
+
id: generateItemID(),
|
|
808
|
+
type: "file-path",
|
|
809
|
+
content: path,
|
|
810
|
+
source: `tool:${toolName}`,
|
|
811
|
+
timestamp,
|
|
812
|
+
relevanceScore: 0,
|
|
813
|
+
mentions: 1,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
case "bash": {
|
|
821
|
+
// Extract errors
|
|
822
|
+
if (output.toLowerCase().includes("error") || output.toLowerCase().includes("failed")) {
|
|
823
|
+
const errorLines = output
|
|
824
|
+
.split("\n")
|
|
825
|
+
.filter(line =>
|
|
826
|
+
line.toLowerCase().includes("error") ||
|
|
827
|
+
line.toLowerCase().includes("failed")
|
|
828
|
+
)
|
|
829
|
+
.slice(0, 3); // Top 3 error lines
|
|
830
|
+
|
|
831
|
+
for (const line of errorLines) {
|
|
832
|
+
const truncated = line.slice(0, WORKING_MEMORY_LIMITS.maxCharsPerItem);
|
|
833
|
+
items.push({
|
|
834
|
+
id: generateItemID(),
|
|
835
|
+
type: "error",
|
|
836
|
+
content: truncated,
|
|
837
|
+
source: "tool:bash",
|
|
838
|
+
timestamp,
|
|
839
|
+
relevanceScore: 0,
|
|
840
|
+
mentions: 1,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
case "grep": {
|
|
848
|
+
// Extract file paths with matches (treat as file-path, not a separate "finding" type)
|
|
849
|
+
// OpenCode grep format: "Found N matches\n/path/to/file:\n Line X: ..."
|
|
850
|
+
// Match file paths that end with common extensions followed by ":"
|
|
851
|
+
const grepMatches = output.match(/^(\/[^\n]+\.(ts|js|md|json|tsx|jsx|py|java|go|rs|txt|yml|yaml|toml)):/gm);
|
|
852
|
+
if (grepMatches) {
|
|
853
|
+
const uniqueFiles = [...new Set(grepMatches.map(m => m.replace(/:$/, "")))].slice(0, 5);
|
|
854
|
+
for (const file of uniqueFiles) {
|
|
855
|
+
items.push({
|
|
856
|
+
id: generateItemID(),
|
|
857
|
+
type: "file-path",
|
|
858
|
+
content: file,
|
|
859
|
+
source: "tool:grep",
|
|
860
|
+
timestamp,
|
|
861
|
+
relevanceScore: 0,
|
|
862
|
+
mentions: 1,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
case "edit":
|
|
870
|
+
case "write": {
|
|
871
|
+
// Extract file paths being modified
|
|
872
|
+
const filePathMatch = output.match(/([\w\-\/\.]+\.(ts|js|json|md|tsx|jsx|py|java|go|rs))/);
|
|
873
|
+
if (filePathMatch) {
|
|
874
|
+
items.push({
|
|
875
|
+
id: generateItemID(),
|
|
876
|
+
type: "file-path",
|
|
877
|
+
content: `Modified: ${filePathMatch[1]}`,
|
|
878
|
+
source: `tool:${toolName}`,
|
|
879
|
+
timestamp,
|
|
880
|
+
relevanceScore: 0,
|
|
881
|
+
mentions: 1,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return items;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Calculate relevance score for pool items (exponential decay)
|
|
893
|
+
*
|
|
894
|
+
* Formula: S_i^(t) = S_i^(t-1) × γ^(events_elapsed) + W_i
|
|
895
|
+
* Where:
|
|
896
|
+
* - γ = decay rate (0.85)
|
|
897
|
+
* - events_elapsed = current eventCounter - item's lastEventCounter
|
|
898
|
+
* - W_i = mention boost (mentions × 1.0)
|
|
899
|
+
*/
|
|
900
|
+
function calculatePoolScore(
|
|
901
|
+
item: WorkingMemoryItem,
|
|
902
|
+
currentEventCounter: number
|
|
903
|
+
): number {
|
|
904
|
+
// Calculate events elapsed since last update
|
|
905
|
+
const lastEvent = item.lastEventCounter ?? 0;
|
|
906
|
+
const eventsElapsed = currentEventCounter - lastEvent;
|
|
907
|
+
|
|
908
|
+
// Apply exponential decay to existing score
|
|
909
|
+
const decayedScore = item.relevanceScore * Math.pow(POOL_CONFIG.gamma, eventsElapsed);
|
|
910
|
+
|
|
911
|
+
// Mention boost (not subject to decay)
|
|
912
|
+
const mentionBoost = item.mentions * 0.5;
|
|
913
|
+
|
|
914
|
+
return Math.max(0, decayedScore + mentionBoost);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Helper: Check if item type is slot-based
|
|
919
|
+
*/
|
|
920
|
+
function isSlotType(type: WorkingMemoryItemType): type is SlotType {
|
|
921
|
+
return type in SLOT_CONFIG;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Add item to working memory (slot-based architecture with auto-cleanup)
|
|
926
|
+
*/
|
|
927
|
+
async function addToWorkingMemory(
|
|
928
|
+
directory: string,
|
|
929
|
+
sessionID: string,
|
|
930
|
+
item: Omit<WorkingMemoryItem, "id" | "relevanceScore">
|
|
931
|
+
): Promise<WorkingMemory> {
|
|
932
|
+
let memory = await loadWorkingMemory(directory, sessionID);
|
|
933
|
+
if (!memory) {
|
|
934
|
+
memory = createEmptyWorkingMemory(sessionID);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Increment event counter for pool decay
|
|
938
|
+
memory.eventCounter += 1;
|
|
939
|
+
|
|
940
|
+
if (isSlotType(item.type)) {
|
|
941
|
+
// ===== Slot-based item: FIFO queue =====
|
|
942
|
+
const slotType = item.type as SlotType;
|
|
943
|
+
const slot = memory.slots[slotType];
|
|
944
|
+
|
|
945
|
+
// Check for duplicates (same content)
|
|
946
|
+
const existing = slot.find(i => i.content === item.content);
|
|
947
|
+
if (existing) {
|
|
948
|
+
// Increment mentions, update timestamp (refresh item)
|
|
949
|
+
existing.mentions += 1;
|
|
950
|
+
existing.timestamp = item.timestamp;
|
|
951
|
+
} else {
|
|
952
|
+
// Add new item
|
|
953
|
+
const newItem: WorkingMemoryItem = {
|
|
954
|
+
...item,
|
|
955
|
+
id: generateItemID(),
|
|
956
|
+
relevanceScore: 0, // Not used for slots
|
|
957
|
+
};
|
|
958
|
+
slot.push(newItem);
|
|
959
|
+
|
|
960
|
+
// Apply FIFO limit: keep only most recent N items
|
|
961
|
+
const limit = SLOT_CONFIG[slotType];
|
|
962
|
+
if (slot.length > limit) {
|
|
963
|
+
// Sort by timestamp descending, keep top N
|
|
964
|
+
slot.sort((a, b) => b.timestamp - a.timestamp);
|
|
965
|
+
memory.slots[slotType] = slot.slice(0, limit);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
} else {
|
|
969
|
+
// ===== Pool-based item: Exponential decay =====
|
|
970
|
+
// Check for duplicates (same content)
|
|
971
|
+
const existing = memory.pool.find(i => i.content === item.content);
|
|
972
|
+
if (existing) {
|
|
973
|
+
// Recalculate score with decay first
|
|
974
|
+
existing.relevanceScore = calculatePoolScore(existing, memory.eventCounter);
|
|
975
|
+
|
|
976
|
+
// Then increment mentions and update timestamp
|
|
977
|
+
existing.mentions += 1;
|
|
978
|
+
existing.timestamp = item.timestamp;
|
|
979
|
+
existing.lastEventCounter = memory.eventCounter;
|
|
980
|
+
|
|
981
|
+
// Add mention boost to score
|
|
982
|
+
existing.relevanceScore += 0.5;
|
|
983
|
+
} else {
|
|
984
|
+
// Add new item with initial score
|
|
985
|
+
const newItem: WorkingMemoryItem = {
|
|
986
|
+
...item,
|
|
987
|
+
id: generateItemID(),
|
|
988
|
+
relevanceScore: 1.0, // Initial score
|
|
989
|
+
lastEventCounter: memory.eventCounter,
|
|
990
|
+
};
|
|
991
|
+
memory.pool.push(newItem);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Apply decay to all OTHER pool items (not the one we just added/updated)
|
|
995
|
+
for (const poolItem of memory.pool) {
|
|
996
|
+
if (poolItem.content !== item.content) {
|
|
997
|
+
poolItem.relevanceScore = calculatePoolScore(poolItem, memory.eventCounter);
|
|
998
|
+
poolItem.lastEventCounter = memory.eventCounter;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Remove items below min score
|
|
1003
|
+
memory.pool = memory.pool.filter(i => i.relevanceScore >= POOL_CONFIG.minScore);
|
|
1004
|
+
|
|
1005
|
+
// Sort by relevance score (descending)
|
|
1006
|
+
memory.pool.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
|
1007
|
+
|
|
1008
|
+
// Limit to max items
|
|
1009
|
+
if (memory.pool.length > POOL_CONFIG.maxItems) {
|
|
1010
|
+
memory.pool = memory.pool.slice(0, POOL_CONFIG.maxItems);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
memory.updatedAt = new Date().toISOString();
|
|
1015
|
+
await saveWorkingMemory(directory, memory);
|
|
1016
|
+
|
|
1017
|
+
return memory;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Get top items for system prompt injection (slots first, then pool)
|
|
1022
|
+
*/
|
|
1023
|
+
function getTopItemsForPrompt(
|
|
1024
|
+
memory: WorkingMemory,
|
|
1025
|
+
maxChars: number = WORKING_MEMORY_LIMITS.systemPromptBudget
|
|
1026
|
+
): { slotItems: WorkingMemoryItem[], poolItems: WorkingMemoryItem[] } {
|
|
1027
|
+
const slotItems: WorkingMemoryItem[] = [];
|
|
1028
|
+
const poolItems: WorkingMemoryItem[] = [];
|
|
1029
|
+
let usedChars = 0;
|
|
1030
|
+
|
|
1031
|
+
// Priority 1: Add all slot items (they're guaranteed)
|
|
1032
|
+
for (const slotType of Object.keys(SLOT_CONFIG) as SlotType[]) {
|
|
1033
|
+
const items = memory.slots[slotType];
|
|
1034
|
+
// Sort by timestamp descending (most recent first)
|
|
1035
|
+
const sorted = [...items].sort((a, b) => b.timestamp - a.timestamp);
|
|
1036
|
+
for (const item of sorted) {
|
|
1037
|
+
const itemChars = item.content.length + 20; // +20 for formatting
|
|
1038
|
+
if (usedChars + itemChars > maxChars) {
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
slotItems.push(item);
|
|
1042
|
+
usedChars += itemChars;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Priority 2: Add pool items (sorted by relevance score)
|
|
1047
|
+
for (const item of memory.pool) {
|
|
1048
|
+
const itemChars = item.content.length + 20; // +20 for formatting
|
|
1049
|
+
if (usedChars + itemChars > maxChars) {
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
poolItems.push(item);
|
|
1053
|
+
usedChars += itemChars;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return { slotItems, poolItems };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Compress file paths to save space in system prompt
|
|
1061
|
+
* /Users/sd_wo/opencode/packages/opencode/src/foo.ts → ~/opencode/pkg/opencode/src/foo.ts
|
|
1062
|
+
* /Users/sd_wo/work/opencode-plugins/.opencode/plugins/foo.ts → ~/work/oc-plugins/.opencode/plugins/foo.ts
|
|
1063
|
+
*/
|
|
1064
|
+
function compressPath(content: string): string {
|
|
1065
|
+
const homeDir = process.env.HOME || '/Users/' + (process.env.USER || 'user');
|
|
1066
|
+
|
|
1067
|
+
return content
|
|
1068
|
+
// Replace home directory with ~
|
|
1069
|
+
.replace(new RegExp(`^${homeDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), '~')
|
|
1070
|
+
// Shorten common patterns
|
|
1071
|
+
.replace(/\/packages\//g, '/pkg/')
|
|
1072
|
+
.replace(/\/opencode-plugins\//g, '/oc-plugins/')
|
|
1073
|
+
.replace(/\/node_modules\//g, '/nm/')
|
|
1074
|
+
.replace(/\/typescript\//g, '/ts/')
|
|
1075
|
+
.replace(/\/javascript\//g, '/js/');
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Render working memory items for system prompt
|
|
1080
|
+
*/
|
|
1081
|
+
function renderWorkingMemoryPrompt(memory: WorkingMemory): string {
|
|
1082
|
+
const { slotItems, poolItems } = getTopItemsForPrompt(memory);
|
|
1083
|
+
|
|
1084
|
+
if (slotItems.length === 0 && poolItems.length === 0) {
|
|
1085
|
+
return "";
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Group items by type
|
|
1089
|
+
const itemsByType = new Map<WorkingMemoryItemType, WorkingMemoryItem[]>();
|
|
1090
|
+
for (const item of [...slotItems, ...poolItems]) {
|
|
1091
|
+
if (!itemsByType.has(item.type)) {
|
|
1092
|
+
itemsByType.set(item.type, []);
|
|
1093
|
+
}
|
|
1094
|
+
itemsByType.get(item.type)!.push(item);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const sections: string[] = [];
|
|
1098
|
+
|
|
1099
|
+
// Format each type section (ordered by importance)
|
|
1100
|
+
const typeLabels: Record<WorkingMemoryItemType, string> = {
|
|
1101
|
+
"file-path": "📁 Key Files",
|
|
1102
|
+
"error": "⚠️ Recent Errors",
|
|
1103
|
+
"decision": "💡 Decisions",
|
|
1104
|
+
"other": "📝 Notes",
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
const typeOrder: WorkingMemoryItemType[] = [
|
|
1108
|
+
"error", "decision", // Slots first
|
|
1109
|
+
"file-path", "other" // Pool second
|
|
1110
|
+
];
|
|
1111
|
+
|
|
1112
|
+
for (const type of typeOrder) {
|
|
1113
|
+
const items = itemsByType.get(type);
|
|
1114
|
+
if (!items || items.length === 0) continue;
|
|
1115
|
+
|
|
1116
|
+
const label = typeLabels[type] || "📝 Notes";
|
|
1117
|
+
// Compress file paths to save space
|
|
1118
|
+
const itemList = items.map(item => {
|
|
1119
|
+
const content = (type === "file-path")
|
|
1120
|
+
? compressPath(item.content)
|
|
1121
|
+
: item.content;
|
|
1122
|
+
return ` - ${content}`;
|
|
1123
|
+
}).join("\n");
|
|
1124
|
+
sections.push(`${label}:\n${itemList}`);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const totalItems = slotItems.length + poolItems.length;
|
|
1128
|
+
return `
|
|
1129
|
+
<working_memory>
|
|
1130
|
+
Recent session context (auto-managed, sorted by relevance):
|
|
1131
|
+
|
|
1132
|
+
${sections.join("\n\n")}
|
|
1133
|
+
|
|
1134
|
+
(${totalItems} items shown, updated: ${new Date(memory.updatedAt).toLocaleTimeString()})
|
|
1135
|
+
</working_memory>
|
|
1136
|
+
`.trim();
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function getWorkingMemoryItemCount(memory: WorkingMemory): number {
|
|
1140
|
+
const slotCount = Object.values(memory.slots).reduce(
|
|
1141
|
+
(count, slotItems) => count + slotItems.length,
|
|
1142
|
+
0
|
|
1143
|
+
);
|
|
1144
|
+
return slotCount + memory.pool.length;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ============================================================================
|
|
1148
|
+
// Phase 4: Compaction Tracking and State Preservation
|
|
1149
|
+
// ============================================================================
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Keep only the most relevant working memory items before compaction
|
|
1153
|
+
* Returns number of items preserved
|
|
1154
|
+
*/
|
|
1155
|
+
async function preserveRelevantItems(
|
|
1156
|
+
directory: string,
|
|
1157
|
+
sessionID: string,
|
|
1158
|
+
keepPercentage: number = 0.5 // Keep top 50% by default
|
|
1159
|
+
): Promise<number> {
|
|
1160
|
+
const memory = await loadWorkingMemory(directory, sessionID);
|
|
1161
|
+
if (!memory) {
|
|
1162
|
+
return 0;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Slots are always preserved (they're guaranteed retention)
|
|
1166
|
+
const slotCount = Object.values(memory.slots).flat().length;
|
|
1167
|
+
|
|
1168
|
+
// For pool items, keep top N%
|
|
1169
|
+
const poolSize = memory.pool.length;
|
|
1170
|
+
if (poolSize > 0) {
|
|
1171
|
+
const keepCount = Math.max(1, Math.ceil(poolSize * keepPercentage));
|
|
1172
|
+
|
|
1173
|
+
// Pool is already sorted by relevance score, just slice
|
|
1174
|
+
memory.pool = memory.pool.slice(0, keepCount);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
memory.updatedAt = new Date().toISOString();
|
|
1178
|
+
await saveWorkingMemory(directory, memory);
|
|
1179
|
+
|
|
1180
|
+
return slotCount + memory.pool.length;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Record compaction event in log
|
|
1185
|
+
*/
|
|
1186
|
+
async function recordCompaction(
|
|
1187
|
+
directory: string,
|
|
1188
|
+
sessionID: string,
|
|
1189
|
+
preservedItems: number
|
|
1190
|
+
): Promise<CompactionLog> {
|
|
1191
|
+
let log = await loadCompactionLog(directory, sessionID);
|
|
1192
|
+
if (!log) {
|
|
1193
|
+
log = createInitialCompactionLog(sessionID);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
log.compactionCount += 1;
|
|
1197
|
+
log.lastCompaction = Date.now();
|
|
1198
|
+
log.preservedItems = preservedItems;
|
|
1199
|
+
log.updatedAt = new Date().toISOString();
|
|
1200
|
+
|
|
1201
|
+
await saveCompactionLog(directory, log);
|
|
1202
|
+
return log;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// ============================================================================
|
|
1206
|
+
// Memory Pressure Calculation & Tracking
|
|
1207
|
+
// ============================================================================
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Calculate usable tokens using OpenCode's exact compaction formula
|
|
1211
|
+
* Reference: packages/opencode/src/session/compaction.ts:32-48
|
|
1212
|
+
*/
|
|
1213
|
+
function calculateUsableTokens(model: {
|
|
1214
|
+
limit: { context: number; input?: number; output: number };
|
|
1215
|
+
}): number {
|
|
1216
|
+
const OUTPUT_TOKEN_MAX = 32_000; // From transform.ts:21
|
|
1217
|
+
const COMPACTION_BUFFER = 20_000; // From compaction.ts:33
|
|
1218
|
+
|
|
1219
|
+
const maxOutputTokens = Math.min(
|
|
1220
|
+
model.limit.output || OUTPUT_TOKEN_MAX,
|
|
1221
|
+
OUTPUT_TOKEN_MAX
|
|
1222
|
+
);
|
|
1223
|
+
const reserved = Math.min(COMPACTION_BUFFER, maxOutputTokens);
|
|
1224
|
+
|
|
1225
|
+
// Match compaction.ts:42-47
|
|
1226
|
+
const usable = model.limit.input
|
|
1227
|
+
? model.limit.input - reserved
|
|
1228
|
+
: model.limit.context - maxOutputTokens;
|
|
1229
|
+
|
|
1230
|
+
return usable;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Calculate pressure level based on current tokens and usable limit
|
|
1235
|
+
*
|
|
1236
|
+
* Thresholds:
|
|
1237
|
+
* - 0.75 (75%): moderate - show reminder in prompt
|
|
1238
|
+
* - 0.9 (90%): high - send intervention message
|
|
1239
|
+
*/
|
|
1240
|
+
function calculatePressureLevel(
|
|
1241
|
+
currentTokens: number,
|
|
1242
|
+
usable: number
|
|
1243
|
+
): PressureLevel {
|
|
1244
|
+
const pressure = currentTokens / usable;
|
|
1245
|
+
|
|
1246
|
+
if (pressure >= 0.90) return "high";
|
|
1247
|
+
if (pressure >= 0.75) return "moderate";
|
|
1248
|
+
return "safe";
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Calculate complete pressure information for a model
|
|
1253
|
+
*/
|
|
1254
|
+
function calculateModelPressure(
|
|
1255
|
+
sessionID: string,
|
|
1256
|
+
model: { id: string; providerID: string; limit: { context: number; input?: number; output: number } },
|
|
1257
|
+
totalTokens: number
|
|
1258
|
+
): ModelPressureInfo {
|
|
1259
|
+
const OUTPUT_TOKEN_MAX = 32_000;
|
|
1260
|
+
const COMPACTION_BUFFER = 20_000;
|
|
1261
|
+
|
|
1262
|
+
const maxOutputTokens = Math.min(
|
|
1263
|
+
model.limit.output || OUTPUT_TOKEN_MAX,
|
|
1264
|
+
OUTPUT_TOKEN_MAX
|
|
1265
|
+
);
|
|
1266
|
+
const reserved = Math.min(COMPACTION_BUFFER, maxOutputTokens);
|
|
1267
|
+
const usable = calculateUsableTokens(model);
|
|
1268
|
+
|
|
1269
|
+
const pressure = totalTokens / usable;
|
|
1270
|
+
const level = calculatePressureLevel(totalTokens, usable);
|
|
1271
|
+
|
|
1272
|
+
return {
|
|
1273
|
+
sessionID,
|
|
1274
|
+
modelID: model.id,
|
|
1275
|
+
providerID: model.providerID,
|
|
1276
|
+
limits: {
|
|
1277
|
+
context: model.limit.context,
|
|
1278
|
+
input: model.limit.input,
|
|
1279
|
+
output: model.limit.output,
|
|
1280
|
+
},
|
|
1281
|
+
calculated: {
|
|
1282
|
+
maxOutputTokens,
|
|
1283
|
+
reserved,
|
|
1284
|
+
usable,
|
|
1285
|
+
},
|
|
1286
|
+
current: {
|
|
1287
|
+
totalTokens,
|
|
1288
|
+
pressure,
|
|
1289
|
+
level,
|
|
1290
|
+
},
|
|
1291
|
+
thresholds: {
|
|
1292
|
+
moderate: Math.floor(usable * 0.75),
|
|
1293
|
+
high: Math.floor(usable * 0.90),
|
|
1294
|
+
},
|
|
1295
|
+
updatedAt: new Date().toISOString(),
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Save model pressure info to disk
|
|
1301
|
+
*/
|
|
1302
|
+
async function saveModelPressureInfo(
|
|
1303
|
+
directory: string,
|
|
1304
|
+
info: ModelPressureInfo
|
|
1305
|
+
): Promise<void> {
|
|
1306
|
+
await ensureWorkingMemoryDir(directory);
|
|
1307
|
+
const path = getModelPressurePath(directory, info.sessionID);
|
|
1308
|
+
try {
|
|
1309
|
+
await writeFile(path, JSON.stringify(info, null, 2), "utf-8");
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
console.error("[working-memory] Failed to save pressure info:", error);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Load model pressure info from disk
|
|
1317
|
+
*/
|
|
1318
|
+
async function loadModelPressureInfo(
|
|
1319
|
+
directory: string,
|
|
1320
|
+
sessionID: string
|
|
1321
|
+
): Promise<ModelPressureInfo | null> {
|
|
1322
|
+
const path = getModelPressurePath(directory, sessionID);
|
|
1323
|
+
if (!existsSync(path)) {
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
try {
|
|
1328
|
+
const content = await readFile(path, "utf-8");
|
|
1329
|
+
return JSON.parse(content) as ModelPressureInfo;
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
console.error("[working-memory] Failed to load pressure info:", error);
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Calculate total tokens by querying OpenCode's session database
|
|
1338
|
+
* This is more reliable than relying on hook-provided messages
|
|
1339
|
+
*
|
|
1340
|
+
* Note: Only looks at last 10 messages to avoid stale data from before compaction
|
|
1341
|
+
*/
|
|
1342
|
+
async function calculateTotalTokensFromDB(sessionID: string): Promise<number> {
|
|
1343
|
+
try {
|
|
1344
|
+
const { execSync } = await import("child_process");
|
|
1345
|
+
const dbPath = join(process.env.HOME || "~", ".local/share/opencode/opencode.db");
|
|
1346
|
+
|
|
1347
|
+
// Get tokens.total from most recent assistant message (last 10 to be safe)
|
|
1348
|
+
// Use MAX to handle edge cases, but limit to recent messages to avoid stale pre-compaction data
|
|
1349
|
+
const query = `
|
|
1350
|
+
SELECT json_extract(data, '$.tokens.total') as total
|
|
1351
|
+
FROM message
|
|
1352
|
+
WHERE session_id = '${sessionID}'
|
|
1353
|
+
AND json_extract(data, '$.role') = 'assistant'
|
|
1354
|
+
AND json_extract(data, '$.tokens.total') IS NOT NULL
|
|
1355
|
+
ORDER BY time_created DESC
|
|
1356
|
+
LIMIT 1;
|
|
1357
|
+
`;
|
|
1358
|
+
|
|
1359
|
+
const result = execSync(`sqlite3 "${dbPath}" "${query}"`, { encoding: "utf-8" }).trim();
|
|
1360
|
+
return parseInt(result) || 0;
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
console.error("[working-memory] Failed to query tokens from DB:", error);
|
|
1363
|
+
return 0;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Generate pressure warning text for system prompt injection
|
|
1369
|
+
*
|
|
1370
|
+
* Design principles:
|
|
1371
|
+
* - MODERATE (75%): gentle nudge, no interruption
|
|
1372
|
+
* - HIGH (90%): actionable commands, pause and persist state
|
|
1373
|
+
*/
|
|
1374
|
+
function generatePressureWarning(info: ModelPressureInfo): string {
|
|
1375
|
+
const { current, calculated } = info;
|
|
1376
|
+
const pct = (current.pressure * 100).toFixed(0);
|
|
1377
|
+
|
|
1378
|
+
if (current.level === "high") {
|
|
1379
|
+
return `\n\n⚠️ HIGH MEMORY PRESSURE: ${pct}% (${current.totalTokens.toLocaleString()}/${calculated.usable.toLocaleString()} usable tokens). Compaction approaching. REQUIRED ACTIONS: Pause current task. Use core_memory_update to write your current progress, findings, and exact next steps. Use working_memory_clear_slot to drop resolved errors and completed todos. Then use Task tool for any remaining exploration.`;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (current.level === "moderate") {
|
|
1383
|
+
return `\n\n💡 Memory pressure: ${pct}% - Prefer Task tool for exploration. Update core_memory regularly.`;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
return "";
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Send proactive intervention message when HIGH pressure detected (90%)
|
|
1391
|
+
*
|
|
1392
|
+
* This sends an independent system message to the session immediately, so the agent
|
|
1393
|
+
* receives the task in the queue without interrupting current work. The agent will
|
|
1394
|
+
* process it automatically when available.
|
|
1395
|
+
*
|
|
1396
|
+
* Design: Use promptAsync() which returns 204 immediately, non-blocking.
|
|
1397
|
+
*/
|
|
1398
|
+
async function sendPressureInterventionMessage(
|
|
1399
|
+
client: any,
|
|
1400
|
+
sessionID: string,
|
|
1401
|
+
info: ModelPressureInfo
|
|
1402
|
+
): Promise<void> {
|
|
1403
|
+
const { current, calculated } = info;
|
|
1404
|
+
const pct = (current.pressure * 100).toFixed(0);
|
|
1405
|
+
|
|
1406
|
+
if (current.level !== "high") return;
|
|
1407
|
+
|
|
1408
|
+
const systemPrompt = `⚠️ HIGH MEMORY PRESSURE DETECTED: ${pct}% (${current.totalTokens.toLocaleString()}/${calculated.usable.toLocaleString()} usable tokens)
|
|
1409
|
+
|
|
1410
|
+
Compaction is approaching. You must take action now to preserve your work:
|
|
1411
|
+
|
|
1412
|
+
REQUIRED ACTIONS:
|
|
1413
|
+
1. Pause your current task immediately
|
|
1414
|
+
2. Use core_memory_update to save:
|
|
1415
|
+
- Current progress on your task
|
|
1416
|
+
- Key findings and discoveries
|
|
1417
|
+
- Exact next steps to continue after compaction
|
|
1418
|
+
3. Use working_memory_clear_slot to drop resolved errors and completed todos
|
|
1419
|
+
4. Use Task tool for any remaining exploration work
|
|
1420
|
+
|
|
1421
|
+
After completing these actions, you may resume your current task.`;
|
|
1422
|
+
|
|
1423
|
+
try {
|
|
1424
|
+
// Use promptAsync to send message without waiting for response
|
|
1425
|
+
await client.session.promptAsync({
|
|
1426
|
+
path: { id: sessionID },
|
|
1427
|
+
body: {
|
|
1428
|
+
parts: [{
|
|
1429
|
+
type: "text",
|
|
1430
|
+
// Send actionable content directly (not log-style placeholder)
|
|
1431
|
+
text: systemPrompt,
|
|
1432
|
+
}],
|
|
1433
|
+
// Keep system unset so the intervention is visible as a normal prompt
|
|
1434
|
+
noReply: false, // We want agent to respond with actions
|
|
1435
|
+
},
|
|
1436
|
+
});
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
console.error("[working-memory] Failed to send pressure intervention:", error);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Get pressure-aware pruning config based on current memory pressure
|
|
1444
|
+
* HYPER-AGGRESSIVE MODE: pressure >= 0.90 enforces strict limits
|
|
1445
|
+
*/
|
|
1446
|
+
function getPressureAwarePruningConfig(pressure: number): {
|
|
1447
|
+
maxLines: number;
|
|
1448
|
+
maxChars: number;
|
|
1449
|
+
aggressiveTruncation: boolean;
|
|
1450
|
+
} {
|
|
1451
|
+
// HIGH (>= 90%): Hyper-Aggressive Mode
|
|
1452
|
+
if (pressure >= 0.90) {
|
|
1453
|
+
return {
|
|
1454
|
+
maxLines: 2000, // Hard limit: 2000 lines max
|
|
1455
|
+
maxChars: 100_000, // ~25k tokens max per tool output
|
|
1456
|
+
aggressiveTruncation: true, // Force truncation, no exceptions
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// MODERATE (>= 75%): Aggressive Mode
|
|
1461
|
+
if (pressure >= 0.75) {
|
|
1462
|
+
return {
|
|
1463
|
+
maxLines: 5000,
|
|
1464
|
+
maxChars: 200_000, // ~50k tokens max
|
|
1465
|
+
aggressiveTruncation: true,
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// SAFE (< 75%): Normal Mode
|
|
1470
|
+
return {
|
|
1471
|
+
maxLines: 10_000,
|
|
1472
|
+
maxChars: 400_000, // ~100k tokens max
|
|
1473
|
+
aggressiveTruncation: false,
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// ============================================================================
|
|
1478
|
+
// System Prompt Rendering
|
|
1479
|
+
// ============================================================================
|
|
1480
|
+
|
|
1481
|
+
function renderCoreMemoryPrompt(memory: CoreMemory): string {
|
|
1482
|
+
const { goal, progress, context } = memory.blocks;
|
|
1483
|
+
|
|
1484
|
+
return `
|
|
1485
|
+
<core_memory>
|
|
1486
|
+
The following persistent memory blocks track your current task state:
|
|
1487
|
+
|
|
1488
|
+
<goal chars="${goal.value.length}/${goal.charLimit}">
|
|
1489
|
+
${goal.value || "[Not set - ask user for goals and update this block]"}
|
|
1490
|
+
</goal>
|
|
1491
|
+
|
|
1492
|
+
<progress chars="${progress.value.length}/${progress.charLimit}">
|
|
1493
|
+
${progress.value || "[No progress tracked yet - update as you work]"}
|
|
1494
|
+
</progress>
|
|
1495
|
+
|
|
1496
|
+
<context chars="${context.value.length}/${context.charLimit}">
|
|
1497
|
+
${context.value || "[No project context set - add relevant file paths, conventions, etc.]"}
|
|
1498
|
+
</context>
|
|
1499
|
+
|
|
1500
|
+
IMPORTANT: These blocks persist across conversation resets and compaction.
|
|
1501
|
+
Update them regularly using core_memory_update tool when:
|
|
1502
|
+
- Goals change or new objectives are identified
|
|
1503
|
+
- Significant progress is made or tasks are completed
|
|
1504
|
+
- Important project context is discovered (file structures, patterns, conventions)
|
|
1505
|
+
|
|
1506
|
+
When memory blocks approach their character limits, compress or rephrase content.
|
|
1507
|
+
|
|
1508
|
+
**Usage Discipline** (see Core Memory Usage Guidelines above for details):
|
|
1509
|
+
- goal: ONE specific task, not project-wide goals
|
|
1510
|
+
- progress: Checklist format, NO line numbers/commit hashes/API signatures
|
|
1511
|
+
- context: ONLY files you're currently working on, NO type definitions/function signatures
|
|
1512
|
+
- NEVER store: API docs, library types, function signatures (read source instead)
|
|
1513
|
+
</core_memory>
|
|
1514
|
+
`.trim();
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// ============================================================================
|
|
1518
|
+
// Plugin Implementation
|
|
1519
|
+
// ============================================================================
|
|
1520
|
+
|
|
1521
|
+
export default async function WorkingMemoryPlugin(
|
|
1522
|
+
input: Parameters<Plugin>[0]
|
|
1523
|
+
): Promise<ReturnType<Plugin>> {
|
|
1524
|
+
const { directory, client } = input;
|
|
1525
|
+
|
|
1526
|
+
return {
|
|
1527
|
+
// ========================================================================
|
|
1528
|
+
// Phase 1: Inject Core Memory and Working Memory into System Prompt
|
|
1529
|
+
// Phase 4: Inject Memory Pressure Warnings & Calculate Tokens from DB
|
|
1530
|
+
// Phase 4.5: Proactive Pressure Intervention (NEW)
|
|
1531
|
+
// Phase 5: Core Memory Usage Guidelines (AGENTS.md Enhancement)
|
|
1532
|
+
//
|
|
1533
|
+
// Dual-System Approach:
|
|
1534
|
+
// 1. PASSIVE WARNING (existing): Injected into next turn's system prompt
|
|
1535
|
+
// - Always present as reminder in system context
|
|
1536
|
+
// - 1-turn delay but persistent
|
|
1537
|
+
//
|
|
1538
|
+
// 2. PROACTIVE INTERVENTION (new): Immediate async message sent to queue
|
|
1539
|
+
// - No delay, sent immediately when HIGH (90%) detected
|
|
1540
|
+
// - Agent processes when available (non-blocking)
|
|
1541
|
+
// - Only sent when pressure level increases (avoids spam)
|
|
1542
|
+
//
|
|
1543
|
+
// 3. USAGE GUIDELINES (new): Injected after AGENTS.md, before core_memory
|
|
1544
|
+
// - Teaches agent how to use core_memory blocks correctly
|
|
1545
|
+
// - Prevents storing API docs/type definitions in memory
|
|
1546
|
+
// - Ensures goal/progress/context stay focused on current task
|
|
1547
|
+
// ========================================================================
|
|
1548
|
+
"experimental.chat.system.transform": async (hookInput, output) => {
|
|
1549
|
+
const { sessionID, model } = hookInput;
|
|
1550
|
+
if (!sessionID) return;
|
|
1551
|
+
|
|
1552
|
+
// Phase 5: Inject Core Memory Usage Guidelines
|
|
1553
|
+
// This enhances AGENTS.md (if exists) with plugin-specific instructions
|
|
1554
|
+
// Inserted early so it's read before agent sees <core_memory> block
|
|
1555
|
+
const coreMemoryGuidelines = `
|
|
1556
|
+
# Core Memory Usage Guidelines
|
|
1557
|
+
|
|
1558
|
+
The Working Memory Plugin provides persistent core_memory blocks. **USE THEM CORRECTLY**:
|
|
1559
|
+
|
|
1560
|
+
## goal block (1000 chars)
|
|
1561
|
+
**Purpose**: ONE specific task you're working on RIGHT NOW
|
|
1562
|
+
|
|
1563
|
+
✅ **GOOD Examples**:
|
|
1564
|
+
- "Fix pruning bug where items with relevanceScore <0.01 are incorrectly excluded"
|
|
1565
|
+
- "Add new tool: working_memory_search to query pool items by keyword"
|
|
1566
|
+
- "Investigate why pressure warnings not showing in system prompt"
|
|
1567
|
+
|
|
1568
|
+
❌ **BAD Examples**:
|
|
1569
|
+
- "Complete Phase 1-4 development and testing" (too broad, likely already done)
|
|
1570
|
+
- "Build a working memory system for OpenCode" (project-level goal, not task-level)
|
|
1571
|
+
|
|
1572
|
+
## progress block (2000 chars)
|
|
1573
|
+
**Purpose**: Checklist of done/in-progress/blocked items + key decisions
|
|
1574
|
+
|
|
1575
|
+
✅ **GOOD Examples**:
|
|
1576
|
+
- "✅ Found bug in applyDecay() line 856\\n⏳ Testing fix with gamma=0.85\\n❓ Need to verify edge case: score=0"
|
|
1577
|
+
- "✅ Phase 1-3 complete\\n⏳ Phase 4 intervention testing\\n⚠️ BLOCKED: Need promptAsync docs"
|
|
1578
|
+
|
|
1579
|
+
❌ **BAD Examples**:
|
|
1580
|
+
- "Function sendPressureInterventionMessage() @ working-memory.ts:L1286-1354" (line numbers useless after edits)
|
|
1581
|
+
- "Commit 2f42f1b implemented promptAsync integration" (commit hash irrelevant)
|
|
1582
|
+
- "API: client.session.promptAsync({ path: {id}, body: {...} })" (API signature, not progress)
|
|
1583
|
+
|
|
1584
|
+
## context block (1500 chars)
|
|
1585
|
+
**Purpose**: Files you're CURRENTLY editing + key patterns/conventions
|
|
1586
|
+
|
|
1587
|
+
✅ **GOOD Examples**:
|
|
1588
|
+
- "Editing: .opencode/plugins/working-memory.ts (main plugin, 1706 lines)\\nRelated: WORKING_MEMORY.md, TEST_PHASE4.md"
|
|
1589
|
+
- "Key paths: .opencode/memory-core/ (persistent blocks), memory-working/ (session data)"
|
|
1590
|
+
- "Pattern: All async file ops use mkdir({recursive:true}) before writeFile"
|
|
1591
|
+
|
|
1592
|
+
❌ **BAD Examples**:
|
|
1593
|
+
- "OpenCode SDK types: TextPartInput = { type: 'text', text: string, synthetic?: boolean }" (type definition)
|
|
1594
|
+
- "Function signature: async function loadCoreMemory(directory: string, sessionID: string): Promise<CoreMemory | null>" (function signature)
|
|
1595
|
+
- "Method client.session.promptAsync() returns 204 No Content" (API behavior, read docs instead)
|
|
1596
|
+
|
|
1597
|
+
## ⚠️ NEVER Store in Core Memory
|
|
1598
|
+
- API documentation (read source/docs when needed)
|
|
1599
|
+
- Type definitions from libraries (import them)
|
|
1600
|
+
- Function signatures (read source code)
|
|
1601
|
+
- Implementation details (belong in code comments)
|
|
1602
|
+
- Completed goals (clear them immediately)
|
|
1603
|
+
|
|
1604
|
+
## ✅ Update Core Memory Immediately When
|
|
1605
|
+
- **Starting new task**: Clear old goal, set new specific goal
|
|
1606
|
+
- **Making progress**: Update progress checklist (keep concise)
|
|
1607
|
+
- **Switching files**: Update context with current working files
|
|
1608
|
+
- **Task completed**: Clear goal/progress, set next task
|
|
1609
|
+
- **Approaching char limit**: Compress or remove outdated info
|
|
1610
|
+
|
|
1611
|
+
**Remember**: Core Memory is your **working scratchpad**, not a reference manual.
|
|
1612
|
+
`.trim();
|
|
1613
|
+
|
|
1614
|
+
output.system.push(coreMemoryGuidelines);
|
|
1615
|
+
|
|
1616
|
+
// Phase 4: Check for memory pressure and inject warning
|
|
1617
|
+
// Skip warning if model just changed (avoids false alarms with different limits)
|
|
1618
|
+
const prevPressure = await loadModelPressureInfo(directory, sessionID);
|
|
1619
|
+
const modelChanged = model && prevPressure && prevPressure.modelID !== model.id;
|
|
1620
|
+
|
|
1621
|
+
if (!modelChanged && prevPressure && prevPressure.current.level !== "safe") {
|
|
1622
|
+
const warning = generatePressureWarning(prevPressure);
|
|
1623
|
+
if (warning) {
|
|
1624
|
+
output.system.push(warning);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Phase 4: Calculate current token usage from DB and update pressure
|
|
1629
|
+
if (model) {
|
|
1630
|
+
const totalTokens = await calculateTotalTokensFromDB(sessionID);
|
|
1631
|
+
|
|
1632
|
+
// Calculate pressure with current model
|
|
1633
|
+
const updatedPressure = calculateModelPressure(
|
|
1634
|
+
sessionID,
|
|
1635
|
+
{
|
|
1636
|
+
id: model.id,
|
|
1637
|
+
providerID: model.providerID,
|
|
1638
|
+
limit: model.limit,
|
|
1639
|
+
},
|
|
1640
|
+
totalTokens
|
|
1641
|
+
);
|
|
1642
|
+
|
|
1643
|
+
// Save for next turn's warning injection
|
|
1644
|
+
await saveModelPressureInfo(directory, updatedPressure);
|
|
1645
|
+
|
|
1646
|
+
// Phase 4.5: Proactive Intervention - Send immediate message if HIGH (90%)
|
|
1647
|
+
// This is better than waiting for next turn's passive warning
|
|
1648
|
+
// The message goes into the queue and agent processes it when available
|
|
1649
|
+
if (updatedPressure.current.level === "high") {
|
|
1650
|
+
// Only send if pressure increased from previous level (avoid spam)
|
|
1651
|
+
const shouldSend = !prevPressure ||
|
|
1652
|
+
prevPressure.current.level === "safe" ||
|
|
1653
|
+
prevPressure.current.level === "moderate";
|
|
1654
|
+
|
|
1655
|
+
if (shouldSend) {
|
|
1656
|
+
await sendPressureInterventionMessage(client, sessionID, updatedPressure);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Phase 1: Core memory
|
|
1662
|
+
const coreMemory = await loadCoreMemory(directory, sessionID);
|
|
1663
|
+
if (coreMemory) {
|
|
1664
|
+
const hasContent =
|
|
1665
|
+
coreMemory.blocks.goal.value ||
|
|
1666
|
+
coreMemory.blocks.progress.value ||
|
|
1667
|
+
coreMemory.blocks.context.value;
|
|
1668
|
+
|
|
1669
|
+
if (hasContent) {
|
|
1670
|
+
const corePrompt = renderCoreMemoryPrompt(coreMemory);
|
|
1671
|
+
output.system.push(corePrompt);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// Phase 1: Working memory
|
|
1676
|
+
const workingMemory = await loadWorkingMemory(directory, sessionID);
|
|
1677
|
+
if (workingMemory && getWorkingMemoryItemCount(workingMemory) > 0) {
|
|
1678
|
+
const workingPrompt = renderWorkingMemoryPrompt(workingMemory);
|
|
1679
|
+
if (workingPrompt) {
|
|
1680
|
+
output.system.push(workingPrompt);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
},
|
|
1684
|
+
|
|
1685
|
+
// ========================================================================
|
|
1686
|
+
// Phase 2 & 3: Cache Tool Outputs and Auto-Extract to Working Memory
|
|
1687
|
+
// Storage Governance Layer 2: Tool Output Cache Sweep Trigger
|
|
1688
|
+
// ========================================================================
|
|
1689
|
+
"tool.execute.after": async (hookInput, hookOutput) => {
|
|
1690
|
+
const { sessionID, callID, tool: toolName, args } = hookInput;
|
|
1691
|
+
const { output: toolOutput } = hookOutput;
|
|
1692
|
+
|
|
1693
|
+
// Phase 2: Cache the full output for later smart pruning
|
|
1694
|
+
await cacheToolOutput(directory, {
|
|
1695
|
+
callID,
|
|
1696
|
+
sessionID,
|
|
1697
|
+
tool: toolName,
|
|
1698
|
+
fullOutput: toolOutput,
|
|
1699
|
+
timestamp: Date.now(),
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
// Phase 3: Auto-extract to working memory
|
|
1703
|
+
const extractedItems = extractFromToolOutput(toolName, toolOutput);
|
|
1704
|
+
for (const item of extractedItems) {
|
|
1705
|
+
await addToWorkingMemory(directory, sessionID, item);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Storage Governance Layer 2: Sweep tool-output cache every N calls
|
|
1709
|
+
const memory = await loadWorkingMemory(directory, sessionID);
|
|
1710
|
+
if (memory && memory.eventCounter % STORAGE_GOVERNANCE.sweepInterval === 0) {
|
|
1711
|
+
await sweepToolOutputCache(directory, sessionID);
|
|
1712
|
+
}
|
|
1713
|
+
},
|
|
1714
|
+
|
|
1715
|
+
// ========================================================================
|
|
1716
|
+
// Phase 2: Apply Smart Pruning to Messages (Pressure-Aware)
|
|
1717
|
+
// ========================================================================
|
|
1718
|
+
"experimental.chat.messages.transform": async (hookInput, output) => {
|
|
1719
|
+
const sessionID = output.messages[0]?.info?.sessionID || "";
|
|
1720
|
+
|
|
1721
|
+
// Load current pressure info to get pressure-aware pruning config
|
|
1722
|
+
const currentPressure = await loadModelPressureInfo(directory, sessionID);
|
|
1723
|
+
const pressureLevel = currentPressure?.current?.pressure || 0;
|
|
1724
|
+
const pruningConfig = getPressureAwarePruningConfig(pressureLevel);
|
|
1725
|
+
|
|
1726
|
+
// Apply smart pruning with pressure-aware limits
|
|
1727
|
+
for (const msg of output.messages) {
|
|
1728
|
+
for (const part of msg.parts) {
|
|
1729
|
+
// Check if this is a tool result that was pruned by OpenCode
|
|
1730
|
+
if (
|
|
1731
|
+
part.type === "tool" &&
|
|
1732
|
+
part.state?.status === "completed" &&
|
|
1733
|
+
part.state?.time?.compacted
|
|
1734
|
+
) {
|
|
1735
|
+
// Retrieve cached full output
|
|
1736
|
+
const cached = await getCachedToolOutput(
|
|
1737
|
+
directory,
|
|
1738
|
+
msg.info.sessionID || "",
|
|
1739
|
+
part.callID || ""
|
|
1740
|
+
);
|
|
1741
|
+
|
|
1742
|
+
if (cached) {
|
|
1743
|
+
const rule = getPruningRule(part.tool || "");
|
|
1744
|
+
const smartPruned = applySmartPruning(cached.fullOutput, rule, pruningConfig);
|
|
1745
|
+
|
|
1746
|
+
// Replace the generic "[Old tool result content cleared]" with smart summary
|
|
1747
|
+
part.state.output = smartPruned;
|
|
1748
|
+
|
|
1749
|
+
// Remove compacted marker to prevent double-pruning
|
|
1750
|
+
delete part.state.time.compacted;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
},
|
|
1756
|
+
|
|
1757
|
+
// ========================================================================
|
|
1758
|
+
// Storage Governance Layer 1: Session Deletion Event Handler
|
|
1759
|
+
// ========================================================================
|
|
1760
|
+
event: async ({ event }) => {
|
|
1761
|
+
// Listen for session.deleted events and cleanup all artifacts
|
|
1762
|
+
if (event.type === "session.deleted") {
|
|
1763
|
+
const sessionID = event.properties?.info?.id;
|
|
1764
|
+
if (sessionID) {
|
|
1765
|
+
await cleanupSessionArtifacts(directory, sessionID);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
},
|
|
1769
|
+
|
|
1770
|
+
// ========================================================================
|
|
1771
|
+
// Phase 4: Preserve State Before Compaction
|
|
1772
|
+
// ========================================================================
|
|
1773
|
+
"experimental.session.compacting": async (hookInput, output) => {
|
|
1774
|
+
const { sessionID } = hookInput;
|
|
1775
|
+
|
|
1776
|
+
// Preserve only the most relevant working memory items
|
|
1777
|
+
const preservedItems = await preserveRelevantItems(directory, sessionID, 0.5);
|
|
1778
|
+
|
|
1779
|
+
// Record this compaction event
|
|
1780
|
+
const log = await recordCompaction(directory, sessionID, preservedItems);
|
|
1781
|
+
|
|
1782
|
+
// Add context to compaction prompt to help preserve key info
|
|
1783
|
+
const coreMemory = await loadCoreMemory(directory, sessionID);
|
|
1784
|
+
if (coreMemory) {
|
|
1785
|
+
const { goal, progress, context } = coreMemory.blocks;
|
|
1786
|
+
|
|
1787
|
+
let contextParts: string[] = [];
|
|
1788
|
+
|
|
1789
|
+
if (goal.value) {
|
|
1790
|
+
contextParts.push(`Current goal: ${goal.value}`);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
if (progress.value) {
|
|
1794
|
+
// Extract just the "next steps" portion if it exists
|
|
1795
|
+
const progressLines = progress.value.split('\n');
|
|
1796
|
+
const nextStepsIdx = progressLines.findIndex(line =>
|
|
1797
|
+
line.includes('⏭️') || line.toLowerCase().includes('next')
|
|
1798
|
+
);
|
|
1799
|
+
if (nextStepsIdx >= 0) {
|
|
1800
|
+
contextParts.push(`Next steps: ${progressLines[nextStepsIdx]}`);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
if (contextParts.length > 0) {
|
|
1805
|
+
output.context.push(
|
|
1806
|
+
`IMPORTANT: Preserve these key details:\n${contextParts.join('\n')}`
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// SSOT Bridge: Inject OpenCode native Todos from DB into compaction context
|
|
1812
|
+
try {
|
|
1813
|
+
const { execSync } = await import("child_process");
|
|
1814
|
+
const dbPath = join(process.env.HOME || "~", ".local/share/opencode/opencode.db");
|
|
1815
|
+
|
|
1816
|
+
const query = `
|
|
1817
|
+
SELECT content, status, priority
|
|
1818
|
+
FROM todo
|
|
1819
|
+
WHERE session_id = '${sessionID}'
|
|
1820
|
+
AND status != 'completed'
|
|
1821
|
+
ORDER BY position ASC;
|
|
1822
|
+
`;
|
|
1823
|
+
|
|
1824
|
+
const result = execSync(`sqlite3 "${dbPath}" "${query.replace(/\n/g, ' ')}"`, {
|
|
1825
|
+
encoding: "utf-8"
|
|
1826
|
+
}).trim();
|
|
1827
|
+
|
|
1828
|
+
if (result) {
|
|
1829
|
+
const todos = result.split('\n').map(line => {
|
|
1830
|
+
const [content, status, priority] = line.split('|');
|
|
1831
|
+
return `- [${status}] ${content} (${priority})`;
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
if (todos.length > 0) {
|
|
1835
|
+
output.context.push(
|
|
1836
|
+
`PENDING TODOS:\n${todos.join('\n')}\nIMPORTANT: Continue working on these tasks after compaction.`
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
console.error("[working-memory] Failed to inject todos from DB:", error);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Inform about preserved working memory
|
|
1845
|
+
if (preservedItems > 0) {
|
|
1846
|
+
output.context.push(
|
|
1847
|
+
`Working memory: Preserved ${preservedItems} most relevant items (compaction #${log.compactionCount})`
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
},
|
|
1851
|
+
|
|
1852
|
+
// ========================================================================
|
|
1853
|
+
// Tools
|
|
1854
|
+
// ========================================================================
|
|
1855
|
+
tool: {
|
|
1856
|
+
core_memory_update: tool({
|
|
1857
|
+
description: `Update persistent core memory blocks that survive compaction.
|
|
1858
|
+
|
|
1859
|
+
Available blocks:
|
|
1860
|
+
- goal: What the user is trying to accomplish (max 1000 chars)
|
|
1861
|
+
- progress: What's done, in-progress, and next steps (max 2000 chars)
|
|
1862
|
+
- context: Key project context like file paths, conventions, patterns (max 1500 chars)
|
|
1863
|
+
|
|
1864
|
+
Operations:
|
|
1865
|
+
- replace: Completely replace the block content
|
|
1866
|
+
- append: Add content to the end of the block (automatically adds newline)
|
|
1867
|
+
|
|
1868
|
+
These blocks are ALWAYS visible to you in every message, even after compaction.
|
|
1869
|
+
Update them regularly to maintain continuity across long conversations.`,
|
|
1870
|
+
args: {
|
|
1871
|
+
block: tool.schema.enum(["goal", "progress", "context"]).describe(
|
|
1872
|
+
"Which memory block to update (goal/progress/context)"
|
|
1873
|
+
),
|
|
1874
|
+
operation: tool.schema.enum(["replace", "append"]).describe(
|
|
1875
|
+
"Whether to replace the entire block or append to it"
|
|
1876
|
+
),
|
|
1877
|
+
content: tool.schema
|
|
1878
|
+
.string()
|
|
1879
|
+
.max(5000)
|
|
1880
|
+
.describe(
|
|
1881
|
+
"Content to write. Will be auto-truncated if exceeds block limit."
|
|
1882
|
+
),
|
|
1883
|
+
},
|
|
1884
|
+
execute: async (args, ctx) => {
|
|
1885
|
+
const { block, operation, content } = args;
|
|
1886
|
+
const { sessionID, directory } = ctx;
|
|
1887
|
+
|
|
1888
|
+
const result = await updateCoreMemoryBlock(
|
|
1889
|
+
directory,
|
|
1890
|
+
sessionID,
|
|
1891
|
+
block,
|
|
1892
|
+
operation,
|
|
1893
|
+
content
|
|
1894
|
+
);
|
|
1895
|
+
|
|
1896
|
+
return result.message;
|
|
1897
|
+
},
|
|
1898
|
+
}),
|
|
1899
|
+
|
|
1900
|
+
core_memory_read: tool({
|
|
1901
|
+
description: `Read the current state of all core memory blocks.
|
|
1902
|
+
|
|
1903
|
+
Returns the current values of goal, progress, and context blocks with their usage stats.
|
|
1904
|
+
Useful for checking what's currently stored before updating.`,
|
|
1905
|
+
args: {},
|
|
1906
|
+
execute: async (args, ctx) => {
|
|
1907
|
+
const { sessionID, directory } = ctx;
|
|
1908
|
+
|
|
1909
|
+
let memory = await loadCoreMemory(directory, sessionID);
|
|
1910
|
+
|
|
1911
|
+
if (!memory) {
|
|
1912
|
+
return "📭 No core memory exists for this session yet.\n\nUse core_memory_update to create memory blocks.";
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const { goal, progress, context } = memory.blocks;
|
|
1916
|
+
|
|
1917
|
+
const formatBlock = (
|
|
1918
|
+
name: string,
|
|
1919
|
+
block: CoreBlock
|
|
1920
|
+
): string => `
|
|
1921
|
+
## ${name.toUpperCase()}
|
|
1922
|
+
Chars: ${block.value.length}/${block.charLimit}
|
|
1923
|
+
Last modified: ${block.lastModified}
|
|
1924
|
+
|
|
1925
|
+
${block.value || "[Empty]"}
|
|
1926
|
+
`.trim();
|
|
1927
|
+
|
|
1928
|
+
return `
|
|
1929
|
+
# Core Memory State
|
|
1930
|
+
|
|
1931
|
+
${formatBlock("goal", goal)}
|
|
1932
|
+
|
|
1933
|
+
---
|
|
1934
|
+
|
|
1935
|
+
${formatBlock("progress", progress)}
|
|
1936
|
+
|
|
1937
|
+
---
|
|
1938
|
+
|
|
1939
|
+
${formatBlock("context", context)}
|
|
1940
|
+
|
|
1941
|
+
---
|
|
1942
|
+
|
|
1943
|
+
Last updated: ${memory.updatedAt}
|
|
1944
|
+
`.trim();
|
|
1945
|
+
},
|
|
1946
|
+
}),
|
|
1947
|
+
|
|
1948
|
+
working_memory_add: tool({
|
|
1949
|
+
description: `Manually add an important item to working memory.
|
|
1950
|
+
|
|
1951
|
+
Working memory auto-extracts key information from tool outputs, but you can
|
|
1952
|
+
also manually add important decisions or notes.
|
|
1953
|
+
|
|
1954
|
+
Item types: file-path, error, decision, other`,
|
|
1955
|
+
args: {
|
|
1956
|
+
content: tool.schema
|
|
1957
|
+
.string()
|
|
1958
|
+
.max(200)
|
|
1959
|
+
.describe("The content to remember (max 200 chars)"),
|
|
1960
|
+
type: tool.schema
|
|
1961
|
+
.enum([
|
|
1962
|
+
"file-path",
|
|
1963
|
+
"error",
|
|
1964
|
+
"decision",
|
|
1965
|
+
"other",
|
|
1966
|
+
])
|
|
1967
|
+
.describe("Type of information")
|
|
1968
|
+
.optional(),
|
|
1969
|
+
},
|
|
1970
|
+
execute: async (args, ctx) => {
|
|
1971
|
+
const { sessionID, directory } = ctx;
|
|
1972
|
+
const { content, type = "other" } = args;
|
|
1973
|
+
|
|
1974
|
+
await addToWorkingMemory(directory, sessionID, {
|
|
1975
|
+
type: type as WorkingMemoryItemType,
|
|
1976
|
+
content,
|
|
1977
|
+
source: "manual",
|
|
1978
|
+
timestamp: Date.now(),
|
|
1979
|
+
mentions: 1,
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
return `✅ Added to working memory: ${content}`;
|
|
1983
|
+
},
|
|
1984
|
+
}),
|
|
1985
|
+
|
|
1986
|
+
working_memory_clear: tool({
|
|
1987
|
+
description: `Clear all working memory items for this session.
|
|
1988
|
+
|
|
1989
|
+
Use this to reset session context when starting a completely new task.
|
|
1990
|
+
Core memory (goal/progress/context) is NOT affected.`,
|
|
1991
|
+
args: {},
|
|
1992
|
+
execute: async (args, ctx) => {
|
|
1993
|
+
const { sessionID, directory } = ctx;
|
|
1994
|
+
|
|
1995
|
+
const emptyMemory = createEmptyWorkingMemory(sessionID);
|
|
1996
|
+
await saveWorkingMemory(directory, emptyMemory);
|
|
1997
|
+
|
|
1998
|
+
return "🗑️ Working memory cleared. Core memory remains intact.";
|
|
1999
|
+
},
|
|
2000
|
+
}),
|
|
2001
|
+
|
|
2002
|
+
working_memory_clear_slot: tool({
|
|
2003
|
+
description: `Clear a specific slot in working memory (e.g., after fixing all errors).
|
|
2004
|
+
|
|
2005
|
+
Useful when you've resolved all items of a certain type:
|
|
2006
|
+
- Clear "error" slot after fixing all bugs
|
|
2007
|
+
- Clear "decision" slot after obsolete decisions
|
|
2008
|
+
|
|
2009
|
+
Core memory and pool items are NOT affected.`,
|
|
2010
|
+
args: {
|
|
2011
|
+
slot: tool.schema
|
|
2012
|
+
.enum(["error", "decision"])
|
|
2013
|
+
.describe("Which slot to clear"),
|
|
2014
|
+
},
|
|
2015
|
+
execute: async (args, ctx) => {
|
|
2016
|
+
const { sessionID, directory } = ctx;
|
|
2017
|
+
const { slot } = args;
|
|
2018
|
+
|
|
2019
|
+
let memory = await loadWorkingMemory(directory, sessionID);
|
|
2020
|
+
if (!memory) {
|
|
2021
|
+
return "⚠️ No working memory found for this session.";
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
const slotType = slot as SlotType;
|
|
2025
|
+
const itemCount = memory.slots[slotType].length;
|
|
2026
|
+
memory.slots[slotType] = [];
|
|
2027
|
+
memory.updatedAt = new Date().toISOString();
|
|
2028
|
+
await saveWorkingMemory(directory, memory);
|
|
2029
|
+
|
|
2030
|
+
return `✅ Cleared ${itemCount} items from "${slot}" slot.`;
|
|
2031
|
+
},
|
|
2032
|
+
}),
|
|
2033
|
+
|
|
2034
|
+
working_memory_remove: tool({
|
|
2035
|
+
description: `Remove a specific item from working memory by content match.
|
|
2036
|
+
|
|
2037
|
+
Use this to remove individual items that are no longer relevant.
|
|
2038
|
+
Provide a unique substring of the content to identify the item.`,
|
|
2039
|
+
args: {
|
|
2040
|
+
content: tool.schema
|
|
2041
|
+
.string()
|
|
2042
|
+
.describe("Content or unique substring to match and remove"),
|
|
2043
|
+
},
|
|
2044
|
+
execute: async (args, ctx) => {
|
|
2045
|
+
const { sessionID, directory } = ctx;
|
|
2046
|
+
const { content } = args;
|
|
2047
|
+
|
|
2048
|
+
let memory = await loadWorkingMemory(directory, sessionID);
|
|
2049
|
+
if (!memory) {
|
|
2050
|
+
return "⚠️ No working memory found for this session.";
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Try to find and remove from slots
|
|
2054
|
+
let removed = false;
|
|
2055
|
+
for (const slotType of Object.keys(SLOT_CONFIG) as SlotType[]) {
|
|
2056
|
+
const slot = memory.slots[slotType];
|
|
2057
|
+
const index = slot.findIndex(item => item.content.includes(content));
|
|
2058
|
+
if (index !== -1) {
|
|
2059
|
+
const removedItem = slot.splice(index, 1)[0];
|
|
2060
|
+
memory.updatedAt = new Date().toISOString();
|
|
2061
|
+
await saveWorkingMemory(directory, memory);
|
|
2062
|
+
return `✅ Removed from "${slotType}" slot: ${removedItem.content}`;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// Try to find and remove from pool
|
|
2067
|
+
const poolIndex = memory.pool.findIndex(item => item.content.includes(content));
|
|
2068
|
+
if (poolIndex !== -1) {
|
|
2069
|
+
const removedItem = memory.pool.splice(poolIndex, 1)[0];
|
|
2070
|
+
memory.updatedAt = new Date().toISOString();
|
|
2071
|
+
await saveWorkingMemory(directory, memory);
|
|
2072
|
+
return `✅ Removed from pool: ${removedItem.content}`;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
return `⚠️ No item found matching: "${content}"`;
|
|
2076
|
+
},
|
|
2077
|
+
}),
|
|
2078
|
+
},
|
|
2079
|
+
};
|
|
2080
|
+
}
|