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/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
+ }