agent-working-memory 0.5.4 → 0.5.6

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.
Files changed (71) hide show
  1. package/README.md +87 -46
  2. package/dist/api/routes.d.ts.map +1 -1
  3. package/dist/api/routes.js +21 -5
  4. package/dist/api/routes.js.map +1 -1
  5. package/dist/cli.js +67 -67
  6. package/dist/coordination/index.d.ts +11 -0
  7. package/dist/coordination/index.d.ts.map +1 -0
  8. package/dist/coordination/index.js +39 -0
  9. package/dist/coordination/index.js.map +1 -0
  10. package/dist/coordination/mcp-tools.d.ts +8 -0
  11. package/dist/coordination/mcp-tools.d.ts.map +1 -0
  12. package/dist/coordination/mcp-tools.js +216 -0
  13. package/dist/coordination/mcp-tools.js.map +1 -0
  14. package/dist/coordination/routes.d.ts +9 -0
  15. package/dist/coordination/routes.d.ts.map +1 -0
  16. package/dist/coordination/routes.js +434 -0
  17. package/dist/coordination/routes.js.map +1 -0
  18. package/dist/coordination/schema.d.ts +12 -0
  19. package/dist/coordination/schema.d.ts.map +1 -0
  20. package/dist/coordination/schema.js +91 -0
  21. package/dist/coordination/schema.js.map +1 -0
  22. package/dist/coordination/schemas.d.ts +208 -0
  23. package/dist/coordination/schemas.d.ts.map +1 -0
  24. package/dist/coordination/schemas.js +109 -0
  25. package/dist/coordination/schemas.js.map +1 -0
  26. package/dist/coordination/stale.d.ts +25 -0
  27. package/dist/coordination/stale.d.ts.map +1 -0
  28. package/dist/coordination/stale.js +53 -0
  29. package/dist/coordination/stale.js.map +1 -0
  30. package/dist/index.js +21 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/mcp.js +90 -79
  33. package/dist/mcp.js.map +1 -1
  34. package/dist/storage/sqlite.d.ts +3 -0
  35. package/dist/storage/sqlite.d.ts.map +1 -1
  36. package/dist/storage/sqlite.js +285 -281
  37. package/dist/storage/sqlite.js.map +1 -1
  38. package/package.json +55 -55
  39. package/src/api/index.ts +3 -3
  40. package/src/api/routes.ts +551 -536
  41. package/src/cli.ts +397 -397
  42. package/src/coordination/index.ts +47 -0
  43. package/src/coordination/mcp-tools.ts +313 -0
  44. package/src/coordination/routes.ts +656 -0
  45. package/src/coordination/schema.ts +94 -0
  46. package/src/coordination/schemas.ts +136 -0
  47. package/src/coordination/stale.ts +89 -0
  48. package/src/core/decay.ts +63 -63
  49. package/src/core/embeddings.ts +88 -88
  50. package/src/core/hebbian.ts +93 -93
  51. package/src/core/index.ts +5 -5
  52. package/src/core/logger.ts +36 -36
  53. package/src/core/query-expander.ts +66 -66
  54. package/src/core/reranker.ts +101 -101
  55. package/src/engine/activation.ts +656 -656
  56. package/src/engine/connections.ts +103 -103
  57. package/src/engine/consolidation-scheduler.ts +125 -125
  58. package/src/engine/eval.ts +102 -102
  59. package/src/engine/eviction.ts +101 -101
  60. package/src/engine/index.ts +8 -8
  61. package/src/engine/retraction.ts +100 -100
  62. package/src/engine/staging.ts +74 -74
  63. package/src/index.ts +137 -121
  64. package/src/mcp.ts +1024 -1013
  65. package/src/storage/index.ts +3 -3
  66. package/src/storage/sqlite.ts +968 -963
  67. package/src/types/agent.ts +67 -67
  68. package/src/types/checkpoint.ts +46 -46
  69. package/src/types/engram.ts +217 -217
  70. package/src/types/eval.ts +100 -100
  71. package/src/types/index.ts +6 -6
@@ -1,963 +1,968 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * SQLite storage layer — persistence for engrams, associations, and eval events.
5
- *
6
- * Uses better-sqlite3 for synchronous, fast, embedded storage.
7
- * FTS5 provides BM25 full-text search for the activation pipeline.
8
- */
9
-
10
- import Database from 'better-sqlite3';
11
- import { randomUUID } from 'node:crypto';
12
- import type {
13
- Engram, EngramCreate, EngramStage, Association, AssociationType,
14
- SearchQuery, SalienceFeatures, ActivationEvent, StagingEvent,
15
- RetrievalFeedbackEvent, Episode, TaskStatus, TaskPriority, MemoryClass,
16
- ConsciousState, AutoCheckpoint, CheckpointRow,
17
- } from '../types/index.js';
18
-
19
- /** Safely convert a Node Buffer to Float32Array, respecting byteOffset/byteLength. */
20
- function bufferToFloat32Array(buf: Buffer | ArrayBuffer): Float32Array {
21
- if (buf instanceof ArrayBuffer) return new Float32Array(buf);
22
- // Node Buffer may share an underlying ArrayBuffer — slice to the exact region
23
- const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
24
- return new Float32Array(ab);
25
- }
26
-
27
- const DEFAULT_SALIENCE_FEATURES: SalienceFeatures = {
28
- surprise: 0, decisionMade: false, causalDepth: 0, resolutionEffort: 0, eventType: 'observation',
29
- };
30
-
31
- export class EngramStore {
32
- private db: Database.Database;
33
-
34
- constructor(dbPath: string = 'memory.db') {
35
- this.db = new Database(dbPath);
36
- this.db.pragma('journal_mode = WAL');
37
- this.db.pragma('foreign_keys = ON');
38
- this.init();
39
- }
40
-
41
- private init(): void {
42
- this.db.exec(`
43
- CREATE TABLE IF NOT EXISTS engrams (
44
- id TEXT PRIMARY KEY,
45
- agent_id TEXT NOT NULL,
46
- concept TEXT NOT NULL,
47
- content TEXT NOT NULL,
48
- embedding BLOB,
49
- confidence REAL NOT NULL DEFAULT 0.5,
50
- salience REAL NOT NULL DEFAULT 0.5,
51
- access_count INTEGER NOT NULL DEFAULT 0,
52
- last_accessed TEXT NOT NULL,
53
- created_at TEXT NOT NULL,
54
- salience_features TEXT NOT NULL DEFAULT '{}',
55
- reason_codes TEXT NOT NULL DEFAULT '[]',
56
- stage TEXT NOT NULL DEFAULT 'active',
57
- ttl INTEGER,
58
- retracted INTEGER NOT NULL DEFAULT 0,
59
- retracted_by TEXT,
60
- retracted_at TEXT,
61
- tags TEXT NOT NULL DEFAULT '[]'
62
- );
63
-
64
- CREATE INDEX IF NOT EXISTS idx_engrams_agent ON engrams(agent_id);
65
- CREATE INDEX IF NOT EXISTS idx_engrams_stage ON engrams(agent_id, stage);
66
- CREATE INDEX IF NOT EXISTS idx_engrams_concept ON engrams(concept);
67
- CREATE INDEX IF NOT EXISTS idx_engrams_retracted ON engrams(agent_id, retracted);
68
-
69
- CREATE TABLE IF NOT EXISTS associations (
70
- id TEXT PRIMARY KEY,
71
- from_engram_id TEXT NOT NULL REFERENCES engrams(id) ON DELETE CASCADE,
72
- to_engram_id TEXT NOT NULL REFERENCES engrams(id) ON DELETE CASCADE,
73
- weight REAL NOT NULL DEFAULT 0.1,
74
- confidence REAL NOT NULL DEFAULT 0.5,
75
- type TEXT NOT NULL DEFAULT 'hebbian',
76
- activation_count INTEGER NOT NULL DEFAULT 0,
77
- created_at TEXT NOT NULL,
78
- last_activated TEXT NOT NULL
79
- );
80
-
81
- CREATE INDEX IF NOT EXISTS idx_assoc_from ON associations(from_engram_id);
82
- CREATE INDEX IF NOT EXISTS idx_assoc_to ON associations(to_engram_id);
83
- CREATE UNIQUE INDEX IF NOT EXISTS idx_assoc_pair ON associations(from_engram_id, to_engram_id);
84
-
85
- CREATE TABLE IF NOT EXISTS agents (
86
- id TEXT PRIMARY KEY,
87
- name TEXT NOT NULL,
88
- created_at TEXT NOT NULL,
89
- config TEXT NOT NULL DEFAULT '{}'
90
- );
91
-
92
- -- FTS5 for full-text search (BM25 ranking built in)
93
- CREATE VIRTUAL TABLE IF NOT EXISTS engrams_fts USING fts5(
94
- concept, content, tags,
95
- content=engrams,
96
- content_rowid=rowid
97
- );
98
-
99
- -- Triggers to keep FTS in sync
100
- CREATE TRIGGER IF NOT EXISTS engrams_ai AFTER INSERT ON engrams BEGIN
101
- INSERT INTO engrams_fts(rowid, concept, content, tags) VALUES (new.rowid, new.concept, new.content, new.tags);
102
- END;
103
- CREATE TRIGGER IF NOT EXISTS engrams_ad AFTER DELETE ON engrams BEGIN
104
- INSERT INTO engrams_fts(engrams_fts, rowid, concept, content, tags) VALUES('delete', old.rowid, old.concept, old.content, old.tags);
105
- END;
106
- CREATE TRIGGER IF NOT EXISTS engrams_au AFTER UPDATE ON engrams BEGIN
107
- INSERT INTO engrams_fts(engrams_fts, rowid, concept, content, tags) VALUES('delete', old.rowid, old.concept, old.content, old.tags);
108
- INSERT INTO engrams_fts(rowid, concept, content, tags) VALUES (new.rowid, new.concept, new.content, new.tags);
109
- END;
110
-
111
- -- Eval event logs
112
- CREATE TABLE IF NOT EXISTS activation_events (
113
- id TEXT PRIMARY KEY,
114
- agent_id TEXT NOT NULL,
115
- timestamp TEXT NOT NULL,
116
- context TEXT NOT NULL,
117
- results_returned INTEGER NOT NULL,
118
- top_score REAL,
119
- latency_ms REAL NOT NULL,
120
- engram_ids TEXT NOT NULL DEFAULT '[]'
121
- );
122
-
123
- CREATE TABLE IF NOT EXISTS staging_events (
124
- engram_id TEXT NOT NULL,
125
- agent_id TEXT NOT NULL,
126
- action TEXT NOT NULL,
127
- resonance_score REAL,
128
- timestamp TEXT NOT NULL,
129
- age_ms INTEGER NOT NULL
130
- );
131
-
132
- CREATE TABLE IF NOT EXISTS retrieval_feedback (
133
- id TEXT PRIMARY KEY,
134
- activation_event_id TEXT,
135
- engram_id TEXT NOT NULL,
136
- useful INTEGER NOT NULL,
137
- context TEXT,
138
- timestamp TEXT NOT NULL
139
- );
140
-
141
- CREATE TABLE IF NOT EXISTS episodes (
142
- id TEXT PRIMARY KEY,
143
- agent_id TEXT NOT NULL,
144
- label TEXT NOT NULL,
145
- embedding BLOB,
146
- engram_count INTEGER NOT NULL DEFAULT 0,
147
- start_time TEXT NOT NULL,
148
- end_time TEXT NOT NULL,
149
- created_at TEXT NOT NULL
150
- );
151
-
152
- CREATE INDEX IF NOT EXISTS idx_episodes_agent ON episodes(agent_id);
153
- CREATE INDEX IF NOT EXISTS idx_episodes_time ON episodes(agent_id, end_time);
154
- `);
155
-
156
- // Migration: add episode_id column if missing
157
- try {
158
- this.db.prepare('SELECT episode_id FROM engrams LIMIT 0').get();
159
- } catch {
160
- this.db.exec('ALTER TABLE engrams ADD COLUMN episode_id TEXT');
161
- }
162
-
163
- // Migration: add task management columns if missing
164
- try {
165
- this.db.prepare('SELECT task_status FROM engrams LIMIT 0').get();
166
- } catch {
167
- this.db.exec(`
168
- ALTER TABLE engrams ADD COLUMN task_status TEXT;
169
- ALTER TABLE engrams ADD COLUMN task_priority TEXT;
170
- ALTER TABLE engrams ADD COLUMN blocked_by TEXT;
171
- `);
172
- this.db.exec('CREATE INDEX IF NOT EXISTS idx_engrams_task ON engrams(agent_id, task_status)');
173
- }
174
-
175
- // Migration: add memory_class and supersession columns if missing
176
- try {
177
- this.db.prepare('SELECT memory_class FROM engrams LIMIT 0').get();
178
- } catch {
179
- this.db.exec(`
180
- ALTER TABLE engrams ADD COLUMN memory_class TEXT NOT NULL DEFAULT 'working';
181
- ALTER TABLE engrams ADD COLUMN superseded_by TEXT;
182
- ALTER TABLE engrams ADD COLUMN supersedes TEXT;
183
- `);
184
- }
185
-
186
- // Migration: add conscious_state table for checkpointing
187
- this.db.exec(`
188
- CREATE TABLE IF NOT EXISTS conscious_state (
189
- agent_id TEXT PRIMARY KEY,
190
- last_write_id TEXT,
191
- last_recall_context TEXT,
192
- last_recall_ids TEXT NOT NULL DEFAULT '[]',
193
- last_activity_at TEXT NOT NULL DEFAULT (datetime('now')),
194
- write_count_since_consolidation INTEGER NOT NULL DEFAULT 0,
195
- recall_count_since_consolidation INTEGER NOT NULL DEFAULT 0,
196
- execution_state TEXT,
197
- checkpoint_at TEXT,
198
- last_consolidation_at TEXT,
199
- last_mini_consolidation_at TEXT,
200
- consolidation_cycle_count INTEGER NOT NULL DEFAULT 0,
201
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
202
- )
203
- `);
204
-
205
- // Migration: add consolidation_cycle_count if missing (existing DBs)
206
- try {
207
- this.db.exec(`ALTER TABLE conscious_state ADD COLUMN consolidation_cycle_count INTEGER NOT NULL DEFAULT 0`);
208
- } catch { /* column already exists */ }
209
- }
210
-
211
- // --- Engram CRUD ---
212
-
213
- createEngram(input: EngramCreate): Engram {
214
- const now = new Date().toISOString();
215
- const id = randomUUID();
216
- const embeddingBlob = input.embedding
217
- ? Buffer.from(new Float32Array(input.embedding).buffer)
218
- : null;
219
-
220
- this.db.prepare(`
221
- INSERT INTO engrams (id, agent_id, concept, content, embedding, confidence, salience,
222
- access_count, last_accessed, created_at, salience_features, reason_codes, stage, tags, episode_id,
223
- ttl, memory_class, supersedes, task_status, task_priority, blocked_by)
224
- VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?)
225
- `).run(
226
- id, input.agentId, input.concept, input.content, embeddingBlob,
227
- input.confidence ?? 0.5,
228
- input.salience ?? 0.5,
229
- now, now,
230
- JSON.stringify(input.salienceFeatures ?? DEFAULT_SALIENCE_FEATURES),
231
- JSON.stringify(input.reasonCodes ?? []),
232
- JSON.stringify(input.tags ?? []),
233
- input.episodeId ?? null,
234
- input.ttl ?? null,
235
- input.memoryClass ?? 'working',
236
- input.supersedes ?? null,
237
- input.taskStatus ?? null,
238
- input.taskPriority ?? null,
239
- input.blockedBy ?? null,
240
- );
241
-
242
- return this.getEngram(id)!;
243
- }
244
-
245
- getEngram(id: string): Engram | null {
246
- const row = this.db.prepare('SELECT * FROM engrams WHERE id = ?').get(id) as any;
247
- return row ? this.rowToEngram(row) : null;
248
- }
249
-
250
- getEngramsByAgent(agentId: string, stage?: EngramStage, includeRetracted: boolean = false): Engram[] {
251
- let query = 'SELECT * FROM engrams WHERE agent_id = ?';
252
- const params: any[] = [agentId];
253
-
254
- if (stage) {
255
- query += ' AND stage = ?';
256
- params.push(stage);
257
- }
258
- if (!includeRetracted) {
259
- query += ' AND retracted = 0';
260
- }
261
-
262
- return (this.db.prepare(query).all(...params) as any[]).map(r => this.rowToEngram(r));
263
- }
264
-
265
- touchEngram(id: string): void {
266
- this.db.prepare(`
267
- UPDATE engrams SET access_count = access_count + 1, last_accessed = ? WHERE id = ?
268
- `).run(new Date().toISOString(), id);
269
- }
270
-
271
- updateStage(id: string, stage: EngramStage): void {
272
- this.db.prepare('UPDATE engrams SET stage = ? WHERE id = ?').run(stage, id);
273
- }
274
-
275
- updateConfidence(id: string, confidence: number): void {
276
- this.db.prepare('UPDATE engrams SET confidence = ? WHERE id = ?').run(
277
- Math.max(0, Math.min(1, confidence)), id
278
- );
279
- }
280
-
281
- updateEmbedding(id: string, embedding: number[]): void {
282
- const blob = Buffer.from(new Float32Array(embedding).buffer);
283
- this.db.prepare('UPDATE engrams SET embedding = ? WHERE id = ?').run(blob, id);
284
- }
285
-
286
- retractEngram(id: string, retractedBy: string | null): void {
287
- this.db.prepare(`
288
- UPDATE engrams SET retracted = 1, retracted_by = ?, retracted_at = ? WHERE id = ?
289
- `).run(retractedBy, new Date().toISOString(), id);
290
- }
291
-
292
- deleteEngram(id: string): void {
293
- this.db.prepare('DELETE FROM engrams WHERE id = ?').run(id);
294
- }
295
-
296
- /**
297
- * Time warp — shift all timestamps backward by ms milliseconds.
298
- * Used for testing time-dependent behavior (decay, forgetting).
299
- * Returns count of records shifted.
300
- */
301
- timeWarp(agentId: string, ms: number): number {
302
- let count = 0;
303
- const shiftSec = Math.round(ms / 1000);
304
- // Shift engram timestamps
305
- const r1 = this.db.prepare(`
306
- UPDATE engrams SET
307
- created_at = datetime(created_at, '-${shiftSec} seconds'),
308
- last_accessed = datetime(last_accessed, '-${shiftSec} seconds')
309
- WHERE agent_id = ?
310
- `).run(agentId);
311
- count += r1.changes;
312
- // Shift association timestamps
313
- const r2 = this.db.prepare(`
314
- UPDATE associations SET
315
- created_at = datetime(created_at, '-${shiftSec} seconds'),
316
- last_activated = datetime(last_activated, '-${shiftSec} seconds')
317
- WHERE from_engram_id IN (SELECT id FROM engrams WHERE agent_id = ?)
318
- OR to_engram_id IN (SELECT id FROM engrams WHERE agent_id = ?)
319
- `).run(agentId, agentId);
320
- count += r2.changes;
321
- return count;
322
- }
323
-
324
- // --- Full-text search (BM25) ---
325
-
326
- searchBM25(agentId: string, query: string, limit: number = 10): Engram[] {
327
- return this.searchBM25WithRank(agentId, query, limit).map(r => r.engram);
328
- }
329
-
330
- /**
331
- * BM25 search returning rank scores alongside engrams.
332
- * FTS5 rank is negative (lower = better match).
333
- * We normalize to 0-1 where higher = better.
334
- */
335
- searchBM25WithRank(agentId: string, query: string, limit: number = 10): { engram: Engram; bm25Score: number }[] {
336
- // Sanitize query for FTS5: quote each word to prevent column name interpretation
337
- const sanitized = query
338
- .replace(/[^\w\s]/g, '')
339
- .split(/\s+/)
340
- .filter(w => w.length > 1)
341
- .map(w => `"${w}"`)
342
- .join(' OR ');
343
-
344
- if (!sanitized) return [];
345
-
346
- try {
347
- const rows = this.db.prepare(`
348
- SELECT e.*, rank FROM engrams e
349
- JOIN engrams_fts ON e.rowid = engrams_fts.rowid
350
- WHERE engrams_fts MATCH ? AND e.agent_id = ? AND e.retracted = 0
351
- ORDER BY rank
352
- LIMIT ?
353
- `).all(sanitized, agentId, limit) as any[];
354
-
355
- return rows.map(r => ({
356
- engram: this.rowToEngram(r),
357
- // Normalize: rank is negative, more negative = better match.
358
- // |rank| / (1 + |rank|) gives 0-1 where higher = better.
359
- bm25Score: Math.abs(r.rank ?? 0) / (1 + Math.abs(r.rank ?? 0)),
360
- }));
361
- } catch {
362
- return [];
363
- }
364
- }
365
-
366
- // --- Diagnostic search (deterministic, not cognitive) ---
367
-
368
- search(query: SearchQuery): Engram[] {
369
- let sql = 'SELECT * FROM engrams WHERE agent_id = ?';
370
- const params: any[] = [query.agentId];
371
-
372
- if (query.text) {
373
- sql += ' AND (content LIKE ? OR concept LIKE ?)';
374
- params.push(`%${query.text}%`, `%${query.text}%`);
375
- }
376
- if (query.concept) {
377
- sql += ' AND concept = ?';
378
- params.push(query.concept);
379
- }
380
- if (query.stage) {
381
- sql += ' AND stage = ?';
382
- params.push(query.stage);
383
- }
384
- if (query.retracted !== undefined) {
385
- sql += ' AND retracted = ?';
386
- params.push(query.retracted ? 1 : 0);
387
- }
388
- if (query.tags && query.tags.length > 0) {
389
- for (const tag of query.tags) {
390
- sql += ' AND tags LIKE ?';
391
- params.push(`%"${tag}"%`);
392
- }
393
- }
394
-
395
- sql += ' ORDER BY last_accessed DESC';
396
- sql += ` LIMIT ? OFFSET ?`;
397
- params.push(query.limit ?? 50, query.offset ?? 0);
398
-
399
- return (this.db.prepare(sql).all(...params) as any[]).map(r => this.rowToEngram(r));
400
- }
401
-
402
- /**
403
- * Get the most recently created engram for an agent (for temporal adjacency edges).
404
- */
405
- getLatestEngram(agentId: string, excludeId?: string): Engram | null {
406
- let sql = 'SELECT * FROM engrams WHERE agent_id = ? AND retracted = 0';
407
- const params: any[] = [agentId];
408
- if (excludeId) {
409
- sql += ' AND id != ?';
410
- params.push(excludeId);
411
- }
412
- sql += ' ORDER BY created_at DESC LIMIT 1';
413
- const row = this.db.prepare(sql).get(...params) as any;
414
- return row ? this.rowToEngram(row) : null;
415
- }
416
-
417
- // --- Task management ---
418
-
419
- updateTaskStatus(id: string, status: TaskStatus): void {
420
- this.db.prepare('UPDATE engrams SET task_status = ? WHERE id = ?').run(status, id);
421
- }
422
-
423
- updateTaskPriority(id: string, priority: TaskPriority): void {
424
- this.db.prepare('UPDATE engrams SET task_priority = ? WHERE id = ?').run(priority, id);
425
- }
426
-
427
- updateBlockedBy(id: string, blockedBy: string | null): void {
428
- this.db.prepare('UPDATE engrams SET blocked_by = ?, task_status = ? WHERE id = ?')
429
- .run(blockedBy, blockedBy ? 'blocked' : 'open', id);
430
- }
431
-
432
- /**
433
- * Get tasks for an agent, optionally filtered by status.
434
- * Results ordered by priority (urgent > high > medium > low), then creation date.
435
- */
436
- getTasks(agentId: string, status?: TaskStatus): Engram[] {
437
- let sql = 'SELECT * FROM engrams WHERE agent_id = ? AND task_status IS NOT NULL AND retracted = 0';
438
- const params: any[] = [agentId];
439
- if (status) {
440
- sql += ' AND task_status = ?';
441
- params.push(status);
442
- }
443
- sql += ` ORDER BY
444
- CASE task_priority
445
- WHEN 'urgent' THEN 0
446
- WHEN 'high' THEN 1
447
- WHEN 'medium' THEN 2
448
- WHEN 'low' THEN 3
449
- ELSE 4
450
- END,
451
- created_at DESC`;
452
- return (this.db.prepare(sql).all(...params) as any[]).map(r => this.rowToEngram(r));
453
- }
454
-
455
- /**
456
- * Get the next actionable task — highest priority that's not blocked or done.
457
- */
458
- getNextTask(agentId: string): Engram | null {
459
- const row = this.db.prepare(`
460
- SELECT * FROM engrams
461
- WHERE agent_id = ? AND task_status IN ('open', 'in_progress') AND retracted = 0
462
- ORDER BY
463
- CASE task_status WHEN 'in_progress' THEN 0 ELSE 1 END,
464
- CASE task_priority
465
- WHEN 'urgent' THEN 0
466
- WHEN 'high' THEN 1
467
- WHEN 'medium' THEN 2
468
- WHEN 'low' THEN 3
469
- ELSE 4
470
- END,
471
- created_at ASC
472
- LIMIT 1
473
- `).get(agentId) as any;
474
- return row ? this.rowToEngram(row) : null;
475
- }
476
-
477
- // --- Supersession ---
478
-
479
- /**
480
- * Mark an engram as superseded by another.
481
- * The old memory stays in the DB (historical) but gets down-ranked in recall.
482
- */
483
- supersedeEngram(oldId: string, newId: string): void {
484
- this.db.prepare('UPDATE engrams SET superseded_by = ? WHERE id = ?').run(newId, oldId);
485
- this.db.prepare('UPDATE engrams SET supersedes = ? WHERE id = ?').run(oldId, newId);
486
- }
487
-
488
- /**
489
- * Check if an engram has been superseded.
490
- */
491
- isSuperseded(id: string): boolean {
492
- const row = this.db.prepare('SELECT superseded_by FROM engrams WHERE id = ?').get(id) as any;
493
- return row?.superseded_by != null;
494
- }
495
-
496
- updateMemoryClass(id: string, memoryClass: MemoryClass): void {
497
- this.db.prepare('UPDATE engrams SET memory_class = ? WHERE id = ?').run(memoryClass, id);
498
- }
499
-
500
- // --- Associations ---
501
-
502
- upsertAssociation(
503
- fromId: string, toId: string, weight: number,
504
- type: AssociationType = 'hebbian', confidence: number = 0.5
505
- ): Association {
506
- const now = new Date().toISOString();
507
- const id = randomUUID();
508
-
509
- this.db.prepare(`
510
- INSERT INTO associations (id, from_engram_id, to_engram_id, weight, confidence, type, activation_count, created_at, last_activated)
511
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
512
- ON CONFLICT(from_engram_id, to_engram_id) DO UPDATE SET
513
- weight = ?, confidence = ?, last_activated = ?, activation_count = activation_count + 1
514
- `).run(id, fromId, toId, weight, confidence, type, now, now, weight, confidence, now);
515
-
516
- return this.getAssociation(fromId, toId)!;
517
- }
518
-
519
- getAssociation(fromId: string, toId: string): Association | null {
520
- const row = this.db.prepare(
521
- 'SELECT * FROM associations WHERE from_engram_id = ? AND to_engram_id = ?'
522
- ).get(fromId, toId) as any;
523
- return row ? this.rowToAssociation(row) : null;
524
- }
525
-
526
- getAssociationsFor(engramId: string): Association[] {
527
- const rows = this.db.prepare(
528
- 'SELECT * FROM associations WHERE from_engram_id = ? OR to_engram_id = ?'
529
- ).all(engramId, engramId);
530
- return (rows as any[]).map(r => this.rowToAssociation(r));
531
- }
532
-
533
- getOutgoingAssociations(engramId: string): Association[] {
534
- const rows = this.db.prepare(
535
- 'SELECT * FROM associations WHERE from_engram_id = ?'
536
- ).all(engramId);
537
- return (rows as any[]).map(r => this.rowToAssociation(r));
538
- }
539
-
540
- countAssociationsFor(engramId: string): number {
541
- const row = this.db.prepare(
542
- 'SELECT COUNT(*) as count FROM associations WHERE from_engram_id = ?'
543
- ).get(engramId) as any;
544
- return row.count;
545
- }
546
-
547
- getWeakestAssociation(engramId: string): Association | null {
548
- const row = this.db.prepare(
549
- 'SELECT * FROM associations WHERE from_engram_id = ? ORDER BY weight ASC LIMIT 1'
550
- ).get(engramId) as any;
551
- return row ? this.rowToAssociation(row) : null;
552
- }
553
-
554
- deleteAssociation(id: string): void {
555
- this.db.prepare('DELETE FROM associations WHERE id = ?').run(id);
556
- }
557
-
558
- getAllAssociations(agentId: string): Association[] {
559
- const rows = this.db.prepare(`
560
- SELECT a.* FROM associations a
561
- JOIN engrams e ON a.from_engram_id = e.id
562
- WHERE e.agent_id = ?
563
- `).all(agentId);
564
- return (rows as any[]).map(r => this.rowToAssociation(r));
565
- }
566
-
567
- // --- Eviction ---
568
-
569
- getEvictionCandidates(agentId: string, limit: number): Engram[] {
570
- // Lowest combined score: low salience + low access + low confidence + oldest
571
- const rows = this.db.prepare(`
572
- SELECT * FROM engrams
573
- WHERE agent_id = ? AND stage = 'active' AND retracted = 0
574
- ORDER BY (salience * 0.3 + confidence * 0.3 + (CAST(access_count AS REAL) / (access_count + 5)) * 0.2 +
575
- (1.0 / (1.0 + (julianday('now') - julianday(last_accessed)))) * 0.2) ASC
576
- LIMIT ?
577
- `).all(agentId, limit) as any[];
578
- return rows.map(r => this.rowToEngram(r));
579
- }
580
-
581
- getActiveCount(agentId: string): number {
582
- const row = this.db.prepare(
583
- "SELECT COUNT(*) as count FROM engrams WHERE agent_id = ? AND stage = 'active'"
584
- ).get(agentId) as any;
585
- return row.count;
586
- }
587
-
588
- getStagingCount(agentId: string): number {
589
- const row = this.db.prepare(
590
- "SELECT COUNT(*) as count FROM engrams WHERE agent_id = ? AND stage = 'staging'"
591
- ).get(agentId) as any;
592
- return row.count;
593
- }
594
-
595
- // --- Staging buffer ---
596
-
597
- getExpiredStaging(): Engram[] {
598
- const now = Date.now();
599
- const rows = this.db.prepare(`
600
- SELECT * FROM engrams WHERE stage = 'staging' AND ttl IS NOT NULL
601
- `).all() as any[];
602
-
603
- return rows
604
- .map(r => this.rowToEngram(r))
605
- .filter(e => e.ttl && (e.createdAt.getTime() + e.ttl) < now);
606
- }
607
-
608
- // --- Eval event logging ---
609
-
610
- logActivationEvent(event: ActivationEvent): void {
611
- this.db.prepare(`
612
- INSERT INTO activation_events (id, agent_id, timestamp, context, results_returned, top_score, latency_ms, engram_ids)
613
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
614
- `).run(
615
- event.id, event.agentId, event.timestamp.toISOString(),
616
- event.context, event.resultsReturned, event.topScore,
617
- event.latencyMs, JSON.stringify(event.engramIds)
618
- );
619
- }
620
-
621
- logStagingEvent(event: StagingEvent): void {
622
- this.db.prepare(`
623
- INSERT INTO staging_events (engram_id, agent_id, action, resonance_score, timestamp, age_ms)
624
- VALUES (?, ?, ?, ?, ?, ?)
625
- `).run(
626
- event.engramId, event.agentId, event.action,
627
- event.resonanceScore, event.timestamp.toISOString(), event.ageMs
628
- );
629
- }
630
-
631
- logRetrievalFeedback(activationEventId: string | null, engramId: string, useful: boolean, context: string): void {
632
- this.db.prepare(`
633
- INSERT INTO retrieval_feedback (id, activation_event_id, engram_id, useful, context, timestamp)
634
- VALUES (?, ?, ?, ?, ?, ?)
635
- `).run(randomUUID(), activationEventId, engramId, useful ? 1 : 0, context, new Date().toISOString());
636
- }
637
-
638
- // --- Eval metrics queries ---
639
-
640
- getRetrievalPrecision(agentId: string, windowHours: number = 24): number {
641
- const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
642
- const row = this.db.prepare(`
643
- SELECT
644
- COUNT(CASE WHEN useful = 1 THEN 1 END) as useful_count,
645
- COUNT(*) as total_count
646
- FROM retrieval_feedback rf
647
- LEFT JOIN activation_events ae ON rf.activation_event_id = ae.id
648
- JOIN engrams e ON rf.engram_id = e.id
649
- WHERE e.agent_id = ? AND rf.timestamp > ?
650
- `).get(agentId, since) as any;
651
-
652
- return row.total_count > 0 ? row.useful_count / row.total_count : 0;
653
- }
654
-
655
- getStagingMetrics(agentId: string): { promoted: number; discarded: number; expired: number } {
656
- const row = this.db.prepare(`
657
- SELECT
658
- COUNT(CASE WHEN action = 'promoted' THEN 1 END) as promoted,
659
- COUNT(CASE WHEN action = 'discarded' THEN 1 END) as discarded,
660
- COUNT(CASE WHEN action = 'expired' THEN 1 END) as expired
661
- FROM staging_events WHERE agent_id = ?
662
- `).get(agentId) as any;
663
- return { promoted: row.promoted, discarded: row.discarded, expired: row.expired };
664
- }
665
-
666
- getActivationStats(agentId: string, windowHours: number = 24): {
667
- count: number; avgLatencyMs: number; p95LatencyMs: number;
668
- } {
669
- const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
670
- const rows = this.db.prepare(`
671
- SELECT latency_ms FROM activation_events
672
- WHERE agent_id = ? AND timestamp > ?
673
- ORDER BY latency_ms ASC
674
- `).all(agentId, since) as { latency_ms: number }[];
675
-
676
- if (rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
677
-
678
- const total = rows.reduce((s, r) => s + r.latency_ms, 0);
679
- const p95Index = Math.min(Math.floor(rows.length * 0.95), rows.length - 1);
680
- return {
681
- count: rows.length,
682
- avgLatencyMs: total / rows.length,
683
- p95LatencyMs: rows[p95Index].latency_ms,
684
- };
685
- }
686
-
687
- getConsolidatedCount(agentId: string): number {
688
- const row = this.db.prepare(
689
- `SELECT COUNT(*) as cnt FROM engrams WHERE agent_id = ? AND stage = 'consolidated'`
690
- ).get(agentId) as any;
691
- return row.cnt;
692
- }
693
-
694
- // --- Helpers ---
695
-
696
- private rowToEngram(row: any): Engram {
697
- return {
698
- id: row.id,
699
- agentId: row.agent_id,
700
- concept: row.concept,
701
- content: row.content,
702
- embedding: row.embedding
703
- ? Array.from(bufferToFloat32Array(row.embedding))
704
- : null,
705
- confidence: row.confidence,
706
- salience: row.salience,
707
- accessCount: row.access_count,
708
- lastAccessed: new Date(row.last_accessed),
709
- createdAt: new Date(row.created_at),
710
- salienceFeatures: JSON.parse(row.salience_features || '{}'),
711
- reasonCodes: JSON.parse(row.reason_codes || '[]'),
712
- stage: row.stage as EngramStage,
713
- ttl: row.ttl,
714
- retracted: !!row.retracted,
715
- retractedBy: row.retracted_by,
716
- retractedAt: row.retracted_at ? new Date(row.retracted_at) : null,
717
- tags: JSON.parse(row.tags),
718
- episodeId: row.episode_id ?? null,
719
- memoryClass: (row.memory_class ?? 'working') as MemoryClass,
720
- supersededBy: row.superseded_by ?? null,
721
- supersedes: row.supersedes ?? null,
722
- taskStatus: row.task_status ?? null,
723
- taskPriority: row.task_priority ?? null,
724
- blockedBy: row.blocked_by ?? null,
725
- };
726
- }
727
-
728
- private rowToAssociation(row: any): Association {
729
- return {
730
- id: row.id,
731
- fromEngramId: row.from_engram_id,
732
- toEngramId: row.to_engram_id,
733
- weight: row.weight,
734
- confidence: row.confidence ?? 0.5,
735
- type: row.type as AssociationType,
736
- activationCount: row.activation_count ?? 0,
737
- createdAt: new Date(row.created_at),
738
- lastActivated: new Date(row.last_activated),
739
- };
740
- }
741
-
742
- // --- Episodes ---
743
-
744
- createEpisode(input: { agentId: string; label: string; embedding?: number[] }): Episode {
745
- const now = new Date().toISOString();
746
- const id = randomUUID();
747
- const embeddingBlob = input.embedding
748
- ? Buffer.from(new Float32Array(input.embedding).buffer)
749
- : null;
750
-
751
- this.db.prepare(`
752
- INSERT INTO episodes (id, agent_id, label, embedding, engram_count, start_time, end_time, created_at)
753
- VALUES (?, ?, ?, ?, 0, ?, ?, ?)
754
- `).run(id, input.agentId, input.label, embeddingBlob, now, now, now);
755
-
756
- return this.getEpisode(id)!;
757
- }
758
-
759
- getEpisode(id: string): Episode | null {
760
- const row = this.db.prepare('SELECT * FROM episodes WHERE id = ?').get(id) as any;
761
- return row ? this.rowToEpisode(row) : null;
762
- }
763
-
764
- getEpisodesByAgent(agentId: string): Episode[] {
765
- const rows = this.db.prepare(
766
- 'SELECT * FROM episodes WHERE agent_id = ? ORDER BY end_time DESC'
767
- ).all(agentId) as any[];
768
- return rows.map(r => this.rowToEpisode(r));
769
- }
770
-
771
- getActiveEpisode(agentId: string, windowMs: number = 3600_000): Episode | null {
772
- // Find most recent episode that ended within the time window
773
- const cutoff = new Date(Date.now() - windowMs).toISOString();
774
- const row = this.db.prepare(`
775
- SELECT * FROM episodes WHERE agent_id = ? AND end_time > ?
776
- ORDER BY end_time DESC LIMIT 1
777
- `).get(agentId, cutoff) as any;
778
- return row ? this.rowToEpisode(row) : null;
779
- }
780
-
781
- addEngramToEpisode(engramId: string, episodeId: string): void {
782
- this.db.prepare('UPDATE engrams SET episode_id = ? WHERE id = ?').run(episodeId, engramId);
783
- this.db.prepare(`
784
- UPDATE episodes SET
785
- engram_count = engram_count + 1,
786
- end_time = MAX(end_time, ?)
787
- WHERE id = ?
788
- `).run(new Date().toISOString(), episodeId);
789
- }
790
-
791
- getEngramsByEpisode(episodeId: string): Engram[] {
792
- const rows = this.db.prepare(
793
- 'SELECT * FROM engrams WHERE episode_id = ? AND retracted = 0 ORDER BY created_at ASC'
794
- ).all(episodeId) as any[];
795
- return rows.map(r => this.rowToEngram(r));
796
- }
797
-
798
- updateEpisodeEmbedding(id: string, embedding: number[]): void {
799
- const blob = Buffer.from(new Float32Array(embedding).buffer);
800
- this.db.prepare('UPDATE episodes SET embedding = ? WHERE id = ?').run(blob, id);
801
- }
802
-
803
- getEpisodeCount(agentId: string): number {
804
- const row = this.db.prepare(
805
- 'SELECT COUNT(*) as cnt FROM episodes WHERE agent_id = ?'
806
- ).get(agentId) as any;
807
- return row.cnt;
808
- }
809
-
810
- private rowToEpisode(row: any): Episode {
811
- return {
812
- id: row.id,
813
- agentId: row.agent_id,
814
- label: row.label,
815
- embedding: row.embedding
816
- ? Array.from(bufferToFloat32Array(row.embedding))
817
- : null,
818
- engramCount: row.engram_count,
819
- startTime: new Date(row.start_time),
820
- endTime: new Date(row.end_time),
821
- createdAt: new Date(row.created_at),
822
- };
823
- }
824
-
825
- /**
826
- * Find engrams whose tags contain any of the given tag values.
827
- * Used for entity-bridge retrieval: given entity tags from top results,
828
- * find other engrams mentioning the same entities.
829
- */
830
- findEngramsByTags(agentId: string, tags: string[], excludeIds?: Set<string>): Engram[] {
831
- if (tags.length === 0) return [];
832
-
833
- // Build OR conditions for tag matching
834
- const conditions = tags.map(() => 'tags LIKE ?').join(' OR ');
835
- const params: any[] = [agentId, ...tags.map(t => `%"${t}"%`)];
836
-
837
- let sql = `SELECT * FROM engrams WHERE agent_id = ? AND retracted = 0 AND (${conditions})`;
838
- const rows = this.db.prepare(sql).all(...params) as any[];
839
-
840
- const results = rows.map(r => this.rowToEngram(r));
841
- if (excludeIds) {
842
- return results.filter(e => !excludeIds.has(e.id));
843
- }
844
- return results;
845
- }
846
-
847
- // --- Checkpointing ---
848
-
849
- updateAutoCheckpointWrite(agentId: string, engramId: string): void {
850
- const now = new Date().toISOString();
851
- this.db.prepare(`
852
- INSERT INTO conscious_state (agent_id, last_write_id, last_activity_at, write_count_since_consolidation, updated_at)
853
- VALUES (?, ?, ?, 1, ?)
854
- ON CONFLICT(agent_id) DO UPDATE SET
855
- last_write_id = excluded.last_write_id,
856
- last_activity_at = excluded.last_activity_at,
857
- write_count_since_consolidation = write_count_since_consolidation + 1,
858
- updated_at = excluded.updated_at
859
- `).run(agentId, engramId, now, now);
860
- }
861
-
862
- updateAutoCheckpointRecall(agentId: string, context: string, engramIds: string[]): void {
863
- const now = new Date().toISOString();
864
- this.db.prepare(`
865
- INSERT INTO conscious_state (agent_id, last_recall_context, last_recall_ids, last_activity_at, recall_count_since_consolidation, updated_at)
866
- VALUES (?, ?, ?, ?, 1, ?)
867
- ON CONFLICT(agent_id) DO UPDATE SET
868
- last_recall_context = excluded.last_recall_context,
869
- last_recall_ids = excluded.last_recall_ids,
870
- last_activity_at = excluded.last_activity_at,
871
- recall_count_since_consolidation = recall_count_since_consolidation + 1,
872
- updated_at = excluded.updated_at
873
- `).run(agentId, context, JSON.stringify(engramIds), now, now);
874
- }
875
-
876
- touchActivity(agentId: string): void {
877
- const now = new Date().toISOString();
878
- this.db.prepare(`
879
- INSERT INTO conscious_state (agent_id, last_activity_at, updated_at)
880
- VALUES (?, ?, ?)
881
- ON CONFLICT(agent_id) DO UPDATE SET
882
- last_activity_at = excluded.last_activity_at,
883
- updated_at = excluded.updated_at
884
- `).run(agentId, now, now);
885
- }
886
-
887
- saveCheckpoint(agentId: string, state: ConsciousState): void {
888
- const now = new Date().toISOString();
889
- this.db.prepare(`
890
- INSERT INTO conscious_state (agent_id, execution_state, checkpoint_at, last_activity_at, updated_at)
891
- VALUES (?, ?, ?, ?, ?)
892
- ON CONFLICT(agent_id) DO UPDATE SET
893
- execution_state = excluded.execution_state,
894
- checkpoint_at = excluded.checkpoint_at,
895
- last_activity_at = excluded.last_activity_at,
896
- updated_at = excluded.updated_at
897
- `).run(agentId, JSON.stringify(state), now, now, now);
898
- }
899
-
900
- getCheckpoint(agentId: string): CheckpointRow | null {
901
- const row = this.db.prepare('SELECT * FROM conscious_state WHERE agent_id = ?').get(agentId) as any;
902
- if (!row) return null;
903
-
904
- return {
905
- agentId: row.agent_id,
906
- auto: {
907
- lastWriteId: row.last_write_id ?? null,
908
- lastRecallContext: row.last_recall_context ?? null,
909
- lastRecallIds: JSON.parse(row.last_recall_ids || '[]'),
910
- lastActivityAt: new Date(row.last_activity_at),
911
- writeCountSinceConsolidation: row.write_count_since_consolidation,
912
- recallCountSinceConsolidation: row.recall_count_since_consolidation,
913
- },
914
- executionState: row.execution_state ? JSON.parse(row.execution_state) : null,
915
- checkpointAt: row.checkpoint_at ? new Date(row.checkpoint_at) : null,
916
- lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
917
- lastMiniConsolidationAt: row.last_mini_consolidation_at ? new Date(row.last_mini_consolidation_at) : null,
918
- updatedAt: new Date(row.updated_at),
919
- };
920
- }
921
-
922
- markConsolidation(agentId: string, mini: boolean): void {
923
- const now = new Date().toISOString();
924
- if (mini) {
925
- this.db.prepare(`
926
- UPDATE conscious_state SET last_mini_consolidation_at = ?, updated_at = ? WHERE agent_id = ?
927
- `).run(now, now, agentId);
928
- } else {
929
- this.db.prepare(`
930
- UPDATE conscious_state SET
931
- last_consolidation_at = ?,
932
- last_mini_consolidation_at = ?,
933
- write_count_since_consolidation = 0,
934
- recall_count_since_consolidation = 0,
935
- consolidation_cycle_count = consolidation_cycle_count + 1,
936
- updated_at = ?
937
- WHERE agent_id = ?
938
- `).run(now, now, now, agentId);
939
- }
940
- }
941
-
942
- getActiveAgents(): Array<{ agentId: string; lastActivityAt: Date; writeCount: number; recallCount: number; lastConsolidationAt: Date | null }> {
943
- const rows = this.db.prepare('SELECT * FROM conscious_state').all() as any[];
944
- return rows.map(row => ({
945
- agentId: row.agent_id,
946
- lastActivityAt: new Date(row.last_activity_at),
947
- writeCount: row.write_count_since_consolidation,
948
- recallCount: row.recall_count_since_consolidation,
949
- lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
950
- }));
951
- }
952
-
953
- getConsolidationCycleCount(agentId: string): number {
954
- const row = this.db.prepare(
955
- 'SELECT consolidation_cycle_count FROM conscious_state WHERE agent_id = ?',
956
- ).get(agentId) as { consolidation_cycle_count: number } | undefined;
957
- return row?.consolidation_cycle_count ?? 0;
958
- }
959
-
960
- close(): void {
961
- this.db.close();
962
- }
963
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * SQLite storage layer — persistence for engrams, associations, and eval events.
5
+ *
6
+ * Uses better-sqlite3 for synchronous, fast, embedded storage.
7
+ * FTS5 provides BM25 full-text search for the activation pipeline.
8
+ */
9
+
10
+ import Database from 'better-sqlite3';
11
+ import { randomUUID } from 'node:crypto';
12
+ import type {
13
+ Engram, EngramCreate, EngramStage, Association, AssociationType,
14
+ SearchQuery, SalienceFeatures, ActivationEvent, StagingEvent,
15
+ RetrievalFeedbackEvent, Episode, TaskStatus, TaskPriority, MemoryClass,
16
+ ConsciousState, AutoCheckpoint, CheckpointRow,
17
+ } from '../types/index.js';
18
+
19
+ /** Safely convert a Node Buffer to Float32Array, respecting byteOffset/byteLength. */
20
+ function bufferToFloat32Array(buf: Buffer | ArrayBuffer): Float32Array {
21
+ if (buf instanceof ArrayBuffer) return new Float32Array(buf);
22
+ // Node Buffer may share an underlying ArrayBuffer — slice to the exact region
23
+ const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
24
+ return new Float32Array(ab);
25
+ }
26
+
27
+ const DEFAULT_SALIENCE_FEATURES: SalienceFeatures = {
28
+ surprise: 0, decisionMade: false, causalDepth: 0, resolutionEffort: 0, eventType: 'observation',
29
+ };
30
+
31
+ export class EngramStore {
32
+ private db: Database.Database;
33
+
34
+ constructor(dbPath: string = 'memory.db') {
35
+ this.db = new Database(dbPath);
36
+ this.db.pragma('journal_mode = WAL');
37
+ this.db.pragma('foreign_keys = ON');
38
+ this.init();
39
+ }
40
+
41
+ /** Expose the raw database handle for the coordination module. */
42
+ getDb(): Database.Database {
43
+ return this.db;
44
+ }
45
+
46
+ private init(): void {
47
+ this.db.exec(`
48
+ CREATE TABLE IF NOT EXISTS engrams (
49
+ id TEXT PRIMARY KEY,
50
+ agent_id TEXT NOT NULL,
51
+ concept TEXT NOT NULL,
52
+ content TEXT NOT NULL,
53
+ embedding BLOB,
54
+ confidence REAL NOT NULL DEFAULT 0.5,
55
+ salience REAL NOT NULL DEFAULT 0.5,
56
+ access_count INTEGER NOT NULL DEFAULT 0,
57
+ last_accessed TEXT NOT NULL,
58
+ created_at TEXT NOT NULL,
59
+ salience_features TEXT NOT NULL DEFAULT '{}',
60
+ reason_codes TEXT NOT NULL DEFAULT '[]',
61
+ stage TEXT NOT NULL DEFAULT 'active',
62
+ ttl INTEGER,
63
+ retracted INTEGER NOT NULL DEFAULT 0,
64
+ retracted_by TEXT,
65
+ retracted_at TEXT,
66
+ tags TEXT NOT NULL DEFAULT '[]'
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_engrams_agent ON engrams(agent_id);
70
+ CREATE INDEX IF NOT EXISTS idx_engrams_stage ON engrams(agent_id, stage);
71
+ CREATE INDEX IF NOT EXISTS idx_engrams_concept ON engrams(concept);
72
+ CREATE INDEX IF NOT EXISTS idx_engrams_retracted ON engrams(agent_id, retracted);
73
+
74
+ CREATE TABLE IF NOT EXISTS associations (
75
+ id TEXT PRIMARY KEY,
76
+ from_engram_id TEXT NOT NULL REFERENCES engrams(id) ON DELETE CASCADE,
77
+ to_engram_id TEXT NOT NULL REFERENCES engrams(id) ON DELETE CASCADE,
78
+ weight REAL NOT NULL DEFAULT 0.1,
79
+ confidence REAL NOT NULL DEFAULT 0.5,
80
+ type TEXT NOT NULL DEFAULT 'hebbian',
81
+ activation_count INTEGER NOT NULL DEFAULT 0,
82
+ created_at TEXT NOT NULL,
83
+ last_activated TEXT NOT NULL
84
+ );
85
+
86
+ CREATE INDEX IF NOT EXISTS idx_assoc_from ON associations(from_engram_id);
87
+ CREATE INDEX IF NOT EXISTS idx_assoc_to ON associations(to_engram_id);
88
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_assoc_pair ON associations(from_engram_id, to_engram_id);
89
+
90
+ CREATE TABLE IF NOT EXISTS agents (
91
+ id TEXT PRIMARY KEY,
92
+ name TEXT NOT NULL,
93
+ created_at TEXT NOT NULL,
94
+ config TEXT NOT NULL DEFAULT '{}'
95
+ );
96
+
97
+ -- FTS5 for full-text search (BM25 ranking built in)
98
+ CREATE VIRTUAL TABLE IF NOT EXISTS engrams_fts USING fts5(
99
+ concept, content, tags,
100
+ content=engrams,
101
+ content_rowid=rowid
102
+ );
103
+
104
+ -- Triggers to keep FTS in sync
105
+ CREATE TRIGGER IF NOT EXISTS engrams_ai AFTER INSERT ON engrams BEGIN
106
+ INSERT INTO engrams_fts(rowid, concept, content, tags) VALUES (new.rowid, new.concept, new.content, new.tags);
107
+ END;
108
+ CREATE TRIGGER IF NOT EXISTS engrams_ad AFTER DELETE ON engrams BEGIN
109
+ INSERT INTO engrams_fts(engrams_fts, rowid, concept, content, tags) VALUES('delete', old.rowid, old.concept, old.content, old.tags);
110
+ END;
111
+ CREATE TRIGGER IF NOT EXISTS engrams_au AFTER UPDATE ON engrams BEGIN
112
+ INSERT INTO engrams_fts(engrams_fts, rowid, concept, content, tags) VALUES('delete', old.rowid, old.concept, old.content, old.tags);
113
+ INSERT INTO engrams_fts(rowid, concept, content, tags) VALUES (new.rowid, new.concept, new.content, new.tags);
114
+ END;
115
+
116
+ -- Eval event logs
117
+ CREATE TABLE IF NOT EXISTS activation_events (
118
+ id TEXT PRIMARY KEY,
119
+ agent_id TEXT NOT NULL,
120
+ timestamp TEXT NOT NULL,
121
+ context TEXT NOT NULL,
122
+ results_returned INTEGER NOT NULL,
123
+ top_score REAL,
124
+ latency_ms REAL NOT NULL,
125
+ engram_ids TEXT NOT NULL DEFAULT '[]'
126
+ );
127
+
128
+ CREATE TABLE IF NOT EXISTS staging_events (
129
+ engram_id TEXT NOT NULL,
130
+ agent_id TEXT NOT NULL,
131
+ action TEXT NOT NULL,
132
+ resonance_score REAL,
133
+ timestamp TEXT NOT NULL,
134
+ age_ms INTEGER NOT NULL
135
+ );
136
+
137
+ CREATE TABLE IF NOT EXISTS retrieval_feedback (
138
+ id TEXT PRIMARY KEY,
139
+ activation_event_id TEXT,
140
+ engram_id TEXT NOT NULL,
141
+ useful INTEGER NOT NULL,
142
+ context TEXT,
143
+ timestamp TEXT NOT NULL
144
+ );
145
+
146
+ CREATE TABLE IF NOT EXISTS episodes (
147
+ id TEXT PRIMARY KEY,
148
+ agent_id TEXT NOT NULL,
149
+ label TEXT NOT NULL,
150
+ embedding BLOB,
151
+ engram_count INTEGER NOT NULL DEFAULT 0,
152
+ start_time TEXT NOT NULL,
153
+ end_time TEXT NOT NULL,
154
+ created_at TEXT NOT NULL
155
+ );
156
+
157
+ CREATE INDEX IF NOT EXISTS idx_episodes_agent ON episodes(agent_id);
158
+ CREATE INDEX IF NOT EXISTS idx_episodes_time ON episodes(agent_id, end_time);
159
+ `);
160
+
161
+ // Migration: add episode_id column if missing
162
+ try {
163
+ this.db.prepare('SELECT episode_id FROM engrams LIMIT 0').get();
164
+ } catch {
165
+ this.db.exec('ALTER TABLE engrams ADD COLUMN episode_id TEXT');
166
+ }
167
+
168
+ // Migration: add task management columns if missing
169
+ try {
170
+ this.db.prepare('SELECT task_status FROM engrams LIMIT 0').get();
171
+ } catch {
172
+ this.db.exec(`
173
+ ALTER TABLE engrams ADD COLUMN task_status TEXT;
174
+ ALTER TABLE engrams ADD COLUMN task_priority TEXT;
175
+ ALTER TABLE engrams ADD COLUMN blocked_by TEXT;
176
+ `);
177
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_engrams_task ON engrams(agent_id, task_status)');
178
+ }
179
+
180
+ // Migration: add memory_class and supersession columns if missing
181
+ try {
182
+ this.db.prepare('SELECT memory_class FROM engrams LIMIT 0').get();
183
+ } catch {
184
+ this.db.exec(`
185
+ ALTER TABLE engrams ADD COLUMN memory_class TEXT NOT NULL DEFAULT 'working';
186
+ ALTER TABLE engrams ADD COLUMN superseded_by TEXT;
187
+ ALTER TABLE engrams ADD COLUMN supersedes TEXT;
188
+ `);
189
+ }
190
+
191
+ // Migration: add conscious_state table for checkpointing
192
+ this.db.exec(`
193
+ CREATE TABLE IF NOT EXISTS conscious_state (
194
+ agent_id TEXT PRIMARY KEY,
195
+ last_write_id TEXT,
196
+ last_recall_context TEXT,
197
+ last_recall_ids TEXT NOT NULL DEFAULT '[]',
198
+ last_activity_at TEXT NOT NULL DEFAULT (datetime('now')),
199
+ write_count_since_consolidation INTEGER NOT NULL DEFAULT 0,
200
+ recall_count_since_consolidation INTEGER NOT NULL DEFAULT 0,
201
+ execution_state TEXT,
202
+ checkpoint_at TEXT,
203
+ last_consolidation_at TEXT,
204
+ last_mini_consolidation_at TEXT,
205
+ consolidation_cycle_count INTEGER NOT NULL DEFAULT 0,
206
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
207
+ )
208
+ `);
209
+
210
+ // Migration: add consolidation_cycle_count if missing (existing DBs)
211
+ try {
212
+ this.db.exec(`ALTER TABLE conscious_state ADD COLUMN consolidation_cycle_count INTEGER NOT NULL DEFAULT 0`);
213
+ } catch { /* column already exists */ }
214
+ }
215
+
216
+ // --- Engram CRUD ---
217
+
218
+ createEngram(input: EngramCreate): Engram {
219
+ const now = new Date().toISOString();
220
+ const id = randomUUID();
221
+ const embeddingBlob = input.embedding
222
+ ? Buffer.from(new Float32Array(input.embedding).buffer)
223
+ : null;
224
+
225
+ this.db.prepare(`
226
+ INSERT INTO engrams (id, agent_id, concept, content, embedding, confidence, salience,
227
+ access_count, last_accessed, created_at, salience_features, reason_codes, stage, tags, episode_id,
228
+ ttl, memory_class, supersedes, task_status, task_priority, blocked_by)
229
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?)
230
+ `).run(
231
+ id, input.agentId, input.concept, input.content, embeddingBlob,
232
+ input.confidence ?? 0.5,
233
+ input.salience ?? 0.5,
234
+ now, now,
235
+ JSON.stringify(input.salienceFeatures ?? DEFAULT_SALIENCE_FEATURES),
236
+ JSON.stringify(input.reasonCodes ?? []),
237
+ JSON.stringify(input.tags ?? []),
238
+ input.episodeId ?? null,
239
+ input.ttl ?? null,
240
+ input.memoryClass ?? 'working',
241
+ input.supersedes ?? null,
242
+ input.taskStatus ?? null,
243
+ input.taskPriority ?? null,
244
+ input.blockedBy ?? null,
245
+ );
246
+
247
+ return this.getEngram(id)!;
248
+ }
249
+
250
+ getEngram(id: string): Engram | null {
251
+ const row = this.db.prepare('SELECT * FROM engrams WHERE id = ?').get(id) as any;
252
+ return row ? this.rowToEngram(row) : null;
253
+ }
254
+
255
+ getEngramsByAgent(agentId: string, stage?: EngramStage, includeRetracted: boolean = false): Engram[] {
256
+ let query = 'SELECT * FROM engrams WHERE agent_id = ?';
257
+ const params: any[] = [agentId];
258
+
259
+ if (stage) {
260
+ query += ' AND stage = ?';
261
+ params.push(stage);
262
+ }
263
+ if (!includeRetracted) {
264
+ query += ' AND retracted = 0';
265
+ }
266
+
267
+ return (this.db.prepare(query).all(...params) as any[]).map(r => this.rowToEngram(r));
268
+ }
269
+
270
+ touchEngram(id: string): void {
271
+ this.db.prepare(`
272
+ UPDATE engrams SET access_count = access_count + 1, last_accessed = ? WHERE id = ?
273
+ `).run(new Date().toISOString(), id);
274
+ }
275
+
276
+ updateStage(id: string, stage: EngramStage): void {
277
+ this.db.prepare('UPDATE engrams SET stage = ? WHERE id = ?').run(stage, id);
278
+ }
279
+
280
+ updateConfidence(id: string, confidence: number): void {
281
+ this.db.prepare('UPDATE engrams SET confidence = ? WHERE id = ?').run(
282
+ Math.max(0, Math.min(1, confidence)), id
283
+ );
284
+ }
285
+
286
+ updateEmbedding(id: string, embedding: number[]): void {
287
+ const blob = Buffer.from(new Float32Array(embedding).buffer);
288
+ this.db.prepare('UPDATE engrams SET embedding = ? WHERE id = ?').run(blob, id);
289
+ }
290
+
291
+ retractEngram(id: string, retractedBy: string | null): void {
292
+ this.db.prepare(`
293
+ UPDATE engrams SET retracted = 1, retracted_by = ?, retracted_at = ? WHERE id = ?
294
+ `).run(retractedBy, new Date().toISOString(), id);
295
+ }
296
+
297
+ deleteEngram(id: string): void {
298
+ this.db.prepare('DELETE FROM engrams WHERE id = ?').run(id);
299
+ }
300
+
301
+ /**
302
+ * Time warp — shift all timestamps backward by ms milliseconds.
303
+ * Used for testing time-dependent behavior (decay, forgetting).
304
+ * Returns count of records shifted.
305
+ */
306
+ timeWarp(agentId: string, ms: number): number {
307
+ let count = 0;
308
+ const shiftSec = Math.round(ms / 1000);
309
+ // Shift engram timestamps
310
+ const r1 = this.db.prepare(`
311
+ UPDATE engrams SET
312
+ created_at = datetime(created_at, '-${shiftSec} seconds'),
313
+ last_accessed = datetime(last_accessed, '-${shiftSec} seconds')
314
+ WHERE agent_id = ?
315
+ `).run(agentId);
316
+ count += r1.changes;
317
+ // Shift association timestamps
318
+ const r2 = this.db.prepare(`
319
+ UPDATE associations SET
320
+ created_at = datetime(created_at, '-${shiftSec} seconds'),
321
+ last_activated = datetime(last_activated, '-${shiftSec} seconds')
322
+ WHERE from_engram_id IN (SELECT id FROM engrams WHERE agent_id = ?)
323
+ OR to_engram_id IN (SELECT id FROM engrams WHERE agent_id = ?)
324
+ `).run(agentId, agentId);
325
+ count += r2.changes;
326
+ return count;
327
+ }
328
+
329
+ // --- Full-text search (BM25) ---
330
+
331
+ searchBM25(agentId: string, query: string, limit: number = 10): Engram[] {
332
+ return this.searchBM25WithRank(agentId, query, limit).map(r => r.engram);
333
+ }
334
+
335
+ /**
336
+ * BM25 search returning rank scores alongside engrams.
337
+ * FTS5 rank is negative (lower = better match).
338
+ * We normalize to 0-1 where higher = better.
339
+ */
340
+ searchBM25WithRank(agentId: string, query: string, limit: number = 10): { engram: Engram; bm25Score: number }[] {
341
+ // Sanitize query for FTS5: quote each word to prevent column name interpretation
342
+ const sanitized = query
343
+ .replace(/[^\w\s]/g, '')
344
+ .split(/\s+/)
345
+ .filter(w => w.length > 1)
346
+ .map(w => `"${w}"`)
347
+ .join(' OR ');
348
+
349
+ if (!sanitized) return [];
350
+
351
+ try {
352
+ const rows = this.db.prepare(`
353
+ SELECT e.*, rank FROM engrams e
354
+ JOIN engrams_fts ON e.rowid = engrams_fts.rowid
355
+ WHERE engrams_fts MATCH ? AND e.agent_id = ? AND e.retracted = 0
356
+ ORDER BY rank
357
+ LIMIT ?
358
+ `).all(sanitized, agentId, limit) as any[];
359
+
360
+ return rows.map(r => ({
361
+ engram: this.rowToEngram(r),
362
+ // Normalize: rank is negative, more negative = better match.
363
+ // |rank| / (1 + |rank|) gives 0-1 where higher = better.
364
+ bm25Score: Math.abs(r.rank ?? 0) / (1 + Math.abs(r.rank ?? 0)),
365
+ }));
366
+ } catch {
367
+ return [];
368
+ }
369
+ }
370
+
371
+ // --- Diagnostic search (deterministic, not cognitive) ---
372
+
373
+ search(query: SearchQuery): Engram[] {
374
+ let sql = 'SELECT * FROM engrams WHERE agent_id = ?';
375
+ const params: any[] = [query.agentId];
376
+
377
+ if (query.text) {
378
+ sql += ' AND (content LIKE ? OR concept LIKE ?)';
379
+ params.push(`%${query.text}%`, `%${query.text}%`);
380
+ }
381
+ if (query.concept) {
382
+ sql += ' AND concept = ?';
383
+ params.push(query.concept);
384
+ }
385
+ if (query.stage) {
386
+ sql += ' AND stage = ?';
387
+ params.push(query.stage);
388
+ }
389
+ if (query.retracted !== undefined) {
390
+ sql += ' AND retracted = ?';
391
+ params.push(query.retracted ? 1 : 0);
392
+ }
393
+ if (query.tags && query.tags.length > 0) {
394
+ for (const tag of query.tags) {
395
+ sql += ' AND tags LIKE ?';
396
+ params.push(`%"${tag}"%`);
397
+ }
398
+ }
399
+
400
+ sql += ' ORDER BY last_accessed DESC';
401
+ sql += ` LIMIT ? OFFSET ?`;
402
+ params.push(query.limit ?? 50, query.offset ?? 0);
403
+
404
+ return (this.db.prepare(sql).all(...params) as any[]).map(r => this.rowToEngram(r));
405
+ }
406
+
407
+ /**
408
+ * Get the most recently created engram for an agent (for temporal adjacency edges).
409
+ */
410
+ getLatestEngram(agentId: string, excludeId?: string): Engram | null {
411
+ let sql = 'SELECT * FROM engrams WHERE agent_id = ? AND retracted = 0';
412
+ const params: any[] = [agentId];
413
+ if (excludeId) {
414
+ sql += ' AND id != ?';
415
+ params.push(excludeId);
416
+ }
417
+ sql += ' ORDER BY created_at DESC LIMIT 1';
418
+ const row = this.db.prepare(sql).get(...params) as any;
419
+ return row ? this.rowToEngram(row) : null;
420
+ }
421
+
422
+ // --- Task management ---
423
+
424
+ updateTaskStatus(id: string, status: TaskStatus): void {
425
+ this.db.prepare('UPDATE engrams SET task_status = ? WHERE id = ?').run(status, id);
426
+ }
427
+
428
+ updateTaskPriority(id: string, priority: TaskPriority): void {
429
+ this.db.prepare('UPDATE engrams SET task_priority = ? WHERE id = ?').run(priority, id);
430
+ }
431
+
432
+ updateBlockedBy(id: string, blockedBy: string | null): void {
433
+ this.db.prepare('UPDATE engrams SET blocked_by = ?, task_status = ? WHERE id = ?')
434
+ .run(blockedBy, blockedBy ? 'blocked' : 'open', id);
435
+ }
436
+
437
+ /**
438
+ * Get tasks for an agent, optionally filtered by status.
439
+ * Results ordered by priority (urgent > high > medium > low), then creation date.
440
+ */
441
+ getTasks(agentId: string, status?: TaskStatus): Engram[] {
442
+ let sql = 'SELECT * FROM engrams WHERE agent_id = ? AND task_status IS NOT NULL AND retracted = 0';
443
+ const params: any[] = [agentId];
444
+ if (status) {
445
+ sql += ' AND task_status = ?';
446
+ params.push(status);
447
+ }
448
+ sql += ` ORDER BY
449
+ CASE task_priority
450
+ WHEN 'urgent' THEN 0
451
+ WHEN 'high' THEN 1
452
+ WHEN 'medium' THEN 2
453
+ WHEN 'low' THEN 3
454
+ ELSE 4
455
+ END,
456
+ created_at DESC`;
457
+ return (this.db.prepare(sql).all(...params) as any[]).map(r => this.rowToEngram(r));
458
+ }
459
+
460
+ /**
461
+ * Get the next actionable task highest priority that's not blocked or done.
462
+ */
463
+ getNextTask(agentId: string): Engram | null {
464
+ const row = this.db.prepare(`
465
+ SELECT * FROM engrams
466
+ WHERE agent_id = ? AND task_status IN ('open', 'in_progress') AND retracted = 0
467
+ ORDER BY
468
+ CASE task_status WHEN 'in_progress' THEN 0 ELSE 1 END,
469
+ CASE task_priority
470
+ WHEN 'urgent' THEN 0
471
+ WHEN 'high' THEN 1
472
+ WHEN 'medium' THEN 2
473
+ WHEN 'low' THEN 3
474
+ ELSE 4
475
+ END,
476
+ created_at ASC
477
+ LIMIT 1
478
+ `).get(agentId) as any;
479
+ return row ? this.rowToEngram(row) : null;
480
+ }
481
+
482
+ // --- Supersession ---
483
+
484
+ /**
485
+ * Mark an engram as superseded by another.
486
+ * The old memory stays in the DB (historical) but gets down-ranked in recall.
487
+ */
488
+ supersedeEngram(oldId: string, newId: string): void {
489
+ this.db.prepare('UPDATE engrams SET superseded_by = ? WHERE id = ?').run(newId, oldId);
490
+ this.db.prepare('UPDATE engrams SET supersedes = ? WHERE id = ?').run(oldId, newId);
491
+ }
492
+
493
+ /**
494
+ * Check if an engram has been superseded.
495
+ */
496
+ isSuperseded(id: string): boolean {
497
+ const row = this.db.prepare('SELECT superseded_by FROM engrams WHERE id = ?').get(id) as any;
498
+ return row?.superseded_by != null;
499
+ }
500
+
501
+ updateMemoryClass(id: string, memoryClass: MemoryClass): void {
502
+ this.db.prepare('UPDATE engrams SET memory_class = ? WHERE id = ?').run(memoryClass, id);
503
+ }
504
+
505
+ // --- Associations ---
506
+
507
+ upsertAssociation(
508
+ fromId: string, toId: string, weight: number,
509
+ type: AssociationType = 'hebbian', confidence: number = 0.5
510
+ ): Association {
511
+ const now = new Date().toISOString();
512
+ const id = randomUUID();
513
+
514
+ this.db.prepare(`
515
+ INSERT INTO associations (id, from_engram_id, to_engram_id, weight, confidence, type, activation_count, created_at, last_activated)
516
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
517
+ ON CONFLICT(from_engram_id, to_engram_id) DO UPDATE SET
518
+ weight = ?, confidence = ?, last_activated = ?, activation_count = activation_count + 1
519
+ `).run(id, fromId, toId, weight, confidence, type, now, now, weight, confidence, now);
520
+
521
+ return this.getAssociation(fromId, toId)!;
522
+ }
523
+
524
+ getAssociation(fromId: string, toId: string): Association | null {
525
+ const row = this.db.prepare(
526
+ 'SELECT * FROM associations WHERE from_engram_id = ? AND to_engram_id = ?'
527
+ ).get(fromId, toId) as any;
528
+ return row ? this.rowToAssociation(row) : null;
529
+ }
530
+
531
+ getAssociationsFor(engramId: string): Association[] {
532
+ const rows = this.db.prepare(
533
+ 'SELECT * FROM associations WHERE from_engram_id = ? OR to_engram_id = ?'
534
+ ).all(engramId, engramId);
535
+ return (rows as any[]).map(r => this.rowToAssociation(r));
536
+ }
537
+
538
+ getOutgoingAssociations(engramId: string): Association[] {
539
+ const rows = this.db.prepare(
540
+ 'SELECT * FROM associations WHERE from_engram_id = ?'
541
+ ).all(engramId);
542
+ return (rows as any[]).map(r => this.rowToAssociation(r));
543
+ }
544
+
545
+ countAssociationsFor(engramId: string): number {
546
+ const row = this.db.prepare(
547
+ 'SELECT COUNT(*) as count FROM associations WHERE from_engram_id = ?'
548
+ ).get(engramId) as any;
549
+ return row.count;
550
+ }
551
+
552
+ getWeakestAssociation(engramId: string): Association | null {
553
+ const row = this.db.prepare(
554
+ 'SELECT * FROM associations WHERE from_engram_id = ? ORDER BY weight ASC LIMIT 1'
555
+ ).get(engramId) as any;
556
+ return row ? this.rowToAssociation(row) : null;
557
+ }
558
+
559
+ deleteAssociation(id: string): void {
560
+ this.db.prepare('DELETE FROM associations WHERE id = ?').run(id);
561
+ }
562
+
563
+ getAllAssociations(agentId: string): Association[] {
564
+ const rows = this.db.prepare(`
565
+ SELECT a.* FROM associations a
566
+ JOIN engrams e ON a.from_engram_id = e.id
567
+ WHERE e.agent_id = ?
568
+ `).all(agentId);
569
+ return (rows as any[]).map(r => this.rowToAssociation(r));
570
+ }
571
+
572
+ // --- Eviction ---
573
+
574
+ getEvictionCandidates(agentId: string, limit: number): Engram[] {
575
+ // Lowest combined score: low salience + low access + low confidence + oldest
576
+ const rows = this.db.prepare(`
577
+ SELECT * FROM engrams
578
+ WHERE agent_id = ? AND stage = 'active' AND retracted = 0
579
+ ORDER BY (salience * 0.3 + confidence * 0.3 + (CAST(access_count AS REAL) / (access_count + 5)) * 0.2 +
580
+ (1.0 / (1.0 + (julianday('now') - julianday(last_accessed)))) * 0.2) ASC
581
+ LIMIT ?
582
+ `).all(agentId, limit) as any[];
583
+ return rows.map(r => this.rowToEngram(r));
584
+ }
585
+
586
+ getActiveCount(agentId: string): number {
587
+ const row = this.db.prepare(
588
+ "SELECT COUNT(*) as count FROM engrams WHERE agent_id = ? AND stage = 'active'"
589
+ ).get(agentId) as any;
590
+ return row.count;
591
+ }
592
+
593
+ getStagingCount(agentId: string): number {
594
+ const row = this.db.prepare(
595
+ "SELECT COUNT(*) as count FROM engrams WHERE agent_id = ? AND stage = 'staging'"
596
+ ).get(agentId) as any;
597
+ return row.count;
598
+ }
599
+
600
+ // --- Staging buffer ---
601
+
602
+ getExpiredStaging(): Engram[] {
603
+ const now = Date.now();
604
+ const rows = this.db.prepare(`
605
+ SELECT * FROM engrams WHERE stage = 'staging' AND ttl IS NOT NULL
606
+ `).all() as any[];
607
+
608
+ return rows
609
+ .map(r => this.rowToEngram(r))
610
+ .filter(e => e.ttl && (e.createdAt.getTime() + e.ttl) < now);
611
+ }
612
+
613
+ // --- Eval event logging ---
614
+
615
+ logActivationEvent(event: ActivationEvent): void {
616
+ this.db.prepare(`
617
+ INSERT INTO activation_events (id, agent_id, timestamp, context, results_returned, top_score, latency_ms, engram_ids)
618
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
619
+ `).run(
620
+ event.id, event.agentId, event.timestamp.toISOString(),
621
+ event.context, event.resultsReturned, event.topScore,
622
+ event.latencyMs, JSON.stringify(event.engramIds)
623
+ );
624
+ }
625
+
626
+ logStagingEvent(event: StagingEvent): void {
627
+ this.db.prepare(`
628
+ INSERT INTO staging_events (engram_id, agent_id, action, resonance_score, timestamp, age_ms)
629
+ VALUES (?, ?, ?, ?, ?, ?)
630
+ `).run(
631
+ event.engramId, event.agentId, event.action,
632
+ event.resonanceScore, event.timestamp.toISOString(), event.ageMs
633
+ );
634
+ }
635
+
636
+ logRetrievalFeedback(activationEventId: string | null, engramId: string, useful: boolean, context: string): void {
637
+ this.db.prepare(`
638
+ INSERT INTO retrieval_feedback (id, activation_event_id, engram_id, useful, context, timestamp)
639
+ VALUES (?, ?, ?, ?, ?, ?)
640
+ `).run(randomUUID(), activationEventId, engramId, useful ? 1 : 0, context, new Date().toISOString());
641
+ }
642
+
643
+ // --- Eval metrics queries ---
644
+
645
+ getRetrievalPrecision(agentId: string, windowHours: number = 24): number {
646
+ const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
647
+ const row = this.db.prepare(`
648
+ SELECT
649
+ COUNT(CASE WHEN useful = 1 THEN 1 END) as useful_count,
650
+ COUNT(*) as total_count
651
+ FROM retrieval_feedback rf
652
+ LEFT JOIN activation_events ae ON rf.activation_event_id = ae.id
653
+ JOIN engrams e ON rf.engram_id = e.id
654
+ WHERE e.agent_id = ? AND rf.timestamp > ?
655
+ `).get(agentId, since) as any;
656
+
657
+ return row.total_count > 0 ? row.useful_count / row.total_count : 0;
658
+ }
659
+
660
+ getStagingMetrics(agentId: string): { promoted: number; discarded: number; expired: number } {
661
+ const row = this.db.prepare(`
662
+ SELECT
663
+ COUNT(CASE WHEN action = 'promoted' THEN 1 END) as promoted,
664
+ COUNT(CASE WHEN action = 'discarded' THEN 1 END) as discarded,
665
+ COUNT(CASE WHEN action = 'expired' THEN 1 END) as expired
666
+ FROM staging_events WHERE agent_id = ?
667
+ `).get(agentId) as any;
668
+ return { promoted: row.promoted, discarded: row.discarded, expired: row.expired };
669
+ }
670
+
671
+ getActivationStats(agentId: string, windowHours: number = 24): {
672
+ count: number; avgLatencyMs: number; p95LatencyMs: number;
673
+ } {
674
+ const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
675
+ const rows = this.db.prepare(`
676
+ SELECT latency_ms FROM activation_events
677
+ WHERE agent_id = ? AND timestamp > ?
678
+ ORDER BY latency_ms ASC
679
+ `).all(agentId, since) as { latency_ms: number }[];
680
+
681
+ if (rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
682
+
683
+ const total = rows.reduce((s, r) => s + r.latency_ms, 0);
684
+ const p95Index = Math.min(Math.floor(rows.length * 0.95), rows.length - 1);
685
+ return {
686
+ count: rows.length,
687
+ avgLatencyMs: total / rows.length,
688
+ p95LatencyMs: rows[p95Index].latency_ms,
689
+ };
690
+ }
691
+
692
+ getConsolidatedCount(agentId: string): number {
693
+ const row = this.db.prepare(
694
+ `SELECT COUNT(*) as cnt FROM engrams WHERE agent_id = ? AND stage = 'consolidated'`
695
+ ).get(agentId) as any;
696
+ return row.cnt;
697
+ }
698
+
699
+ // --- Helpers ---
700
+
701
+ private rowToEngram(row: any): Engram {
702
+ return {
703
+ id: row.id,
704
+ agentId: row.agent_id,
705
+ concept: row.concept,
706
+ content: row.content,
707
+ embedding: row.embedding
708
+ ? Array.from(bufferToFloat32Array(row.embedding))
709
+ : null,
710
+ confidence: row.confidence,
711
+ salience: row.salience,
712
+ accessCount: row.access_count,
713
+ lastAccessed: new Date(row.last_accessed),
714
+ createdAt: new Date(row.created_at),
715
+ salienceFeatures: JSON.parse(row.salience_features || '{}'),
716
+ reasonCodes: JSON.parse(row.reason_codes || '[]'),
717
+ stage: row.stage as EngramStage,
718
+ ttl: row.ttl,
719
+ retracted: !!row.retracted,
720
+ retractedBy: row.retracted_by,
721
+ retractedAt: row.retracted_at ? new Date(row.retracted_at) : null,
722
+ tags: JSON.parse(row.tags),
723
+ episodeId: row.episode_id ?? null,
724
+ memoryClass: (row.memory_class ?? 'working') as MemoryClass,
725
+ supersededBy: row.superseded_by ?? null,
726
+ supersedes: row.supersedes ?? null,
727
+ taskStatus: row.task_status ?? null,
728
+ taskPriority: row.task_priority ?? null,
729
+ blockedBy: row.blocked_by ?? null,
730
+ };
731
+ }
732
+
733
+ private rowToAssociation(row: any): Association {
734
+ return {
735
+ id: row.id,
736
+ fromEngramId: row.from_engram_id,
737
+ toEngramId: row.to_engram_id,
738
+ weight: row.weight,
739
+ confidence: row.confidence ?? 0.5,
740
+ type: row.type as AssociationType,
741
+ activationCount: row.activation_count ?? 0,
742
+ createdAt: new Date(row.created_at),
743
+ lastActivated: new Date(row.last_activated),
744
+ };
745
+ }
746
+
747
+ // --- Episodes ---
748
+
749
+ createEpisode(input: { agentId: string; label: string; embedding?: number[] }): Episode {
750
+ const now = new Date().toISOString();
751
+ const id = randomUUID();
752
+ const embeddingBlob = input.embedding
753
+ ? Buffer.from(new Float32Array(input.embedding).buffer)
754
+ : null;
755
+
756
+ this.db.prepare(`
757
+ INSERT INTO episodes (id, agent_id, label, embedding, engram_count, start_time, end_time, created_at)
758
+ VALUES (?, ?, ?, ?, 0, ?, ?, ?)
759
+ `).run(id, input.agentId, input.label, embeddingBlob, now, now, now);
760
+
761
+ return this.getEpisode(id)!;
762
+ }
763
+
764
+ getEpisode(id: string): Episode | null {
765
+ const row = this.db.prepare('SELECT * FROM episodes WHERE id = ?').get(id) as any;
766
+ return row ? this.rowToEpisode(row) : null;
767
+ }
768
+
769
+ getEpisodesByAgent(agentId: string): Episode[] {
770
+ const rows = this.db.prepare(
771
+ 'SELECT * FROM episodes WHERE agent_id = ? ORDER BY end_time DESC'
772
+ ).all(agentId) as any[];
773
+ return rows.map(r => this.rowToEpisode(r));
774
+ }
775
+
776
+ getActiveEpisode(agentId: string, windowMs: number = 3600_000): Episode | null {
777
+ // Find most recent episode that ended within the time window
778
+ const cutoff = new Date(Date.now() - windowMs).toISOString();
779
+ const row = this.db.prepare(`
780
+ SELECT * FROM episodes WHERE agent_id = ? AND end_time > ?
781
+ ORDER BY end_time DESC LIMIT 1
782
+ `).get(agentId, cutoff) as any;
783
+ return row ? this.rowToEpisode(row) : null;
784
+ }
785
+
786
+ addEngramToEpisode(engramId: string, episodeId: string): void {
787
+ this.db.prepare('UPDATE engrams SET episode_id = ? WHERE id = ?').run(episodeId, engramId);
788
+ this.db.prepare(`
789
+ UPDATE episodes SET
790
+ engram_count = engram_count + 1,
791
+ end_time = MAX(end_time, ?)
792
+ WHERE id = ?
793
+ `).run(new Date().toISOString(), episodeId);
794
+ }
795
+
796
+ getEngramsByEpisode(episodeId: string): Engram[] {
797
+ const rows = this.db.prepare(
798
+ 'SELECT * FROM engrams WHERE episode_id = ? AND retracted = 0 ORDER BY created_at ASC'
799
+ ).all(episodeId) as any[];
800
+ return rows.map(r => this.rowToEngram(r));
801
+ }
802
+
803
+ updateEpisodeEmbedding(id: string, embedding: number[]): void {
804
+ const blob = Buffer.from(new Float32Array(embedding).buffer);
805
+ this.db.prepare('UPDATE episodes SET embedding = ? WHERE id = ?').run(blob, id);
806
+ }
807
+
808
+ getEpisodeCount(agentId: string): number {
809
+ const row = this.db.prepare(
810
+ 'SELECT COUNT(*) as cnt FROM episodes WHERE agent_id = ?'
811
+ ).get(agentId) as any;
812
+ return row.cnt;
813
+ }
814
+
815
+ private rowToEpisode(row: any): Episode {
816
+ return {
817
+ id: row.id,
818
+ agentId: row.agent_id,
819
+ label: row.label,
820
+ embedding: row.embedding
821
+ ? Array.from(bufferToFloat32Array(row.embedding))
822
+ : null,
823
+ engramCount: row.engram_count,
824
+ startTime: new Date(row.start_time),
825
+ endTime: new Date(row.end_time),
826
+ createdAt: new Date(row.created_at),
827
+ };
828
+ }
829
+
830
+ /**
831
+ * Find engrams whose tags contain any of the given tag values.
832
+ * Used for entity-bridge retrieval: given entity tags from top results,
833
+ * find other engrams mentioning the same entities.
834
+ */
835
+ findEngramsByTags(agentId: string, tags: string[], excludeIds?: Set<string>): Engram[] {
836
+ if (tags.length === 0) return [];
837
+
838
+ // Build OR conditions for tag matching
839
+ const conditions = tags.map(() => 'tags LIKE ?').join(' OR ');
840
+ const params: any[] = [agentId, ...tags.map(t => `%"${t}"%`)];
841
+
842
+ let sql = `SELECT * FROM engrams WHERE agent_id = ? AND retracted = 0 AND (${conditions})`;
843
+ const rows = this.db.prepare(sql).all(...params) as any[];
844
+
845
+ const results = rows.map(r => this.rowToEngram(r));
846
+ if (excludeIds) {
847
+ return results.filter(e => !excludeIds.has(e.id));
848
+ }
849
+ return results;
850
+ }
851
+
852
+ // --- Checkpointing ---
853
+
854
+ updateAutoCheckpointWrite(agentId: string, engramId: string): void {
855
+ const now = new Date().toISOString();
856
+ this.db.prepare(`
857
+ INSERT INTO conscious_state (agent_id, last_write_id, last_activity_at, write_count_since_consolidation, updated_at)
858
+ VALUES (?, ?, ?, 1, ?)
859
+ ON CONFLICT(agent_id) DO UPDATE SET
860
+ last_write_id = excluded.last_write_id,
861
+ last_activity_at = excluded.last_activity_at,
862
+ write_count_since_consolidation = write_count_since_consolidation + 1,
863
+ updated_at = excluded.updated_at
864
+ `).run(agentId, engramId, now, now);
865
+ }
866
+
867
+ updateAutoCheckpointRecall(agentId: string, context: string, engramIds: string[]): void {
868
+ const now = new Date().toISOString();
869
+ this.db.prepare(`
870
+ INSERT INTO conscious_state (agent_id, last_recall_context, last_recall_ids, last_activity_at, recall_count_since_consolidation, updated_at)
871
+ VALUES (?, ?, ?, ?, 1, ?)
872
+ ON CONFLICT(agent_id) DO UPDATE SET
873
+ last_recall_context = excluded.last_recall_context,
874
+ last_recall_ids = excluded.last_recall_ids,
875
+ last_activity_at = excluded.last_activity_at,
876
+ recall_count_since_consolidation = recall_count_since_consolidation + 1,
877
+ updated_at = excluded.updated_at
878
+ `).run(agentId, context, JSON.stringify(engramIds), now, now);
879
+ }
880
+
881
+ touchActivity(agentId: string): void {
882
+ const now = new Date().toISOString();
883
+ this.db.prepare(`
884
+ INSERT INTO conscious_state (agent_id, last_activity_at, updated_at)
885
+ VALUES (?, ?, ?)
886
+ ON CONFLICT(agent_id) DO UPDATE SET
887
+ last_activity_at = excluded.last_activity_at,
888
+ updated_at = excluded.updated_at
889
+ `).run(agentId, now, now);
890
+ }
891
+
892
+ saveCheckpoint(agentId: string, state: ConsciousState): void {
893
+ const now = new Date().toISOString();
894
+ this.db.prepare(`
895
+ INSERT INTO conscious_state (agent_id, execution_state, checkpoint_at, last_activity_at, updated_at)
896
+ VALUES (?, ?, ?, ?, ?)
897
+ ON CONFLICT(agent_id) DO UPDATE SET
898
+ execution_state = excluded.execution_state,
899
+ checkpoint_at = excluded.checkpoint_at,
900
+ last_activity_at = excluded.last_activity_at,
901
+ updated_at = excluded.updated_at
902
+ `).run(agentId, JSON.stringify(state), now, now, now);
903
+ }
904
+
905
+ getCheckpoint(agentId: string): CheckpointRow | null {
906
+ const row = this.db.prepare('SELECT * FROM conscious_state WHERE agent_id = ?').get(agentId) as any;
907
+ if (!row) return null;
908
+
909
+ return {
910
+ agentId: row.agent_id,
911
+ auto: {
912
+ lastWriteId: row.last_write_id ?? null,
913
+ lastRecallContext: row.last_recall_context ?? null,
914
+ lastRecallIds: JSON.parse(row.last_recall_ids || '[]'),
915
+ lastActivityAt: new Date(row.last_activity_at),
916
+ writeCountSinceConsolidation: row.write_count_since_consolidation,
917
+ recallCountSinceConsolidation: row.recall_count_since_consolidation,
918
+ },
919
+ executionState: row.execution_state ? JSON.parse(row.execution_state) : null,
920
+ checkpointAt: row.checkpoint_at ? new Date(row.checkpoint_at) : null,
921
+ lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
922
+ lastMiniConsolidationAt: row.last_mini_consolidation_at ? new Date(row.last_mini_consolidation_at) : null,
923
+ updatedAt: new Date(row.updated_at),
924
+ };
925
+ }
926
+
927
+ markConsolidation(agentId: string, mini: boolean): void {
928
+ const now = new Date().toISOString();
929
+ if (mini) {
930
+ this.db.prepare(`
931
+ UPDATE conscious_state SET last_mini_consolidation_at = ?, updated_at = ? WHERE agent_id = ?
932
+ `).run(now, now, agentId);
933
+ } else {
934
+ this.db.prepare(`
935
+ UPDATE conscious_state SET
936
+ last_consolidation_at = ?,
937
+ last_mini_consolidation_at = ?,
938
+ write_count_since_consolidation = 0,
939
+ recall_count_since_consolidation = 0,
940
+ consolidation_cycle_count = consolidation_cycle_count + 1,
941
+ updated_at = ?
942
+ WHERE agent_id = ?
943
+ `).run(now, now, now, agentId);
944
+ }
945
+ }
946
+
947
+ getActiveAgents(): Array<{ agentId: string; lastActivityAt: Date; writeCount: number; recallCount: number; lastConsolidationAt: Date | null }> {
948
+ const rows = this.db.prepare('SELECT * FROM conscious_state').all() as any[];
949
+ return rows.map(row => ({
950
+ agentId: row.agent_id,
951
+ lastActivityAt: new Date(row.last_activity_at),
952
+ writeCount: row.write_count_since_consolidation,
953
+ recallCount: row.recall_count_since_consolidation,
954
+ lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
955
+ }));
956
+ }
957
+
958
+ getConsolidationCycleCount(agentId: string): number {
959
+ const row = this.db.prepare(
960
+ 'SELECT consolidation_cycle_count FROM conscious_state WHERE agent_id = ?',
961
+ ).get(agentId) as { consolidation_cycle_count: number } | undefined;
962
+ return row?.consolidation_cycle_count ?? 0;
963
+ }
964
+
965
+ close(): void {
966
+ this.db.close();
967
+ }
968
+ }