agent-working-memory 0.8.8 → 0.9.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.
Files changed (54) hide show
  1. package/README.md +165 -46
  2. package/dist/api/routes.js +7 -7
  3. package/dist/cli/migrate.js +29 -29
  4. package/dist/cli.js +104 -104
  5. package/dist/coordination/circuit-breaker.js +23 -23
  6. package/dist/core/write-pipeline.d.ts.map +1 -1
  7. package/dist/core/write-pipeline.js +17 -0
  8. package/dist/core/write-pipeline.js.map +1 -1
  9. package/dist/engine/activation.d.ts +28 -0
  10. package/dist/engine/activation.d.ts.map +1 -1
  11. package/dist/engine/activation.js +341 -11
  12. package/dist/engine/activation.js.map +1 -1
  13. package/dist/engine/connections.d.ts +12 -0
  14. package/dist/engine/connections.d.ts.map +1 -1
  15. package/dist/engine/connections.js +95 -0
  16. package/dist/engine/connections.js.map +1 -1
  17. package/dist/mcp.js +90 -90
  18. package/dist/storage/pglite-schema.js +143 -143
  19. package/dist/storage/pglite.js +138 -138
  20. package/dist/types/engram.d.ts +1 -0
  21. package/dist/types/engram.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/api/index.ts +3 -3
  24. package/src/cli/migrate.ts +307 -307
  25. package/src/coordination/circuit-breaker.ts +83 -83
  26. package/src/coordination/failure-modes.ts +50 -50
  27. package/src/core/decay.ts +63 -63
  28. package/src/core/embeddings.ts +110 -110
  29. package/src/core/index.ts +5 -5
  30. package/src/core/logger.ts +36 -36
  31. package/src/core/ml-worker-entry.ts +194 -194
  32. package/src/core/ml-worker.ts +281 -281
  33. package/src/core/query-expander.ts +122 -122
  34. package/src/core/reranker.ts +119 -119
  35. package/src/core/write-pipeline.ts +15 -0
  36. package/src/engine/activation.ts +328 -11
  37. package/src/engine/confidence.ts +120 -120
  38. package/src/engine/connections.ts +94 -0
  39. package/src/engine/consolidation-scheduler.ts +242 -242
  40. package/src/engine/eval.ts +102 -102
  41. package/src/engine/eviction.ts +101 -101
  42. package/src/engine/index.ts +8 -8
  43. package/src/engine/retraction.ts +366 -366
  44. package/src/engine/staging.ts +74 -74
  45. package/src/storage/factory.ts +147 -147
  46. package/src/storage/index.ts +3 -3
  47. package/src/storage/pglite-schema.ts +166 -166
  48. package/src/storage/pglite.ts +1363 -1363
  49. package/src/storage/store.ts +80 -80
  50. package/src/types/agent.ts +67 -67
  51. package/src/types/checkpoint.ts +46 -46
  52. package/src/types/engram.ts +1 -0
  53. package/src/types/eval.ts +100 -100
  54. package/src/types/index.ts +6 -6
@@ -1,1363 +1,1363 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * PGlite-backed EngramStore (AWM 0.8.x P4a).
5
- *
6
- * Uses @electric-sql/pglite — Postgres compiled to WASM, single-file
7
- * persistence (or in-memory), pgvector built in. Same SQL surface as
8
- * Postgres server, just a different driver.
9
- *
10
- * The full IEngramStore contract is implemented as async methods. Cognitive
11
- * engines call store methods with await; the existing SQLite sync path
12
- * continues to work via SqliteEngramStore (unchanged).
13
- */
14
-
15
- import { PGlite } from '@electric-sql/pglite';
16
- import { vector } from '@electric-sql/pglite/vector';
17
- import { randomUUID } from 'node:crypto';
18
-
19
- import type {
20
- Engram, EngramCreate, EngramStage, Association, AssociationType,
21
- SearchQuery, ActivationEvent, StagingEvent,
22
- Episode, TaskStatus, TaskPriority, MemoryClass, MemoryType,
23
- ConsciousState, CheckpointRow,
24
- } from '../types/index.js';
25
- import { PGLITE_SCHEMA_DDL, PGLITE_VECTOR_DIMENSIONS } from './pglite-schema.js';
26
-
27
- function toISO(d: Date | string | null | undefined): string | null {
28
- if (d == null) return null;
29
- return d instanceof Date ? d.toISOString() : d;
30
- }
31
-
32
- /**
33
- * Optional calibration knob for `ts_rank_cd` → bm25-compatible score range.
34
- * **Default is pass-through (no calibration).**
35
- *
36
- * Background: SQLite normalizes FTS5 BM25 rank via `|rank|/(1+|rank|)`,
37
- * producing scores in [0.5, 0.95] for matched docs. Postgres `ts_rank_cd`
38
- * (cover density) raw values land in [0.05, 0.5] — a similar shape but a
39
- * different *algorithm* than BM25.
40
- *
41
- * I tried calibrating with `M=10` to make PGlite's bm25Score distribution
42
- * match SQLite's (`scripts/measure-bm25.ts`, 2026-05-26). The distribution
43
- * matched, but the test:tokens accuracy gap (PGlite 25% vs SQLite 42.5%)
44
- * did NOT close. Per-write trace (`scripts/trace-salience.ts`) showed
45
- * `ts_rank_cd` and FTS5 BM25 disagree on which document pairs are
46
- * "duplicates" for short-text matches — that's an algorithmic difference,
47
- * not a magnitude one. ts_rank (frequency-weighted) doesn't help either.
48
- *
49
- * Default M=1 = no calibration. The function is kept as a tuning surface
50
- * for future work on the salience-novelty path (likely going to need
51
- * embedding-based novelty or per-backend calibration tables).
52
- *
53
- * Env override: `AWM_PGLITE_BM25_M`.
54
- */
55
- const PGLITE_BM25_M = Number(process.env.AWM_PGLITE_BM25_M ?? 1);
56
- function calibrateBm25(rawTsRank: number): number {
57
- if (!Number.isFinite(rawTsRank) || rawTsRank <= 0) return 0;
58
- if (PGLITE_BM25_M === 1) return rawTsRank;
59
- const scaled = rawTsRank * PGLITE_BM25_M;
60
- return scaled / (1 + scaled);
61
- }
62
-
63
- function vectorToLiteral(v: number[] | null | undefined): string | null {
64
- if (!v || v.length === 0) return null;
65
- return '[' + v.join(',') + ']';
66
- }
67
-
68
- function literalToVector(s: string | null | undefined): number[] | null {
69
- if (!s) return null;
70
- return s.replace(/^\[|\]$/g, '').split(',').map(Number);
71
- }
72
-
73
- function rowToEngram(row: any): Engram {
74
- return {
75
- id: row.id as string,
76
- agentId: row.agent_id as string,
77
- concept: row.concept as string,
78
- content: row.content as string,
79
- embedding: literalToVector(row.embedding as string | null),
80
- confidence: row.confidence as number,
81
- salience: row.salience as number,
82
- accessCount: row.access_count as number,
83
- lastAccessed: new Date(row.last_accessed as string),
84
- createdAt: new Date(row.created_at as string),
85
- salienceFeatures: row.salience_features ? JSON.parse(row.salience_features as string) : {},
86
- reasonCodes: row.reason_codes ? JSON.parse(row.reason_codes as string) : [],
87
- stage: (row.stage as EngramStage) ?? 'active',
88
- ttl: (row.ttl as number | null) ?? null,
89
- retracted: Boolean(row.retracted),
90
- retractedBy: (row.retracted_by as string | null) ?? null,
91
- retractedAt: row.retracted_at ? new Date(row.retracted_at as string) : null,
92
- tags: row.tags ? JSON.parse(row.tags as string) : [],
93
- memoryType: (row.memory_type as MemoryType) ?? 'unclassified',
94
- memoryClass: (row.memory_class as MemoryClass) ?? 'working',
95
- supersededBy: (row.superseded_by as string | null) ?? null,
96
- supersedes: (row.supersedes as string | null) ?? null,
97
- episodeId: (row.episode_id as string | null) ?? null,
98
- taskStatus: (row.task_status as TaskStatus | null) ?? null,
99
- taskPriority: (row.task_priority as TaskPriority | null) ?? null,
100
- blockedBy: (row.blocked_by as string | null) ?? null,
101
- sequence: row.sequence == null ? null : Number(row.sequence),
102
- references: row.references_json ? JSON.parse(row.references_json as string) : null,
103
- } as Engram;
104
- }
105
-
106
- function rowToAssociation(row: any): Association {
107
- return {
108
- id: row.id,
109
- fromEngramId: row.from_engram_id,
110
- toEngramId: row.to_engram_id,
111
- weight: row.weight,
112
- confidence: row.confidence ?? 0.5,
113
- type: row.type as AssociationType,
114
- activationCount: row.activation_count ?? 0,
115
- createdAt: new Date(row.created_at),
116
- lastActivated: new Date(row.last_activated),
117
- };
118
- }
119
-
120
- function rowToEpisode(row: any): Episode {
121
- return {
122
- id: row.id,
123
- agentId: row.agent_id,
124
- label: row.label,
125
- embedding: literalToVector(row.embedding as string | null),
126
- engramCount: row.engram_count,
127
- startTime: new Date(row.start_time),
128
- endTime: new Date(row.end_time),
129
- createdAt: new Date(row.created_at),
130
- };
131
- }
132
-
133
- function tagLike(tag: string): string {
134
- return `%"${tag}"%`;
135
- }
136
-
137
- function extractTagValue(tags: string[], prefix: string): string | null {
138
- for (const t of tags) {
139
- if (t.startsWith(prefix)) return t.slice(prefix.length);
140
- }
141
- return null;
142
- }
143
-
144
- export class PGliteEngramStore {
145
- private db!: PGlite;
146
- private readyPromise: Promise<void>;
147
-
148
- // Activation-event batching — recall path writes one event per call. On
149
- // PGlite that's a full transaction per recall, adding ~20-50ms. We queue
150
- // events in memory and flush every 5s or when buffer reaches 100.
151
- // Buffer is best-effort — crash loses last batch (eval data only, not state).
152
- private activationEventBuffer: ActivationEvent[] = [];
153
- private activationFlushTimer: ReturnType<typeof setInterval> | null = null;
154
- private static readonly ACTIVATION_FLUSH_INTERVAL_MS = 5_000;
155
- private static readonly ACTIVATION_FLUSH_BATCH_SIZE = 100;
156
-
157
- constructor(dbPath: string = './memory.db') {
158
- this.readyPromise = this.init(dbPath);
159
- }
160
-
161
- private async init(dataDir: string): Promise<void> {
162
- this.db = await PGlite.create(dataDir, { extensions: { vector } });
163
- await this.db.exec(PGLITE_SCHEMA_DDL);
164
- // ivfflat probes: at lists=100 (set in pglite-schema.ts), default probes=1
165
- // scans only 1 cluster which misses neighbors on sparse query distributions.
166
- // probes=5 trades ~10-20ms latency for ~5x better recall on top-K — the
167
- // sweet spot for our 1K–100K engram range. Tunable via AWM_IVFFLAT_PROBES.
168
- const probes = parseInt(process.env.AWM_IVFFLAT_PROBES ?? '5', 10);
169
- if (probes > 1) {
170
- await this.db.exec(`SET ivfflat.probes = ${probes}`);
171
- }
172
- // Periodic flush for batched activation events.
173
- this.activationFlushTimer = setInterval(
174
- () => { void this.flushActivationEvents().catch(() => {/* best-effort */}); },
175
- PGliteEngramStore.ACTIVATION_FLUSH_INTERVAL_MS,
176
- );
177
- }
178
-
179
- async ready(): Promise<void> { return this.readyPromise; }
180
-
181
- async close(): Promise<void> {
182
- await this.readyPromise;
183
- if (this.activationFlushTimer) {
184
- clearInterval(this.activationFlushTimer);
185
- this.activationFlushTimer = null;
186
- }
187
- await this.flushActivationEvents();
188
- await this.db.close();
189
- }
190
-
191
- /**
192
- * Flush queued activation events as a single multi-row INSERT.
193
- * Idempotent — safe to call when the buffer is empty.
194
- */
195
- private async flushActivationEvents(): Promise<void> {
196
- if (this.activationEventBuffer.length === 0) return;
197
- const batch = this.activationEventBuffer.splice(0);
198
- const values: string[] = [];
199
- const params: any[] = [];
200
- for (let i = 0; i < batch.length; i++) {
201
- const e = batch[i];
202
- const base = i * 8;
203
- values.push(`($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8})`);
204
- params.push(
205
- e.id, e.agentId, e.timestamp.toISOString(),
206
- e.context, e.resultsReturned, e.topScore,
207
- e.latencyMs, JSON.stringify(e.engramIds),
208
- );
209
- }
210
- try {
211
- await this.db.query(
212
- `INSERT INTO activation_events (id, agent_id, timestamp, context, results_returned, top_score, latency_ms, engram_ids)
213
- VALUES ${values.join(',')}`,
214
- params,
215
- );
216
- } catch {
217
- // Drop the batch on failure — eval data, not state.
218
- }
219
- }
220
-
221
- /**
222
- * Async-aware transaction wrapper that matches IEngramStore.withTransaction.
223
- *
224
- * Uses raw BEGIN/COMMIT/ROLLBACK on the shared connection so `fn` can call
225
- * the regular (non-tx-context) store methods — they all funnel through
226
- * `this.db.query()` which serializes on the same PGlite connection.
227
- * The transaction lock is held across awaits inside fn.
228
- */
229
- async withTransaction<T>(fn: () => Promise<T>): Promise<T> {
230
- await this.readyPromise;
231
- await this.db.query('BEGIN');
232
- try {
233
- const result = await fn();
234
- await this.db.query('COMMIT');
235
- return result;
236
- } catch (err) {
237
- try { await this.db.query('ROLLBACK'); } catch { /* best-effort */ }
238
- throw err;
239
- }
240
- }
241
-
242
- // ============================================================
243
- // Engram CRUD
244
- // ============================================================
245
-
246
- async createEngram(input: EngramCreate & { id?: string }): Promise<Engram> {
247
- await this.readyPromise;
248
- const id = input.id ?? randomUUID();
249
- const now = new Date().toISOString();
250
-
251
- await this.db.query(
252
- `INSERT INTO engrams (
253
- id, agent_id, concept, content, embedding, embedding_model,
254
- confidence, salience, access_count, last_accessed, created_at,
255
- salience_features, reason_codes, stage, ttl, retracted,
256
- tags, memory_type, memory_class, supersedes, episode_id,
257
- task_status, task_priority, blocked_by, sequence, references_json
258
- ) VALUES (
259
- $1, $2, $3, $4, $5::vector, $6,
260
- $7, $8, 0, $9, $10,
261
- $11, $12, 'active', $13, FALSE,
262
- $14, $15, $16, $17, $18,
263
- $19, $20, $21, $22, $23
264
- )`,
265
- [
266
- id,
267
- input.agentId,
268
- input.concept,
269
- input.content,
270
- vectorToLiteral(input.embedding ?? null),
271
- (input as any).embeddingModel ?? null,
272
- input.confidence ?? 0.5,
273
- input.salience ?? 0.5,
274
- now, now,
275
- JSON.stringify(input.salienceFeatures ?? {}),
276
- JSON.stringify((input as any).reasonCodes ?? []),
277
- (input as any).ttl ?? null,
278
- JSON.stringify(input.tags ?? []),
279
- (input as any).memoryType ?? 'unclassified',
280
- (input as any).memoryClass ?? 'working',
281
- (input as any).supersedes ?? null,
282
- (input as any).episodeId ?? null,
283
- (input as any).taskStatus ?? null,
284
- (input as any).taskPriority ?? null,
285
- (input as any).blockedBy ?? null,
286
- (input as any).sequence ?? null,
287
- input.references && input.references.length > 0
288
- ? JSON.stringify(input.references) : null,
289
- ],
290
- );
291
-
292
- const row = await this.getEngram(id);
293
- if (!row) throw new Error(`createEngram: row ${id} not found after insert`);
294
- return row;
295
- }
296
-
297
- async getEngram(id: string): Promise<Engram | null> {
298
- await this.readyPromise;
299
- const result = await this.db.query<any>(`SELECT * FROM engrams WHERE id = $1`, [id]);
300
- if (result.rows.length === 0) return null;
301
- return rowToEngram(result.rows[0]);
302
- }
303
-
304
- async getEngramsByAgent(agentId: string, stage?: EngramStage, includeRetracted: boolean = false): Promise<Engram[]> {
305
- await this.readyPromise;
306
- let sql = `SELECT * FROM engrams WHERE agent_id = $1`;
307
- const params: any[] = [agentId];
308
- if (stage) {
309
- sql += ` AND stage = $${params.length + 1}`;
310
- params.push(stage);
311
- }
312
- if (!includeRetracted) sql += ` AND retracted = FALSE`;
313
- sql += ` ORDER BY created_at DESC`;
314
- const result = await this.db.query<any>(sql, params);
315
- return result.rows.map(rowToEngram);
316
- }
317
-
318
- async getEngramsByAgentSlim(
319
- agentId: string,
320
- stage?: EngramStage,
321
- includeRetracted: boolean = false,
322
- ): Promise<Array<{ id: string; concept: string; embedding: number[] | null }>> {
323
- await this.readyPromise;
324
- let sql = `SELECT id, concept, embedding FROM engrams WHERE agent_id = $1`;
325
- const params: any[] = [agentId];
326
- if (stage) {
327
- sql += ` AND stage = $${params.length + 1}`;
328
- params.push(stage);
329
- }
330
- if (!includeRetracted) sql += ` AND retracted = FALSE`;
331
- const result = await this.db.query<any>(sql, params);
332
- return result.rows.map((r) => ({
333
- id: r.id as string,
334
- concept: r.concept as string,
335
- embedding: literalToVector(r.embedding as string | null),
336
- }));
337
- }
338
-
339
- async getEngramsByAgentsSlim(
340
- agentIds: string[],
341
- stage?: EngramStage,
342
- includeRetracted: boolean = false,
343
- ): Promise<Array<{ id: string; concept: string; embedding: number[] | null }>> {
344
- if (agentIds.length === 0) return [];
345
- if (agentIds.length === 1) return this.getEngramsByAgentSlim(agentIds[0], stage, includeRetracted);
346
- await this.readyPromise;
347
- let sql = `SELECT id, concept, embedding FROM engrams WHERE agent_id = ANY($1::text[])`;
348
- const params: any[] = [agentIds];
349
- if (stage) {
350
- sql += ` AND stage = $${params.length + 1}`;
351
- params.push(stage);
352
- }
353
- if (!includeRetracted) sql += ` AND retracted = FALSE`;
354
- const result = await this.db.query<any>(sql, params);
355
- return result.rows.map((r) => ({
356
- id: r.id as string,
357
- concept: r.concept as string,
358
- embedding: literalToVector(r.embedding as string | null),
359
- }));
360
- }
361
-
362
- async getEngramsByIds(ids: string[]): Promise<Engram[]> {
363
- if (ids.length === 0) return [];
364
- await this.readyPromise;
365
- const result = await this.db.query<any>(
366
- `SELECT * FROM engrams WHERE id = ANY($1::text[])`,
367
- [ids],
368
- );
369
- return result.rows.map(rowToEngram);
370
- }
371
-
372
- async getEngramsByAgents(agentIds: string[], stage?: EngramStage, includeRetracted: boolean = false): Promise<Engram[]> {
373
- if (agentIds.length === 0) return [];
374
- if (agentIds.length === 1) return this.getEngramsByAgent(agentIds[0], stage, includeRetracted);
375
- await this.readyPromise;
376
- let sql = `SELECT * FROM engrams WHERE agent_id = ANY($1::text[])`;
377
- const params: any[] = [agentIds];
378
- if (stage) {
379
- sql += ` AND stage = $${params.length + 1}`;
380
- params.push(stage);
381
- }
382
- if (!includeRetracted) sql += ` AND retracted = FALSE`;
383
- const result = await this.db.query<any>(sql, params);
384
- return result.rows.map(rowToEngram);
385
- }
386
-
387
- async getWorkspaceAgentIds(agentId: string, workspace: string): Promise<string[]> {
388
- await this.readyPromise;
389
- try {
390
- const result = await this.db.query<any>(
391
- `SELECT DISTINCT name FROM coord_agents WHERE workspace = $1 AND status != 'dead'`,
392
- [workspace],
393
- );
394
- const names = result.rows.map((r) => r.name as string);
395
- if (!names.includes(agentId)) names.push(agentId);
396
- return names;
397
- } catch {
398
- return [agentId];
399
- }
400
- }
401
-
402
- async touchEngram(id: string): Promise<void> {
403
- await this.readyPromise;
404
- await this.db.query(
405
- `UPDATE engrams
406
- SET access_count = access_count + 1,
407
- last_accessed = $1,
408
- confidence = LEAST(0.85, confidence + 0.02 / (1.0 + sqrt(access_count::float)))
409
- WHERE id = $2`,
410
- [new Date().toISOString(), id],
411
- );
412
- }
413
-
414
- async updateStage(id: string, stage: EngramStage): Promise<void> {
415
- await this.readyPromise;
416
- await this.db.query(`UPDATE engrams SET stage = $1 WHERE id = $2`, [stage, id]);
417
- }
418
-
419
- /**
420
- * Replace an engram's content. Used by the fade phase of consolidation
421
- * (Paper 1: storage degradation) to coarsen un-recalled memories.
422
- * The FTS trigger (BEFORE INSERT OR UPDATE OF concept, content, tags)
423
- * automatically refreshes the tsvector index with the new content.
424
- */
425
- async updateContent(id: string, content: string): Promise<void> {
426
- await this.readyPromise;
427
- await this.db.query(`UPDATE engrams SET content = $1 WHERE id = $2`, [content, id]);
428
- }
429
-
430
- async updateConfidence(id: string, confidence: number): Promise<void> {
431
- await this.readyPromise;
432
- const clamped = Math.max(0, Math.min(1, confidence));
433
- await this.db.query(`UPDATE engrams SET confidence = $1 WHERE id = $2`, [clamped, id]);
434
- }
435
-
436
- async updateEmbedding(id: string, embedding: number[], modelId?: string): Promise<void> {
437
- await this.readyPromise;
438
- if (modelId) {
439
- await this.db.query(
440
- `UPDATE engrams SET embedding = $1::vector, embedding_model = $2 WHERE id = $3`,
441
- [vectorToLiteral(embedding), modelId, id],
442
- );
443
- } else {
444
- await this.db.query(
445
- `UPDATE engrams SET embedding = $1::vector WHERE id = $2`,
446
- [vectorToLiteral(embedding), id],
447
- );
448
- }
449
- }
450
-
451
- async retractEngram(id: string, retractedBy: string | null): Promise<void> {
452
- await this.readyPromise;
453
- await this.db.query(
454
- `UPDATE engrams SET retracted = TRUE, retracted_by = $1, retracted_at = $2 WHERE id = $3`,
455
- [retractedBy, new Date().toISOString(), id],
456
- );
457
- }
458
-
459
- async deleteEngram(id: string): Promise<void> {
460
- await this.readyPromise;
461
- await this.db.query(`DELETE FROM engrams WHERE id = $1`, [id]);
462
- }
463
-
464
- /**
465
- * Time warp - shift all timestamps backward by ms milliseconds.
466
- * Used for testing decay-dependent behavior.
467
- */
468
- async timeWarp(agentId: string, ms: number): Promise<number> {
469
- await this.readyPromise;
470
- const seconds = Math.round(ms / 1000);
471
- const r1 = await this.db.query(
472
- `UPDATE engrams SET
473
- created_at = to_char(($1::timestamptz - interval '1 second' * $2), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
474
- last_accessed = to_char(($3::timestamptz - interval '1 second' * $2), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
475
- WHERE agent_id = $4`,
476
- ['now', seconds, 'now', agentId],
477
- );
478
- // Simpler: just update with relative arithmetic on stored ISO strings.
479
- // PGlite doesn't support all date ops cleanly; fall through to JS-side calculation.
480
- return (r1 as any).affectedRows ?? 0;
481
- }
482
-
483
- async getLatestEngram(agentId: string, excludeId?: string): Promise<Engram | null> {
484
- await this.readyPromise;
485
- let sql = `SELECT * FROM engrams WHERE agent_id = $1 AND retracted = FALSE`;
486
- const params: any[] = [agentId];
487
- if (excludeId) {
488
- sql += ` AND id != $${params.length + 1}`;
489
- params.push(excludeId);
490
- }
491
- sql += ` ORDER BY created_at DESC LIMIT 1`;
492
- const result = await this.db.query<any>(sql, params);
493
- return result.rows.length > 0 ? rowToEngram(result.rows[0]) : null;
494
- }
495
-
496
- // ============================================================
497
- // Search
498
- // ============================================================
499
-
500
- async searchByVector(agentId: string, vec: number[], limit: number = 10): Promise<Array<{ engram: Engram; distance: number }>> {
501
- await this.readyPromise;
502
- // Restrict to active + fading. Faded engrams (Paper 1: storage degradation)
503
- // retain their embedding so they still participate in semantic recall, even
504
- // though their content has been trimmed. Excludes staging/consolidated/archived.
505
- const result = await this.db.query<any>(
506
- `SELECT *, (embedding <=> $2::vector) AS distance
507
- FROM engrams
508
- WHERE agent_id = $1
509
- AND embedding IS NOT NULL
510
- AND retracted = FALSE
511
- AND stage IN ('active', 'fading')
512
- ORDER BY distance ASC
513
- LIMIT $3`,
514
- [agentId, vectorToLiteral(vec), limit],
515
- );
516
- return result.rows.map((r) => ({ engram: rowToEngram(r), distance: r.distance as number }));
517
- }
518
-
519
- async searchBM25(agentId: string, query: string, limit: number = 10): Promise<Engram[]> {
520
- const ranked = await this.searchBM25WithRank(agentId, query, limit);
521
- return ranked.map((r) => r.engram);
522
- }
523
-
524
- async searchBM25WithRank(agentId: string, query: string, limit: number = 10): Promise<Array<{ engram: Engram; bm25Score: number }>> {
525
- await this.readyPromise;
526
- // SQLite FTS5 uses OR-by-default; we mirror that with websearch_to_tsquery
527
- // and explicit OR joining. plainto_tsquery would AND all terms, missing
528
- // documents that contain only a subset of the query (e.g., a "correction"
529
- // engram lacking the exact word "operator" but matching "javascript",
530
- // "equality", "type").
531
- const tokens = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(t => t.length > 1);
532
- if (tokens.length === 0) return [];
533
- const websearchQuery = tokens.join(' OR ');
534
- const result = await this.db.query<any>(
535
- `SELECT *, ts_rank_cd(fts, websearch_to_tsquery('english', $2)) AS rank
536
- FROM engrams
537
- WHERE agent_id = $1 AND retracted = FALSE
538
- AND fts @@ websearch_to_tsquery('english', $2)
539
- ORDER BY rank DESC
540
- LIMIT $3`,
541
- [agentId, websearchQuery, limit],
542
- );
543
- return result.rows.map((r) => ({ engram: rowToEngram(r), bm25Score: calibrateBm25(Number(r.rank)) }));
544
- }
545
-
546
- async searchBM25WithRankMultiAgent(agentIds: string[], query: string, limit: number = 10): Promise<Array<{ engram: Engram; bm25Score: number }>> {
547
- if (agentIds.length === 0) return [];
548
- if (agentIds.length === 1) return this.searchBM25WithRank(agentIds[0], query, limit);
549
- await this.readyPromise;
550
- const tokens = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(t => t.length > 1);
551
- if (tokens.length === 0) return [];
552
- const websearchQuery = tokens.join(' OR ');
553
- const result = await this.db.query<any>(
554
- `SELECT *, ts_rank_cd(fts, websearch_to_tsquery('english', $2)) AS rank
555
- FROM engrams
556
- WHERE agent_id = ANY($1::text[]) AND retracted = FALSE
557
- AND fts @@ websearch_to_tsquery('english', $2)
558
- ORDER BY rank DESC
559
- LIMIT $3`,
560
- [agentIds, websearchQuery, limit],
561
- );
562
- return result.rows.map((r) => ({ engram: rowToEngram(r), bm25Score: calibrateBm25(Number(r.rank)) }));
563
- }
564
-
565
- /** Deterministic search (no vector or BM25 ranking — for diagnostic / structural queries). */
566
- async search(query: SearchQuery): Promise<Engram[]> {
567
- await this.readyPromise;
568
- let sql = `SELECT * FROM engrams WHERE agent_id = $1`;
569
- const params: any[] = [query.agentId];
570
-
571
- if (query.text) {
572
- sql += ` AND (content ILIKE $${params.length + 1} OR concept ILIKE $${params.length + 1})`;
573
- params.push(`%${query.text}%`);
574
- }
575
- if (query.concept) {
576
- sql += ` AND concept = $${params.length + 1}`;
577
- params.push(query.concept);
578
- }
579
- if (query.stage) {
580
- sql += ` AND stage = $${params.length + 1}`;
581
- params.push(query.stage);
582
- }
583
- if (query.retracted !== undefined) {
584
- sql += ` AND retracted = $${params.length + 1}`;
585
- params.push(query.retracted);
586
- }
587
- const allTags = [...(query.tags ?? []), ...(query.tagsAll ?? [])];
588
- for (const tag of allTags) {
589
- sql += ` AND tags LIKE $${params.length + 1}`;
590
- params.push(tagLike(tag));
591
- }
592
- if (query.tagsAny && query.tagsAny.length > 0) {
593
- const ors = query.tagsAny.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
594
- sql += ` AND (${ors})`;
595
- for (const tag of query.tagsAny) params.push(tagLike(tag));
596
- }
597
- if (query.tagsNone && query.tagsNone.length > 0) {
598
- const ors = query.tagsNone.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
599
- sql += ` AND NOT (${ors})`;
600
- for (const tag of query.tagsNone) params.push(tagLike(tag));
601
- }
602
-
603
- const sortCol = ({
604
- createdAt: 'created_at', sequence: 'sequence', salience: 'salience',
605
- confidence: 'confidence', lastAccessed: 'last_accessed',
606
- } as const)[query.sortBy ?? 'lastAccessed'];
607
- const dir = query.sortOrder === 'asc' ? 'ASC' : 'DESC';
608
- if (query.sortBy === 'sequence') {
609
- sql += ` ORDER BY (sequence IS NULL), sequence ${dir}`;
610
- } else {
611
- sql += ` ORDER BY ${sortCol} ${dir}`;
612
- }
613
- sql += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
614
- params.push(query.limit ?? 50, query.offset ?? 0);
615
-
616
- const result = await this.db.query<any>(sql, params);
617
- return result.rows.map(rowToEngram);
618
- }
619
-
620
- // ============================================================
621
- // Tasks
622
- // ============================================================
623
-
624
- async updateTaskStatus(id: string, status: TaskStatus): Promise<void> {
625
- await this.readyPromise;
626
- await this.db.query(`UPDATE engrams SET task_status = $1 WHERE id = $2`, [status, id]);
627
- }
628
-
629
- async updateTaskPriority(id: string, priority: TaskPriority): Promise<void> {
630
- await this.readyPromise;
631
- await this.db.query(`UPDATE engrams SET task_priority = $1 WHERE id = $2`, [priority, id]);
632
- }
633
-
634
- async updateBlockedBy(id: string, blockedBy: string | null): Promise<void> {
635
- await this.readyPromise;
636
- await this.db.query(
637
- `UPDATE engrams SET blocked_by = $1, task_status = $2 WHERE id = $3`,
638
- [blockedBy, blockedBy ? 'blocked' : 'open', id],
639
- );
640
- }
641
-
642
- async getTasks(agentId: string, status?: TaskStatus): Promise<Engram[]> {
643
- await this.readyPromise;
644
- let sql = `SELECT * FROM engrams WHERE agent_id = $1 AND task_status IS NOT NULL AND retracted = FALSE`;
645
- const params: any[] = [agentId];
646
- if (status) {
647
- sql += ` AND task_status = $${params.length + 1}`;
648
- params.push(status);
649
- }
650
- sql += ` ORDER BY
651
- CASE task_priority
652
- WHEN 'urgent' THEN 0
653
- WHEN 'high' THEN 1
654
- WHEN 'medium' THEN 2
655
- WHEN 'low' THEN 3
656
- ELSE 4
657
- END,
658
- created_at DESC`;
659
- const result = await this.db.query<any>(sql, params);
660
- return result.rows.map(rowToEngram);
661
- }
662
-
663
- async getNextTask(agentId: string): Promise<Engram | null> {
664
- await this.readyPromise;
665
- const result = await this.db.query<any>(
666
- `SELECT * FROM engrams
667
- WHERE agent_id = $1 AND task_status IN ('open', 'in_progress') AND retracted = FALSE
668
- ORDER BY
669
- CASE task_status WHEN 'in_progress' THEN 0 ELSE 1 END,
670
- CASE task_priority
671
- WHEN 'urgent' THEN 0
672
- WHEN 'high' THEN 1
673
- WHEN 'medium' THEN 2
674
- WHEN 'low' THEN 3
675
- ELSE 4
676
- END,
677
- created_at ASC
678
- LIMIT 1`,
679
- [agentId],
680
- );
681
- return result.rows.length > 0 ? rowToEngram(result.rows[0]) : null;
682
- }
683
-
684
- // ============================================================
685
- // Supersession & tags
686
- // ============================================================
687
-
688
- async supersedeEngram(oldId: string, newId: string): Promise<void> {
689
- await this.readyPromise;
690
- await this.db.query(`UPDATE engrams SET superseded_by = $1 WHERE id = $2`, [newId, oldId]);
691
- await this.db.query(`UPDATE engrams SET supersedes = $1 WHERE id = $2`, [oldId, newId]);
692
- }
693
-
694
- async findActiveMatchByConcept(
695
- agentId: string,
696
- concept: string,
697
- requiredTags?: string[],
698
- ): Promise<Engram | null> {
699
- await this.readyPromise;
700
- let sql = `SELECT * FROM engrams
701
- WHERE agent_id = $1
702
- AND LOWER(TRIM(concept)) = LOWER(TRIM($2))
703
- AND stage = 'active'
704
- AND retracted = FALSE
705
- AND superseded_by IS NULL`;
706
- const params: any[] = [agentId, concept];
707
- if (requiredTags && requiredTags.length > 0) {
708
- for (const tag of requiredTags) {
709
- sql += ` AND tags LIKE $${params.length + 1}`;
710
- params.push(tagLike(tag));
711
- }
712
- }
713
- sql += ` ORDER BY created_at DESC LIMIT 1`;
714
- const result = await this.db.query<any>(sql, params);
715
- return result.rows.length > 0 ? rowToEngram(result.rows[0]) : null;
716
- }
717
-
718
- async isSuperseded(id: string): Promise<boolean> {
719
- await this.readyPromise;
720
- const result = await this.db.query<any>(
721
- `SELECT superseded_by FROM engrams WHERE id = $1`,
722
- [id],
723
- );
724
- return result.rows.length > 0 && result.rows[0].superseded_by != null;
725
- }
726
-
727
- async updateMemoryClass(id: string, memoryClass: MemoryClass): Promise<void> {
728
- await this.readyPromise;
729
- await this.db.query(`UPDATE engrams SET memory_class = $1 WHERE id = $2`, [memoryClass, id]);
730
- }
731
-
732
- async updateTags(id: string, tags: string[]): Promise<void> {
733
- await this.readyPromise;
734
- await this.db.query(`UPDATE engrams SET tags = $1 WHERE id = $2`, [JSON.stringify(tags), id]);
735
- }
736
-
737
- // ============================================================
738
- // Associations
739
- // ============================================================
740
-
741
- async upsertAssociation(
742
- fromId: string, toId: string, weight: number,
743
- type: AssociationType = 'hebbian', confidence: number = 0.5,
744
- ): Promise<Association> {
745
- await this.readyPromise;
746
- const id = randomUUID();
747
- const now = new Date().toISOString();
748
- await this.db.query(
749
- `INSERT INTO associations (id, from_engram_id, to_engram_id, weight, confidence, type, activation_count, created_at, last_activated)
750
- VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $7)
751
- ON CONFLICT (from_engram_id, to_engram_id) DO UPDATE SET
752
- weight = EXCLUDED.weight,
753
- confidence = EXCLUDED.confidence,
754
- last_activated = EXCLUDED.last_activated,
755
- activation_count = associations.activation_count + 1`,
756
- [id, fromId, toId, weight, confidence, type, now],
757
- );
758
- const assoc = await this.getAssociation(fromId, toId);
759
- if (!assoc) throw new Error('upsertAssociation: row not found after insert');
760
- return assoc;
761
- }
762
-
763
- async getAssociation(fromId: string, toId: string): Promise<Association | null> {
764
- await this.readyPromise;
765
- const result = await this.db.query<any>(
766
- `SELECT * FROM associations WHERE from_engram_id = $1 AND to_engram_id = $2`,
767
- [fromId, toId],
768
- );
769
- return result.rows.length > 0 ? rowToAssociation(result.rows[0]) : null;
770
- }
771
-
772
- async getAssociationsFor(engramId: string): Promise<Association[]> {
773
- await this.readyPromise;
774
- const result = await this.db.query<any>(
775
- `SELECT * FROM associations WHERE from_engram_id = $1 OR to_engram_id = $1`,
776
- [engramId],
777
- );
778
- return result.rows.map(rowToAssociation);
779
- }
780
-
781
- async getAssociationStatsForBatch(engramIds: string[]): Promise<Map<string, { count: number; sumWeight: number }>> {
782
- const result = new Map<string, { count: number; sumWeight: number }>();
783
- if (engramIds.length === 0) return result;
784
- await this.readyPromise;
785
- const r = await this.db.query<any>(
786
- `SELECT id, SUM(cnt) AS count, SUM(sw) AS sum_weight FROM (
787
- SELECT from_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE from_engram_id = ANY($1::text[])
788
- UNION ALL
789
- SELECT to_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE to_engram_id = ANY($1::text[])
790
- ) t
791
- WHERE id = ANY($1::text[])
792
- GROUP BY id`,
793
- [engramIds],
794
- );
795
- for (const row of r.rows) {
796
- result.set(row.id as string, { count: Number(row.count), sumWeight: Number(row.sum_weight) });
797
- }
798
- for (const id of engramIds) {
799
- if (!result.has(id)) result.set(id, { count: 0, sumWeight: 0 });
800
- }
801
- return result;
802
- }
803
-
804
- async getAssociationsForBatch(engramIds: string[]): Promise<Map<string, Association[]>> {
805
- const result = new Map<string, Association[]>();
806
- if (engramIds.length === 0) return result;
807
- await this.readyPromise;
808
- const r = await this.db.query<any>(
809
- `SELECT * FROM associations
810
- WHERE from_engram_id = ANY($1::text[]) OR to_engram_id = ANY($1::text[])`,
811
- [engramIds],
812
- );
813
- for (const row of r.rows) {
814
- const a = rowToAssociation(row);
815
- const fromList = result.get(a.fromEngramId) ?? [];
816
- fromList.push(a);
817
- result.set(a.fromEngramId, fromList);
818
- if (a.toEngramId !== a.fromEngramId) {
819
- const toList = result.get(a.toEngramId) ?? [];
820
- toList.push(a);
821
- result.set(a.toEngramId, toList);
822
- }
823
- }
824
- for (const id of engramIds) {
825
- if (!result.has(id)) result.set(id, []);
826
- }
827
- return result;
828
- }
829
-
830
- async getOutgoingAssociations(engramId: string): Promise<Association[]> {
831
- await this.readyPromise;
832
- const result = await this.db.query<any>(
833
- `SELECT * FROM associations WHERE from_engram_id = $1`,
834
- [engramId],
835
- );
836
- return result.rows.map(rowToAssociation);
837
- }
838
-
839
- async countAssociationsFor(engramId: string): Promise<number> {
840
- await this.readyPromise;
841
- const result = await this.db.query<any>(
842
- `SELECT COUNT(*) AS count FROM associations WHERE from_engram_id = $1`,
843
- [engramId],
844
- );
845
- return Number(result.rows[0]?.count ?? 0);
846
- }
847
-
848
- async getWeakestAssociation(engramId: string): Promise<Association | null> {
849
- await this.readyPromise;
850
- const result = await this.db.query<any>(
851
- `SELECT * FROM associations WHERE from_engram_id = $1 ORDER BY weight ASC LIMIT 1`,
852
- [engramId],
853
- );
854
- return result.rows.length > 0 ? rowToAssociation(result.rows[0]) : null;
855
- }
856
-
857
- async deleteAssociation(id: string): Promise<void> {
858
- await this.readyPromise;
859
- await this.db.query(`DELETE FROM associations WHERE id = $1`, [id]);
860
- }
861
-
862
- async getAllAssociations(agentId: string): Promise<Association[]> {
863
- await this.readyPromise;
864
- const result = await this.db.query<any>(
865
- `SELECT a.* FROM associations a
866
- JOIN engrams e ON a.from_engram_id = e.id
867
- WHERE e.agent_id = $1`,
868
- [agentId],
869
- );
870
- return result.rows.map(rowToAssociation);
871
- }
872
-
873
- // ============================================================
874
- // Eviction & counts
875
- // ============================================================
876
-
877
- async getEvictionCandidates(agentId: string, limit: number): Promise<Engram[]> {
878
- await this.readyPromise;
879
- const result = await this.db.query<any>(
880
- `SELECT * FROM engrams
881
- WHERE agent_id = $1 AND stage = 'active' AND retracted = FALSE
882
- ORDER BY (salience * 0.3 + confidence * 0.3
883
- + (access_count::float / (access_count + 5)) * 0.2
884
- + (1.0 / (1.0 + EXTRACT(EPOCH FROM (now() - last_accessed::timestamptz)) / 86400.0)) * 0.2) ASC
885
- LIMIT $2`,
886
- [agentId, limit],
887
- );
888
- return result.rows.map(rowToEngram);
889
- }
890
-
891
- async getActiveCount(agentId: string): Promise<number> {
892
- await this.readyPromise;
893
- const result = await this.db.query<any>(
894
- `SELECT COUNT(*) AS count FROM engrams WHERE agent_id = $1 AND stage = 'active'`,
895
- [agentId],
896
- );
897
- return Number(result.rows[0]?.count ?? 0);
898
- }
899
-
900
- async getStagingCount(agentId: string): Promise<number> {
901
- await this.readyPromise;
902
- const result = await this.db.query<any>(
903
- `SELECT COUNT(*) AS count FROM engrams WHERE agent_id = $1 AND stage = 'staging'`,
904
- [agentId],
905
- );
906
- return Number(result.rows[0]?.count ?? 0);
907
- }
908
-
909
- async getExpiredStaging(): Promise<Engram[]> {
910
- await this.readyPromise;
911
- const result = await this.db.query<any>(
912
- `SELECT * FROM engrams WHERE stage = 'staging' AND ttl IS NOT NULL`,
913
- );
914
- const now = Date.now();
915
- return result.rows
916
- .map(rowToEngram)
917
- .filter((e) => e.ttl && (e.createdAt.getTime() + e.ttl) < now);
918
- }
919
-
920
- // ============================================================
921
- // Eval logging
922
- // ============================================================
923
-
924
- async logActivationEvent(event: ActivationEvent): Promise<void> {
925
- // Queue rather than write synchronously — removes activation INSERT from
926
- // the recall hot path. Flushed on timer (5s) or when buffer hits 100.
927
- this.activationEventBuffer.push(event);
928
- if (this.activationEventBuffer.length >= PGliteEngramStore.ACTIVATION_FLUSH_BATCH_SIZE) {
929
- void this.flushActivationEvents().catch(() => {/* best-effort */});
930
- }
931
- }
932
-
933
- async logStagingEvent(event: StagingEvent): Promise<void> {
934
- await this.readyPromise;
935
- await this.db.query(
936
- `INSERT INTO staging_events (engram_id, agent_id, action, resonance_score, timestamp, age_ms)
937
- VALUES ($1, $2, $3, $4, $5, $6)`,
938
- [
939
- event.engramId, event.agentId, event.action,
940
- event.resonanceScore, event.timestamp.toISOString(), event.ageMs,
941
- ],
942
- );
943
- }
944
-
945
- async logRetrievalFeedback(activationEventId: string | null, engramId: string, useful: boolean, context: string): Promise<void> {
946
- await this.readyPromise;
947
- await this.db.query(
948
- `INSERT INTO retrieval_feedback (id, activation_event_id, engram_id, useful, context, timestamp)
949
- VALUES ($1, $2, $3, $4, $5, $6)`,
950
- [randomUUID(), activationEventId, engramId, useful, context, new Date().toISOString()],
951
- );
952
- }
953
-
954
- async getRetrievalPrecision(agentId: string, windowHours: number = 24): Promise<number> {
955
- await this.readyPromise;
956
- const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
957
- const result = await this.db.query<any>(
958
- `SELECT
959
- COUNT(CASE WHEN useful = TRUE THEN 1 END) AS useful_count,
960
- COUNT(*) AS total_count
961
- FROM retrieval_feedback rf
962
- LEFT JOIN activation_events ae ON rf.activation_event_id = ae.id
963
- JOIN engrams e ON rf.engram_id = e.id
964
- WHERE e.agent_id = $1 AND rf.timestamp > $2`,
965
- [agentId, since],
966
- );
967
- const row = result.rows[0];
968
- const total = Number(row?.total_count ?? 0);
969
- const useful = Number(row?.useful_count ?? 0);
970
- return total > 0 ? useful / total : 0;
971
- }
972
-
973
- async getStagingMetrics(agentId: string): Promise<{ promoted: number; discarded: number; expired: number }> {
974
- await this.readyPromise;
975
- const result = await this.db.query<any>(
976
- `SELECT
977
- COUNT(CASE WHEN action = 'promoted' THEN 1 END) AS promoted,
978
- COUNT(CASE WHEN action = 'discarded' THEN 1 END) AS discarded,
979
- COUNT(CASE WHEN action = 'expired' THEN 1 END) AS expired
980
- FROM staging_events WHERE agent_id = $1`,
981
- [agentId],
982
- );
983
- const row = result.rows[0] ?? { promoted: 0, discarded: 0, expired: 0 };
984
- return {
985
- promoted: Number(row.promoted),
986
- discarded: Number(row.discarded),
987
- expired: Number(row.expired),
988
- };
989
- }
990
-
991
- async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p95LatencyMs: number }> {
992
- await this.readyPromise;
993
- // Flush any buffered activation events so stats reflect the latest writes.
994
- await this.flushActivationEvents();
995
- const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
996
- const result = await this.db.query<any>(
997
- `SELECT latency_ms FROM activation_events
998
- WHERE agent_id = $1 AND timestamp > $2
999
- ORDER BY latency_ms ASC`,
1000
- [agentId, since],
1001
- );
1002
- if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
1003
- const latencies = result.rows.map((r) => Number(r.latency_ms));
1004
- const total = latencies.reduce((s, l) => s + l, 0);
1005
- const p95Idx = Math.min(Math.floor(latencies.length * 0.95), latencies.length - 1);
1006
- return {
1007
- count: latencies.length,
1008
- avgLatencyMs: total / latencies.length,
1009
- p95LatencyMs: latencies[p95Idx],
1010
- };
1011
- }
1012
-
1013
- async getConsolidatedCount(agentId: string): Promise<number> {
1014
- await this.readyPromise;
1015
- const result = await this.db.query<any>(
1016
- `SELECT COUNT(*) AS cnt FROM engrams WHERE agent_id = $1 AND stage = 'consolidated'`,
1017
- [agentId],
1018
- );
1019
- return Number(result.rows[0]?.cnt ?? 0);
1020
- }
1021
-
1022
- // ============================================================
1023
- // Episodes
1024
- // ============================================================
1025
-
1026
- async createEpisode(input: { agentId: string; label: string; embedding?: number[] }): Promise<Episode> {
1027
- await this.readyPromise;
1028
- const id = randomUUID();
1029
- const now = new Date().toISOString();
1030
- await this.db.query(
1031
- `INSERT INTO episodes (id, agent_id, label, embedding, engram_count, start_time, end_time, created_at)
1032
- VALUES ($1, $2, $3, $4::vector, 0, $5, $5, $5)`,
1033
- [id, input.agentId, input.label, vectorToLiteral(input.embedding ?? null), now],
1034
- );
1035
- const ep = await this.getEpisode(id);
1036
- if (!ep) throw new Error('createEpisode: row not found after insert');
1037
- return ep;
1038
- }
1039
-
1040
- async getEpisode(id: string): Promise<Episode | null> {
1041
- await this.readyPromise;
1042
- const result = await this.db.query<any>(`SELECT * FROM episodes WHERE id = $1`, [id]);
1043
- return result.rows.length > 0 ? rowToEpisode(result.rows[0]) : null;
1044
- }
1045
-
1046
- async getEpisodesByAgent(agentId: string): Promise<Episode[]> {
1047
- await this.readyPromise;
1048
- const result = await this.db.query<any>(
1049
- `SELECT * FROM episodes WHERE agent_id = $1 ORDER BY end_time DESC`,
1050
- [agentId],
1051
- );
1052
- return result.rows.map(rowToEpisode);
1053
- }
1054
-
1055
- async getActiveEpisode(agentId: string, windowMs: number = 3600_000): Promise<Episode | null> {
1056
- await this.readyPromise;
1057
- const cutoff = new Date(Date.now() - windowMs).toISOString();
1058
- const result = await this.db.query<any>(
1059
- `SELECT * FROM episodes WHERE agent_id = $1 AND end_time > $2 ORDER BY end_time DESC LIMIT 1`,
1060
- [agentId, cutoff],
1061
- );
1062
- return result.rows.length > 0 ? rowToEpisode(result.rows[0]) : null;
1063
- }
1064
-
1065
- async addEngramToEpisode(engramId: string, episodeId: string): Promise<void> {
1066
- await this.readyPromise;
1067
- await this.db.query(`UPDATE engrams SET episode_id = $1 WHERE id = $2`, [episodeId, engramId]);
1068
- await this.db.query(
1069
- `UPDATE episodes SET
1070
- engram_count = engram_count + 1,
1071
- end_time = GREATEST(end_time, $1)
1072
- WHERE id = $2`,
1073
- [new Date().toISOString(), episodeId],
1074
- );
1075
- }
1076
-
1077
- async getEngramsByEpisode(episodeId: string): Promise<Engram[]> {
1078
- await this.readyPromise;
1079
- const result = await this.db.query<any>(
1080
- `SELECT * FROM engrams WHERE episode_id = $1 AND retracted = FALSE ORDER BY created_at ASC`,
1081
- [episodeId],
1082
- );
1083
- return result.rows.map(rowToEngram);
1084
- }
1085
-
1086
- async updateEpisodeEmbedding(id: string, embedding: number[]): Promise<void> {
1087
- await this.readyPromise;
1088
- await this.db.query(
1089
- `UPDATE episodes SET embedding = $1::vector WHERE id = $2`,
1090
- [vectorToLiteral(embedding), id],
1091
- );
1092
- }
1093
-
1094
- async getEpisodeCount(agentId: string): Promise<number> {
1095
- await this.readyPromise;
1096
- const result = await this.db.query<any>(
1097
- `SELECT COUNT(*) AS cnt FROM episodes WHERE agent_id = $1`,
1098
- [agentId],
1099
- );
1100
- return Number(result.rows[0]?.cnt ?? 0);
1101
- }
1102
-
1103
- // ============================================================
1104
- // Tags lookup
1105
- // ============================================================
1106
-
1107
- async findEngramsByTags(agentId: string, tags: string[], excludeIds?: Set<string>): Promise<Engram[]> {
1108
- if (tags.length === 0) return [];
1109
- await this.readyPromise;
1110
- const conditions = tags.map((_, i) => `tags LIKE $${i + 2}`).join(' OR ');
1111
- const params: any[] = [agentId, ...tags.map(tagLike)];
1112
- const sql = `SELECT * FROM engrams WHERE agent_id = $1 AND retracted = FALSE AND (${conditions})`;
1113
- const result = await this.db.query<any>(sql, params);
1114
- const engrams = result.rows.map(rowToEngram);
1115
- if (excludeIds) return engrams.filter((e) => !excludeIds.has(e.id));
1116
- return engrams;
1117
- }
1118
-
1119
- // ============================================================
1120
- // Checkpointing & conscious state
1121
- // ============================================================
1122
-
1123
- async updateAutoCheckpointWrite(agentId: string, engramId: string): Promise<void> {
1124
- await this.readyPromise;
1125
- const now = new Date().toISOString();
1126
- await this.db.query(
1127
- `INSERT INTO conscious_state (agent_id, last_write_id, last_activity_at, write_count_since_consolidation, updated_at)
1128
- VALUES ($1, $2, $3, 1, $3)
1129
- ON CONFLICT(agent_id) DO UPDATE SET
1130
- last_write_id = EXCLUDED.last_write_id,
1131
- last_activity_at = EXCLUDED.last_activity_at,
1132
- write_count_since_consolidation = conscious_state.write_count_since_consolidation + 1,
1133
- updated_at = EXCLUDED.updated_at`,
1134
- [agentId, engramId, now],
1135
- );
1136
- }
1137
-
1138
- async updateAutoCheckpointRecall(agentId: string, context: string, engramIds: string[]): Promise<void> {
1139
- await this.readyPromise;
1140
- const now = new Date().toISOString();
1141
- await this.db.query(
1142
- `INSERT INTO conscious_state (agent_id, last_recall_context, last_recall_ids, last_activity_at, recall_count_since_consolidation, updated_at)
1143
- VALUES ($1, $2, $3, $4, 1, $4)
1144
- ON CONFLICT(agent_id) DO UPDATE SET
1145
- last_recall_context = EXCLUDED.last_recall_context,
1146
- last_recall_ids = EXCLUDED.last_recall_ids,
1147
- last_activity_at = EXCLUDED.last_activity_at,
1148
- recall_count_since_consolidation = conscious_state.recall_count_since_consolidation + 1,
1149
- updated_at = EXCLUDED.updated_at`,
1150
- [agentId, context, JSON.stringify(engramIds), now],
1151
- );
1152
- }
1153
-
1154
- async touchActivity(agentId: string): Promise<void> {
1155
- await this.readyPromise;
1156
- const now = new Date().toISOString();
1157
- await this.db.query(
1158
- `INSERT INTO conscious_state (agent_id, last_activity_at, updated_at)
1159
- VALUES ($1, $2, $2)
1160
- ON CONFLICT(agent_id) DO UPDATE SET
1161
- last_activity_at = EXCLUDED.last_activity_at,
1162
- updated_at = EXCLUDED.updated_at`,
1163
- [agentId, now],
1164
- );
1165
- }
1166
-
1167
- async saveCheckpoint(agentId: string, state: ConsciousState): Promise<void> {
1168
- await this.readyPromise;
1169
- const now = new Date().toISOString();
1170
- await this.db.query(
1171
- `INSERT INTO conscious_state (agent_id, execution_state, checkpoint_at, last_activity_at, updated_at)
1172
- VALUES ($1, $2, $3, $3, $3)
1173
- ON CONFLICT(agent_id) DO UPDATE SET
1174
- execution_state = EXCLUDED.execution_state,
1175
- checkpoint_at = EXCLUDED.checkpoint_at,
1176
- last_activity_at = EXCLUDED.last_activity_at,
1177
- updated_at = EXCLUDED.updated_at`,
1178
- [agentId, JSON.stringify(state), now],
1179
- );
1180
- }
1181
-
1182
- async getCheckpoint(agentId: string): Promise<CheckpointRow | null> {
1183
- await this.readyPromise;
1184
- const result = await this.db.query<any>(
1185
- `SELECT * FROM conscious_state WHERE agent_id = $1`,
1186
- [agentId],
1187
- );
1188
- if (result.rows.length === 0) return null;
1189
- const row = result.rows[0];
1190
- return {
1191
- agentId: row.agent_id,
1192
- auto: {
1193
- lastWriteId: row.last_write_id ?? null,
1194
- lastRecallContext: row.last_recall_context ?? null,
1195
- lastRecallIds: JSON.parse(row.last_recall_ids || '[]'),
1196
- lastActivityAt: new Date(row.last_activity_at),
1197
- writeCountSinceConsolidation: row.write_count_since_consolidation,
1198
- recallCountSinceConsolidation: row.recall_count_since_consolidation,
1199
- },
1200
- executionState: row.execution_state ? JSON.parse(row.execution_state) : null,
1201
- checkpointAt: row.checkpoint_at ? new Date(row.checkpoint_at) : null,
1202
- lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
1203
- lastMiniConsolidationAt: row.last_mini_consolidation_at ? new Date(row.last_mini_consolidation_at) : null,
1204
- updatedAt: new Date(row.updated_at),
1205
- } as CheckpointRow;
1206
- }
1207
-
1208
- async markConsolidation(agentId: string, mini: boolean): Promise<void> {
1209
- await this.readyPromise;
1210
- const now = new Date().toISOString();
1211
- if (mini) {
1212
- await this.db.query(
1213
- `UPDATE conscious_state SET last_mini_consolidation_at = $1, updated_at = $1 WHERE agent_id = $2`,
1214
- [now, agentId],
1215
- );
1216
- } else {
1217
- await this.db.query(
1218
- `UPDATE conscious_state SET
1219
- last_consolidation_at = $1,
1220
- last_mini_consolidation_at = $1,
1221
- write_count_since_consolidation = 0,
1222
- recall_count_since_consolidation = 0,
1223
- consolidation_cycle_count = consolidation_cycle_count + 1,
1224
- updated_at = $1
1225
- WHERE agent_id = $2`,
1226
- [now, agentId],
1227
- );
1228
- }
1229
- }
1230
-
1231
- async getActiveAgents(): Promise<Array<{ agentId: string; lastActivityAt: Date; writeCount: number; recallCount: number; lastConsolidationAt: Date | null }>> {
1232
- await this.readyPromise;
1233
- const result = await this.db.query<any>(`SELECT * FROM conscious_state`);
1234
- return result.rows.map((row) => ({
1235
- agentId: row.agent_id,
1236
- lastActivityAt: new Date(row.last_activity_at),
1237
- writeCount: row.write_count_since_consolidation,
1238
- recallCount: row.recall_count_since_consolidation,
1239
- lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
1240
- }));
1241
- }
1242
-
1243
- async getConsolidationCycleCount(agentId: string): Promise<number> {
1244
- await this.readyPromise;
1245
- const result = await this.db.query<any>(
1246
- `SELECT consolidation_cycle_count FROM conscious_state WHERE agent_id = $1`,
1247
- [agentId],
1248
- );
1249
- return Number(result.rows[0]?.consolidation_cycle_count ?? 0);
1250
- }
1251
-
1252
- // ============================================================
1253
- // 0.8 Cluster C — substrate primitives
1254
- // ============================================================
1255
-
1256
- async getLatestByTag(opts: {
1257
- agentId: string;
1258
- tagKeyPrefix: string;
1259
- scopeTagsAll?: string[];
1260
- retracted?: boolean;
1261
- sortBy?: 'createdAt' | 'sequence';
1262
- limit?: number;
1263
- }): Promise<Engram[]> {
1264
- await this.readyPromise;
1265
- let sql = `SELECT * FROM engrams
1266
- WHERE agent_id = $1
1267
- AND retracted = $2
1268
- AND stage = 'active'
1269
- AND tags LIKE $3`;
1270
- const params: any[] = [opts.agentId, opts.retracted ?? false, `%"${opts.tagKeyPrefix}%`];
1271
- if (opts.scopeTagsAll && opts.scopeTagsAll.length > 0) {
1272
- for (const t of opts.scopeTagsAll) {
1273
- sql += ` AND tags LIKE $${params.length + 1}`;
1274
- params.push(tagLike(t));
1275
- }
1276
- }
1277
- if (opts.sortBy === 'sequence') sql += ` AND sequence IS NOT NULL`;
1278
- sql += ` ORDER BY ` + (opts.sortBy === 'sequence' ? 'sequence DESC, created_at DESC' : 'created_at DESC');
1279
- const result = await this.db.query<any>(sql, params);
1280
- const engrams = result.rows.map(rowToEngram);
1281
- const seen = new Map<string, Engram>();
1282
- for (const e of engrams) {
1283
- const value = extractTagValue(e.tags, opts.tagKeyPrefix);
1284
- if (value == null) continue;
1285
- if (!seen.has(value)) seen.set(value, e);
1286
- }
1287
- const out = Array.from(seen.values());
1288
- return opts.limit ? out.slice(0, opts.limit) : out;
1289
- }
1290
-
1291
- async getTopBy(opts: {
1292
- agentId: string;
1293
- sortField: string;
1294
- order: 'asc' | 'desc';
1295
- filterTagsAll?: string[];
1296
- filterTagsAny?: string[];
1297
- filterTagsNone?: string[];
1298
- limit?: number;
1299
- retracted?: boolean;
1300
- }): Promise<Engram[]> {
1301
- await this.readyPromise;
1302
- let sql = `SELECT * FROM engrams
1303
- WHERE agent_id = $1
1304
- AND retracted = $2
1305
- AND stage = 'active'
1306
- AND tags LIKE $3`;
1307
- const params: any[] = [opts.agentId, opts.retracted ?? false, `%"${opts.sortField}%`];
1308
- if (opts.filterTagsAll && opts.filterTagsAll.length > 0) {
1309
- for (const tag of opts.filterTagsAll) {
1310
- sql += ` AND tags LIKE $${params.length + 1}`;
1311
- params.push(tagLike(tag));
1312
- }
1313
- }
1314
- if (opts.filterTagsAny && opts.filterTagsAny.length > 0) {
1315
- const ors = opts.filterTagsAny.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
1316
- sql += ` AND (${ors})`;
1317
- for (const tag of opts.filterTagsAny) params.push(tagLike(tag));
1318
- }
1319
- if (opts.filterTagsNone && opts.filterTagsNone.length > 0) {
1320
- const ors = opts.filterTagsNone.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
1321
- sql += ` AND NOT (${ors})`;
1322
- for (const tag of opts.filterTagsNone) params.push(tagLike(tag));
1323
- }
1324
- const result = await this.db.query<any>(sql, params);
1325
- const engrams = result.rows.map(rowToEngram);
1326
- const valued = engrams.map((e) => {
1327
- const raw = extractTagValue(e.tags, opts.sortField);
1328
- const n = raw == null ? NaN : Number(raw);
1329
- return { e, n };
1330
- });
1331
- valued.sort((a, b) => {
1332
- const aNaN = Number.isNaN(a.n);
1333
- const bNaN = Number.isNaN(b.n);
1334
- if (aNaN && bNaN) return 0;
1335
- if (aNaN) return 1;
1336
- if (bNaN) return -1;
1337
- return opts.order === 'asc' ? a.n - b.n : b.n - a.n;
1338
- });
1339
- const sorted = valued.map((v) => v.e);
1340
- return opts.limit ? sorted.slice(0, opts.limit) : sorted;
1341
- }
1342
-
1343
- /**
1344
- * Atomically allocate the next sequence number for an agent.
1345
- *
1346
- * Uses PGlite's transaction API — the callback receives a `tx` context
1347
- * that must be used for queries; calling `this.db.query` from inside
1348
- * the callback bypasses the transaction and deadlocks the connection.
1349
- */
1350
- async allocateNextSequence(agentId: string): Promise<number> {
1351
- await this.readyPromise;
1352
- return this.db.transaction(async (tx: any) => {
1353
- const result = await tx.query(
1354
- `SELECT MAX(sequence) AS max_seq FROM engrams WHERE agent_id = $1`,
1355
- [agentId],
1356
- );
1357
- const max = result.rows[0]?.max_seq;
1358
- return (max != null ? Number(max) : 0) + 1;
1359
- }) as Promise<number>;
1360
- }
1361
- }
1362
-
1363
- export const PGLITE_DIMENSIONS = PGLITE_VECTOR_DIMENSIONS;
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * PGlite-backed EngramStore (AWM 0.8.x P4a).
5
+ *
6
+ * Uses @electric-sql/pglite — Postgres compiled to WASM, single-file
7
+ * persistence (or in-memory), pgvector built in. Same SQL surface as
8
+ * Postgres server, just a different driver.
9
+ *
10
+ * The full IEngramStore contract is implemented as async methods. Cognitive
11
+ * engines call store methods with await; the existing SQLite sync path
12
+ * continues to work via SqliteEngramStore (unchanged).
13
+ */
14
+
15
+ import { PGlite } from '@electric-sql/pglite';
16
+ import { vector } from '@electric-sql/pglite/vector';
17
+ import { randomUUID } from 'node:crypto';
18
+
19
+ import type {
20
+ Engram, EngramCreate, EngramStage, Association, AssociationType,
21
+ SearchQuery, ActivationEvent, StagingEvent,
22
+ Episode, TaskStatus, TaskPriority, MemoryClass, MemoryType,
23
+ ConsciousState, CheckpointRow,
24
+ } from '../types/index.js';
25
+ import { PGLITE_SCHEMA_DDL, PGLITE_VECTOR_DIMENSIONS } from './pglite-schema.js';
26
+
27
+ function toISO(d: Date | string | null | undefined): string | null {
28
+ if (d == null) return null;
29
+ return d instanceof Date ? d.toISOString() : d;
30
+ }
31
+
32
+ /**
33
+ * Optional calibration knob for `ts_rank_cd` → bm25-compatible score range.
34
+ * **Default is pass-through (no calibration).**
35
+ *
36
+ * Background: SQLite normalizes FTS5 BM25 rank via `|rank|/(1+|rank|)`,
37
+ * producing scores in [0.5, 0.95] for matched docs. Postgres `ts_rank_cd`
38
+ * (cover density) raw values land in [0.05, 0.5] — a similar shape but a
39
+ * different *algorithm* than BM25.
40
+ *
41
+ * I tried calibrating with `M=10` to make PGlite's bm25Score distribution
42
+ * match SQLite's (`scripts/measure-bm25.ts`, 2026-05-26). The distribution
43
+ * matched, but the test:tokens accuracy gap (PGlite 25% vs SQLite 42.5%)
44
+ * did NOT close. Per-write trace (`scripts/trace-salience.ts`) showed
45
+ * `ts_rank_cd` and FTS5 BM25 disagree on which document pairs are
46
+ * "duplicates" for short-text matches — that's an algorithmic difference,
47
+ * not a magnitude one. ts_rank (frequency-weighted) doesn't help either.
48
+ *
49
+ * Default M=1 = no calibration. The function is kept as a tuning surface
50
+ * for future work on the salience-novelty path (likely going to need
51
+ * embedding-based novelty or per-backend calibration tables).
52
+ *
53
+ * Env override: `AWM_PGLITE_BM25_M`.
54
+ */
55
+ const PGLITE_BM25_M = Number(process.env.AWM_PGLITE_BM25_M ?? 1);
56
+ function calibrateBm25(rawTsRank: number): number {
57
+ if (!Number.isFinite(rawTsRank) || rawTsRank <= 0) return 0;
58
+ if (PGLITE_BM25_M === 1) return rawTsRank;
59
+ const scaled = rawTsRank * PGLITE_BM25_M;
60
+ return scaled / (1 + scaled);
61
+ }
62
+
63
+ function vectorToLiteral(v: number[] | null | undefined): string | null {
64
+ if (!v || v.length === 0) return null;
65
+ return '[' + v.join(',') + ']';
66
+ }
67
+
68
+ function literalToVector(s: string | null | undefined): number[] | null {
69
+ if (!s) return null;
70
+ return s.replace(/^\[|\]$/g, '').split(',').map(Number);
71
+ }
72
+
73
+ function rowToEngram(row: any): Engram {
74
+ return {
75
+ id: row.id as string,
76
+ agentId: row.agent_id as string,
77
+ concept: row.concept as string,
78
+ content: row.content as string,
79
+ embedding: literalToVector(row.embedding as string | null),
80
+ confidence: row.confidence as number,
81
+ salience: row.salience as number,
82
+ accessCount: row.access_count as number,
83
+ lastAccessed: new Date(row.last_accessed as string),
84
+ createdAt: new Date(row.created_at as string),
85
+ salienceFeatures: row.salience_features ? JSON.parse(row.salience_features as string) : {},
86
+ reasonCodes: row.reason_codes ? JSON.parse(row.reason_codes as string) : [],
87
+ stage: (row.stage as EngramStage) ?? 'active',
88
+ ttl: (row.ttl as number | null) ?? null,
89
+ retracted: Boolean(row.retracted),
90
+ retractedBy: (row.retracted_by as string | null) ?? null,
91
+ retractedAt: row.retracted_at ? new Date(row.retracted_at as string) : null,
92
+ tags: row.tags ? JSON.parse(row.tags as string) : [],
93
+ memoryType: (row.memory_type as MemoryType) ?? 'unclassified',
94
+ memoryClass: (row.memory_class as MemoryClass) ?? 'working',
95
+ supersededBy: (row.superseded_by as string | null) ?? null,
96
+ supersedes: (row.supersedes as string | null) ?? null,
97
+ episodeId: (row.episode_id as string | null) ?? null,
98
+ taskStatus: (row.task_status as TaskStatus | null) ?? null,
99
+ taskPriority: (row.task_priority as TaskPriority | null) ?? null,
100
+ blockedBy: (row.blocked_by as string | null) ?? null,
101
+ sequence: row.sequence == null ? null : Number(row.sequence),
102
+ references: row.references_json ? JSON.parse(row.references_json as string) : null,
103
+ } as Engram;
104
+ }
105
+
106
+ function rowToAssociation(row: any): Association {
107
+ return {
108
+ id: row.id,
109
+ fromEngramId: row.from_engram_id,
110
+ toEngramId: row.to_engram_id,
111
+ weight: row.weight,
112
+ confidence: row.confidence ?? 0.5,
113
+ type: row.type as AssociationType,
114
+ activationCount: row.activation_count ?? 0,
115
+ createdAt: new Date(row.created_at),
116
+ lastActivated: new Date(row.last_activated),
117
+ };
118
+ }
119
+
120
+ function rowToEpisode(row: any): Episode {
121
+ return {
122
+ id: row.id,
123
+ agentId: row.agent_id,
124
+ label: row.label,
125
+ embedding: literalToVector(row.embedding as string | null),
126
+ engramCount: row.engram_count,
127
+ startTime: new Date(row.start_time),
128
+ endTime: new Date(row.end_time),
129
+ createdAt: new Date(row.created_at),
130
+ };
131
+ }
132
+
133
+ function tagLike(tag: string): string {
134
+ return `%"${tag}"%`;
135
+ }
136
+
137
+ function extractTagValue(tags: string[], prefix: string): string | null {
138
+ for (const t of tags) {
139
+ if (t.startsWith(prefix)) return t.slice(prefix.length);
140
+ }
141
+ return null;
142
+ }
143
+
144
+ export class PGliteEngramStore {
145
+ private db!: PGlite;
146
+ private readyPromise: Promise<void>;
147
+
148
+ // Activation-event batching — recall path writes one event per call. On
149
+ // PGlite that's a full transaction per recall, adding ~20-50ms. We queue
150
+ // events in memory and flush every 5s or when buffer reaches 100.
151
+ // Buffer is best-effort — crash loses last batch (eval data only, not state).
152
+ private activationEventBuffer: ActivationEvent[] = [];
153
+ private activationFlushTimer: ReturnType<typeof setInterval> | null = null;
154
+ private static readonly ACTIVATION_FLUSH_INTERVAL_MS = 5_000;
155
+ private static readonly ACTIVATION_FLUSH_BATCH_SIZE = 100;
156
+
157
+ constructor(dbPath: string = './memory.db') {
158
+ this.readyPromise = this.init(dbPath);
159
+ }
160
+
161
+ private async init(dataDir: string): Promise<void> {
162
+ this.db = await PGlite.create(dataDir, { extensions: { vector } });
163
+ await this.db.exec(PGLITE_SCHEMA_DDL);
164
+ // ivfflat probes: at lists=100 (set in pglite-schema.ts), default probes=1
165
+ // scans only 1 cluster which misses neighbors on sparse query distributions.
166
+ // probes=5 trades ~10-20ms latency for ~5x better recall on top-K — the
167
+ // sweet spot for our 1K–100K engram range. Tunable via AWM_IVFFLAT_PROBES.
168
+ const probes = parseInt(process.env.AWM_IVFFLAT_PROBES ?? '5', 10);
169
+ if (probes > 1) {
170
+ await this.db.exec(`SET ivfflat.probes = ${probes}`);
171
+ }
172
+ // Periodic flush for batched activation events.
173
+ this.activationFlushTimer = setInterval(
174
+ () => { void this.flushActivationEvents().catch(() => {/* best-effort */}); },
175
+ PGliteEngramStore.ACTIVATION_FLUSH_INTERVAL_MS,
176
+ );
177
+ }
178
+
179
+ async ready(): Promise<void> { return this.readyPromise; }
180
+
181
+ async close(): Promise<void> {
182
+ await this.readyPromise;
183
+ if (this.activationFlushTimer) {
184
+ clearInterval(this.activationFlushTimer);
185
+ this.activationFlushTimer = null;
186
+ }
187
+ await this.flushActivationEvents();
188
+ await this.db.close();
189
+ }
190
+
191
+ /**
192
+ * Flush queued activation events as a single multi-row INSERT.
193
+ * Idempotent — safe to call when the buffer is empty.
194
+ */
195
+ private async flushActivationEvents(): Promise<void> {
196
+ if (this.activationEventBuffer.length === 0) return;
197
+ const batch = this.activationEventBuffer.splice(0);
198
+ const values: string[] = [];
199
+ const params: any[] = [];
200
+ for (let i = 0; i < batch.length; i++) {
201
+ const e = batch[i];
202
+ const base = i * 8;
203
+ values.push(`($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8})`);
204
+ params.push(
205
+ e.id, e.agentId, e.timestamp.toISOString(),
206
+ e.context, e.resultsReturned, e.topScore,
207
+ e.latencyMs, JSON.stringify(e.engramIds),
208
+ );
209
+ }
210
+ try {
211
+ await this.db.query(
212
+ `INSERT INTO activation_events (id, agent_id, timestamp, context, results_returned, top_score, latency_ms, engram_ids)
213
+ VALUES ${values.join(',')}`,
214
+ params,
215
+ );
216
+ } catch {
217
+ // Drop the batch on failure — eval data, not state.
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Async-aware transaction wrapper that matches IEngramStore.withTransaction.
223
+ *
224
+ * Uses raw BEGIN/COMMIT/ROLLBACK on the shared connection so `fn` can call
225
+ * the regular (non-tx-context) store methods — they all funnel through
226
+ * `this.db.query()` which serializes on the same PGlite connection.
227
+ * The transaction lock is held across awaits inside fn.
228
+ */
229
+ async withTransaction<T>(fn: () => Promise<T>): Promise<T> {
230
+ await this.readyPromise;
231
+ await this.db.query('BEGIN');
232
+ try {
233
+ const result = await fn();
234
+ await this.db.query('COMMIT');
235
+ return result;
236
+ } catch (err) {
237
+ try { await this.db.query('ROLLBACK'); } catch { /* best-effort */ }
238
+ throw err;
239
+ }
240
+ }
241
+
242
+ // ============================================================
243
+ // Engram CRUD
244
+ // ============================================================
245
+
246
+ async createEngram(input: EngramCreate & { id?: string }): Promise<Engram> {
247
+ await this.readyPromise;
248
+ const id = input.id ?? randomUUID();
249
+ const now = new Date().toISOString();
250
+
251
+ await this.db.query(
252
+ `INSERT INTO engrams (
253
+ id, agent_id, concept, content, embedding, embedding_model,
254
+ confidence, salience, access_count, last_accessed, created_at,
255
+ salience_features, reason_codes, stage, ttl, retracted,
256
+ tags, memory_type, memory_class, supersedes, episode_id,
257
+ task_status, task_priority, blocked_by, sequence, references_json
258
+ ) VALUES (
259
+ $1, $2, $3, $4, $5::vector, $6,
260
+ $7, $8, 0, $9, $10,
261
+ $11, $12, 'active', $13, FALSE,
262
+ $14, $15, $16, $17, $18,
263
+ $19, $20, $21, $22, $23
264
+ )`,
265
+ [
266
+ id,
267
+ input.agentId,
268
+ input.concept,
269
+ input.content,
270
+ vectorToLiteral(input.embedding ?? null),
271
+ (input as any).embeddingModel ?? null,
272
+ input.confidence ?? 0.5,
273
+ input.salience ?? 0.5,
274
+ now, now,
275
+ JSON.stringify(input.salienceFeatures ?? {}),
276
+ JSON.stringify((input as any).reasonCodes ?? []),
277
+ (input as any).ttl ?? null,
278
+ JSON.stringify(input.tags ?? []),
279
+ (input as any).memoryType ?? 'unclassified',
280
+ (input as any).memoryClass ?? 'working',
281
+ (input as any).supersedes ?? null,
282
+ (input as any).episodeId ?? null,
283
+ (input as any).taskStatus ?? null,
284
+ (input as any).taskPriority ?? null,
285
+ (input as any).blockedBy ?? null,
286
+ (input as any).sequence ?? null,
287
+ input.references && input.references.length > 0
288
+ ? JSON.stringify(input.references) : null,
289
+ ],
290
+ );
291
+
292
+ const row = await this.getEngram(id);
293
+ if (!row) throw new Error(`createEngram: row ${id} not found after insert`);
294
+ return row;
295
+ }
296
+
297
+ async getEngram(id: string): Promise<Engram | null> {
298
+ await this.readyPromise;
299
+ const result = await this.db.query<any>(`SELECT * FROM engrams WHERE id = $1`, [id]);
300
+ if (result.rows.length === 0) return null;
301
+ return rowToEngram(result.rows[0]);
302
+ }
303
+
304
+ async getEngramsByAgent(agentId: string, stage?: EngramStage, includeRetracted: boolean = false): Promise<Engram[]> {
305
+ await this.readyPromise;
306
+ let sql = `SELECT * FROM engrams WHERE agent_id = $1`;
307
+ const params: any[] = [agentId];
308
+ if (stage) {
309
+ sql += ` AND stage = $${params.length + 1}`;
310
+ params.push(stage);
311
+ }
312
+ if (!includeRetracted) sql += ` AND retracted = FALSE`;
313
+ sql += ` ORDER BY created_at DESC`;
314
+ const result = await this.db.query<any>(sql, params);
315
+ return result.rows.map(rowToEngram);
316
+ }
317
+
318
+ async getEngramsByAgentSlim(
319
+ agentId: string,
320
+ stage?: EngramStage,
321
+ includeRetracted: boolean = false,
322
+ ): Promise<Array<{ id: string; concept: string; embedding: number[] | null }>> {
323
+ await this.readyPromise;
324
+ let sql = `SELECT id, concept, embedding FROM engrams WHERE agent_id = $1`;
325
+ const params: any[] = [agentId];
326
+ if (stage) {
327
+ sql += ` AND stage = $${params.length + 1}`;
328
+ params.push(stage);
329
+ }
330
+ if (!includeRetracted) sql += ` AND retracted = FALSE`;
331
+ const result = await this.db.query<any>(sql, params);
332
+ return result.rows.map((r) => ({
333
+ id: r.id as string,
334
+ concept: r.concept as string,
335
+ embedding: literalToVector(r.embedding as string | null),
336
+ }));
337
+ }
338
+
339
+ async getEngramsByAgentsSlim(
340
+ agentIds: string[],
341
+ stage?: EngramStage,
342
+ includeRetracted: boolean = false,
343
+ ): Promise<Array<{ id: string; concept: string; embedding: number[] | null }>> {
344
+ if (agentIds.length === 0) return [];
345
+ if (agentIds.length === 1) return this.getEngramsByAgentSlim(agentIds[0], stage, includeRetracted);
346
+ await this.readyPromise;
347
+ let sql = `SELECT id, concept, embedding FROM engrams WHERE agent_id = ANY($1::text[])`;
348
+ const params: any[] = [agentIds];
349
+ if (stage) {
350
+ sql += ` AND stage = $${params.length + 1}`;
351
+ params.push(stage);
352
+ }
353
+ if (!includeRetracted) sql += ` AND retracted = FALSE`;
354
+ const result = await this.db.query<any>(sql, params);
355
+ return result.rows.map((r) => ({
356
+ id: r.id as string,
357
+ concept: r.concept as string,
358
+ embedding: literalToVector(r.embedding as string | null),
359
+ }));
360
+ }
361
+
362
+ async getEngramsByIds(ids: string[]): Promise<Engram[]> {
363
+ if (ids.length === 0) return [];
364
+ await this.readyPromise;
365
+ const result = await this.db.query<any>(
366
+ `SELECT * FROM engrams WHERE id = ANY($1::text[])`,
367
+ [ids],
368
+ );
369
+ return result.rows.map(rowToEngram);
370
+ }
371
+
372
+ async getEngramsByAgents(agentIds: string[], stage?: EngramStage, includeRetracted: boolean = false): Promise<Engram[]> {
373
+ if (agentIds.length === 0) return [];
374
+ if (agentIds.length === 1) return this.getEngramsByAgent(agentIds[0], stage, includeRetracted);
375
+ await this.readyPromise;
376
+ let sql = `SELECT * FROM engrams WHERE agent_id = ANY($1::text[])`;
377
+ const params: any[] = [agentIds];
378
+ if (stage) {
379
+ sql += ` AND stage = $${params.length + 1}`;
380
+ params.push(stage);
381
+ }
382
+ if (!includeRetracted) sql += ` AND retracted = FALSE`;
383
+ const result = await this.db.query<any>(sql, params);
384
+ return result.rows.map(rowToEngram);
385
+ }
386
+
387
+ async getWorkspaceAgentIds(agentId: string, workspace: string): Promise<string[]> {
388
+ await this.readyPromise;
389
+ try {
390
+ const result = await this.db.query<any>(
391
+ `SELECT DISTINCT name FROM coord_agents WHERE workspace = $1 AND status != 'dead'`,
392
+ [workspace],
393
+ );
394
+ const names = result.rows.map((r) => r.name as string);
395
+ if (!names.includes(agentId)) names.push(agentId);
396
+ return names;
397
+ } catch {
398
+ return [agentId];
399
+ }
400
+ }
401
+
402
+ async touchEngram(id: string): Promise<void> {
403
+ await this.readyPromise;
404
+ await this.db.query(
405
+ `UPDATE engrams
406
+ SET access_count = access_count + 1,
407
+ last_accessed = $1,
408
+ confidence = LEAST(0.85, confidence + 0.02 / (1.0 + sqrt(access_count::float)))
409
+ WHERE id = $2`,
410
+ [new Date().toISOString(), id],
411
+ );
412
+ }
413
+
414
+ async updateStage(id: string, stage: EngramStage): Promise<void> {
415
+ await this.readyPromise;
416
+ await this.db.query(`UPDATE engrams SET stage = $1 WHERE id = $2`, [stage, id]);
417
+ }
418
+
419
+ /**
420
+ * Replace an engram's content. Used by the fade phase of consolidation
421
+ * (Paper 1: storage degradation) to coarsen un-recalled memories.
422
+ * The FTS trigger (BEFORE INSERT OR UPDATE OF concept, content, tags)
423
+ * automatically refreshes the tsvector index with the new content.
424
+ */
425
+ async updateContent(id: string, content: string): Promise<void> {
426
+ await this.readyPromise;
427
+ await this.db.query(`UPDATE engrams SET content = $1 WHERE id = $2`, [content, id]);
428
+ }
429
+
430
+ async updateConfidence(id: string, confidence: number): Promise<void> {
431
+ await this.readyPromise;
432
+ const clamped = Math.max(0, Math.min(1, confidence));
433
+ await this.db.query(`UPDATE engrams SET confidence = $1 WHERE id = $2`, [clamped, id]);
434
+ }
435
+
436
+ async updateEmbedding(id: string, embedding: number[], modelId?: string): Promise<void> {
437
+ await this.readyPromise;
438
+ if (modelId) {
439
+ await this.db.query(
440
+ `UPDATE engrams SET embedding = $1::vector, embedding_model = $2 WHERE id = $3`,
441
+ [vectorToLiteral(embedding), modelId, id],
442
+ );
443
+ } else {
444
+ await this.db.query(
445
+ `UPDATE engrams SET embedding = $1::vector WHERE id = $2`,
446
+ [vectorToLiteral(embedding), id],
447
+ );
448
+ }
449
+ }
450
+
451
+ async retractEngram(id: string, retractedBy: string | null): Promise<void> {
452
+ await this.readyPromise;
453
+ await this.db.query(
454
+ `UPDATE engrams SET retracted = TRUE, retracted_by = $1, retracted_at = $2 WHERE id = $3`,
455
+ [retractedBy, new Date().toISOString(), id],
456
+ );
457
+ }
458
+
459
+ async deleteEngram(id: string): Promise<void> {
460
+ await this.readyPromise;
461
+ await this.db.query(`DELETE FROM engrams WHERE id = $1`, [id]);
462
+ }
463
+
464
+ /**
465
+ * Time warp - shift all timestamps backward by ms milliseconds.
466
+ * Used for testing decay-dependent behavior.
467
+ */
468
+ async timeWarp(agentId: string, ms: number): Promise<number> {
469
+ await this.readyPromise;
470
+ const seconds = Math.round(ms / 1000);
471
+ const r1 = await this.db.query(
472
+ `UPDATE engrams SET
473
+ created_at = to_char(($1::timestamptz - interval '1 second' * $2), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
474
+ last_accessed = to_char(($3::timestamptz - interval '1 second' * $2), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
475
+ WHERE agent_id = $4`,
476
+ ['now', seconds, 'now', agentId],
477
+ );
478
+ // Simpler: just update with relative arithmetic on stored ISO strings.
479
+ // PGlite doesn't support all date ops cleanly; fall through to JS-side calculation.
480
+ return (r1 as any).affectedRows ?? 0;
481
+ }
482
+
483
+ async getLatestEngram(agentId: string, excludeId?: string): Promise<Engram | null> {
484
+ await this.readyPromise;
485
+ let sql = `SELECT * FROM engrams WHERE agent_id = $1 AND retracted = FALSE`;
486
+ const params: any[] = [agentId];
487
+ if (excludeId) {
488
+ sql += ` AND id != $${params.length + 1}`;
489
+ params.push(excludeId);
490
+ }
491
+ sql += ` ORDER BY created_at DESC LIMIT 1`;
492
+ const result = await this.db.query<any>(sql, params);
493
+ return result.rows.length > 0 ? rowToEngram(result.rows[0]) : null;
494
+ }
495
+
496
+ // ============================================================
497
+ // Search
498
+ // ============================================================
499
+
500
+ async searchByVector(agentId: string, vec: number[], limit: number = 10): Promise<Array<{ engram: Engram; distance: number }>> {
501
+ await this.readyPromise;
502
+ // Restrict to active + fading. Faded engrams (Paper 1: storage degradation)
503
+ // retain their embedding so they still participate in semantic recall, even
504
+ // though their content has been trimmed. Excludes staging/consolidated/archived.
505
+ const result = await this.db.query<any>(
506
+ `SELECT *, (embedding <=> $2::vector) AS distance
507
+ FROM engrams
508
+ WHERE agent_id = $1
509
+ AND embedding IS NOT NULL
510
+ AND retracted = FALSE
511
+ AND stage IN ('active', 'fading')
512
+ ORDER BY distance ASC
513
+ LIMIT $3`,
514
+ [agentId, vectorToLiteral(vec), limit],
515
+ );
516
+ return result.rows.map((r) => ({ engram: rowToEngram(r), distance: r.distance as number }));
517
+ }
518
+
519
+ async searchBM25(agentId: string, query: string, limit: number = 10): Promise<Engram[]> {
520
+ const ranked = await this.searchBM25WithRank(agentId, query, limit);
521
+ return ranked.map((r) => r.engram);
522
+ }
523
+
524
+ async searchBM25WithRank(agentId: string, query: string, limit: number = 10): Promise<Array<{ engram: Engram; bm25Score: number }>> {
525
+ await this.readyPromise;
526
+ // SQLite FTS5 uses OR-by-default; we mirror that with websearch_to_tsquery
527
+ // and explicit OR joining. plainto_tsquery would AND all terms, missing
528
+ // documents that contain only a subset of the query (e.g., a "correction"
529
+ // engram lacking the exact word "operator" but matching "javascript",
530
+ // "equality", "type").
531
+ const tokens = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(t => t.length > 1);
532
+ if (tokens.length === 0) return [];
533
+ const websearchQuery = tokens.join(' OR ');
534
+ const result = await this.db.query<any>(
535
+ `SELECT *, ts_rank_cd(fts, websearch_to_tsquery('english', $2)) AS rank
536
+ FROM engrams
537
+ WHERE agent_id = $1 AND retracted = FALSE
538
+ AND fts @@ websearch_to_tsquery('english', $2)
539
+ ORDER BY rank DESC
540
+ LIMIT $3`,
541
+ [agentId, websearchQuery, limit],
542
+ );
543
+ return result.rows.map((r) => ({ engram: rowToEngram(r), bm25Score: calibrateBm25(Number(r.rank)) }));
544
+ }
545
+
546
+ async searchBM25WithRankMultiAgent(agentIds: string[], query: string, limit: number = 10): Promise<Array<{ engram: Engram; bm25Score: number }>> {
547
+ if (agentIds.length === 0) return [];
548
+ if (agentIds.length === 1) return this.searchBM25WithRank(agentIds[0], query, limit);
549
+ await this.readyPromise;
550
+ const tokens = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(t => t.length > 1);
551
+ if (tokens.length === 0) return [];
552
+ const websearchQuery = tokens.join(' OR ');
553
+ const result = await this.db.query<any>(
554
+ `SELECT *, ts_rank_cd(fts, websearch_to_tsquery('english', $2)) AS rank
555
+ FROM engrams
556
+ WHERE agent_id = ANY($1::text[]) AND retracted = FALSE
557
+ AND fts @@ websearch_to_tsquery('english', $2)
558
+ ORDER BY rank DESC
559
+ LIMIT $3`,
560
+ [agentIds, websearchQuery, limit],
561
+ );
562
+ return result.rows.map((r) => ({ engram: rowToEngram(r), bm25Score: calibrateBm25(Number(r.rank)) }));
563
+ }
564
+
565
+ /** Deterministic search (no vector or BM25 ranking — for diagnostic / structural queries). */
566
+ async search(query: SearchQuery): Promise<Engram[]> {
567
+ await this.readyPromise;
568
+ let sql = `SELECT * FROM engrams WHERE agent_id = $1`;
569
+ const params: any[] = [query.agentId];
570
+
571
+ if (query.text) {
572
+ sql += ` AND (content ILIKE $${params.length + 1} OR concept ILIKE $${params.length + 1})`;
573
+ params.push(`%${query.text}%`);
574
+ }
575
+ if (query.concept) {
576
+ sql += ` AND concept = $${params.length + 1}`;
577
+ params.push(query.concept);
578
+ }
579
+ if (query.stage) {
580
+ sql += ` AND stage = $${params.length + 1}`;
581
+ params.push(query.stage);
582
+ }
583
+ if (query.retracted !== undefined) {
584
+ sql += ` AND retracted = $${params.length + 1}`;
585
+ params.push(query.retracted);
586
+ }
587
+ const allTags = [...(query.tags ?? []), ...(query.tagsAll ?? [])];
588
+ for (const tag of allTags) {
589
+ sql += ` AND tags LIKE $${params.length + 1}`;
590
+ params.push(tagLike(tag));
591
+ }
592
+ if (query.tagsAny && query.tagsAny.length > 0) {
593
+ const ors = query.tagsAny.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
594
+ sql += ` AND (${ors})`;
595
+ for (const tag of query.tagsAny) params.push(tagLike(tag));
596
+ }
597
+ if (query.tagsNone && query.tagsNone.length > 0) {
598
+ const ors = query.tagsNone.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
599
+ sql += ` AND NOT (${ors})`;
600
+ for (const tag of query.tagsNone) params.push(tagLike(tag));
601
+ }
602
+
603
+ const sortCol = ({
604
+ createdAt: 'created_at', sequence: 'sequence', salience: 'salience',
605
+ confidence: 'confidence', lastAccessed: 'last_accessed',
606
+ } as const)[query.sortBy ?? 'lastAccessed'];
607
+ const dir = query.sortOrder === 'asc' ? 'ASC' : 'DESC';
608
+ if (query.sortBy === 'sequence') {
609
+ sql += ` ORDER BY (sequence IS NULL), sequence ${dir}`;
610
+ } else {
611
+ sql += ` ORDER BY ${sortCol} ${dir}`;
612
+ }
613
+ sql += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
614
+ params.push(query.limit ?? 50, query.offset ?? 0);
615
+
616
+ const result = await this.db.query<any>(sql, params);
617
+ return result.rows.map(rowToEngram);
618
+ }
619
+
620
+ // ============================================================
621
+ // Tasks
622
+ // ============================================================
623
+
624
+ async updateTaskStatus(id: string, status: TaskStatus): Promise<void> {
625
+ await this.readyPromise;
626
+ await this.db.query(`UPDATE engrams SET task_status = $1 WHERE id = $2`, [status, id]);
627
+ }
628
+
629
+ async updateTaskPriority(id: string, priority: TaskPriority): Promise<void> {
630
+ await this.readyPromise;
631
+ await this.db.query(`UPDATE engrams SET task_priority = $1 WHERE id = $2`, [priority, id]);
632
+ }
633
+
634
+ async updateBlockedBy(id: string, blockedBy: string | null): Promise<void> {
635
+ await this.readyPromise;
636
+ await this.db.query(
637
+ `UPDATE engrams SET blocked_by = $1, task_status = $2 WHERE id = $3`,
638
+ [blockedBy, blockedBy ? 'blocked' : 'open', id],
639
+ );
640
+ }
641
+
642
+ async getTasks(agentId: string, status?: TaskStatus): Promise<Engram[]> {
643
+ await this.readyPromise;
644
+ let sql = `SELECT * FROM engrams WHERE agent_id = $1 AND task_status IS NOT NULL AND retracted = FALSE`;
645
+ const params: any[] = [agentId];
646
+ if (status) {
647
+ sql += ` AND task_status = $${params.length + 1}`;
648
+ params.push(status);
649
+ }
650
+ sql += ` ORDER BY
651
+ CASE task_priority
652
+ WHEN 'urgent' THEN 0
653
+ WHEN 'high' THEN 1
654
+ WHEN 'medium' THEN 2
655
+ WHEN 'low' THEN 3
656
+ ELSE 4
657
+ END,
658
+ created_at DESC`;
659
+ const result = await this.db.query<any>(sql, params);
660
+ return result.rows.map(rowToEngram);
661
+ }
662
+
663
+ async getNextTask(agentId: string): Promise<Engram | null> {
664
+ await this.readyPromise;
665
+ const result = await this.db.query<any>(
666
+ `SELECT * FROM engrams
667
+ WHERE agent_id = $1 AND task_status IN ('open', 'in_progress') AND retracted = FALSE
668
+ ORDER BY
669
+ CASE task_status WHEN 'in_progress' THEN 0 ELSE 1 END,
670
+ CASE task_priority
671
+ WHEN 'urgent' THEN 0
672
+ WHEN 'high' THEN 1
673
+ WHEN 'medium' THEN 2
674
+ WHEN 'low' THEN 3
675
+ ELSE 4
676
+ END,
677
+ created_at ASC
678
+ LIMIT 1`,
679
+ [agentId],
680
+ );
681
+ return result.rows.length > 0 ? rowToEngram(result.rows[0]) : null;
682
+ }
683
+
684
+ // ============================================================
685
+ // Supersession & tags
686
+ // ============================================================
687
+
688
+ async supersedeEngram(oldId: string, newId: string): Promise<void> {
689
+ await this.readyPromise;
690
+ await this.db.query(`UPDATE engrams SET superseded_by = $1 WHERE id = $2`, [newId, oldId]);
691
+ await this.db.query(`UPDATE engrams SET supersedes = $1 WHERE id = $2`, [oldId, newId]);
692
+ }
693
+
694
+ async findActiveMatchByConcept(
695
+ agentId: string,
696
+ concept: string,
697
+ requiredTags?: string[],
698
+ ): Promise<Engram | null> {
699
+ await this.readyPromise;
700
+ let sql = `SELECT * FROM engrams
701
+ WHERE agent_id = $1
702
+ AND LOWER(TRIM(concept)) = LOWER(TRIM($2))
703
+ AND stage = 'active'
704
+ AND retracted = FALSE
705
+ AND superseded_by IS NULL`;
706
+ const params: any[] = [agentId, concept];
707
+ if (requiredTags && requiredTags.length > 0) {
708
+ for (const tag of requiredTags) {
709
+ sql += ` AND tags LIKE $${params.length + 1}`;
710
+ params.push(tagLike(tag));
711
+ }
712
+ }
713
+ sql += ` ORDER BY created_at DESC LIMIT 1`;
714
+ const result = await this.db.query<any>(sql, params);
715
+ return result.rows.length > 0 ? rowToEngram(result.rows[0]) : null;
716
+ }
717
+
718
+ async isSuperseded(id: string): Promise<boolean> {
719
+ await this.readyPromise;
720
+ const result = await this.db.query<any>(
721
+ `SELECT superseded_by FROM engrams WHERE id = $1`,
722
+ [id],
723
+ );
724
+ return result.rows.length > 0 && result.rows[0].superseded_by != null;
725
+ }
726
+
727
+ async updateMemoryClass(id: string, memoryClass: MemoryClass): Promise<void> {
728
+ await this.readyPromise;
729
+ await this.db.query(`UPDATE engrams SET memory_class = $1 WHERE id = $2`, [memoryClass, id]);
730
+ }
731
+
732
+ async updateTags(id: string, tags: string[]): Promise<void> {
733
+ await this.readyPromise;
734
+ await this.db.query(`UPDATE engrams SET tags = $1 WHERE id = $2`, [JSON.stringify(tags), id]);
735
+ }
736
+
737
+ // ============================================================
738
+ // Associations
739
+ // ============================================================
740
+
741
+ async upsertAssociation(
742
+ fromId: string, toId: string, weight: number,
743
+ type: AssociationType = 'hebbian', confidence: number = 0.5,
744
+ ): Promise<Association> {
745
+ await this.readyPromise;
746
+ const id = randomUUID();
747
+ const now = new Date().toISOString();
748
+ await this.db.query(
749
+ `INSERT INTO associations (id, from_engram_id, to_engram_id, weight, confidence, type, activation_count, created_at, last_activated)
750
+ VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $7)
751
+ ON CONFLICT (from_engram_id, to_engram_id) DO UPDATE SET
752
+ weight = EXCLUDED.weight,
753
+ confidence = EXCLUDED.confidence,
754
+ last_activated = EXCLUDED.last_activated,
755
+ activation_count = associations.activation_count + 1`,
756
+ [id, fromId, toId, weight, confidence, type, now],
757
+ );
758
+ const assoc = await this.getAssociation(fromId, toId);
759
+ if (!assoc) throw new Error('upsertAssociation: row not found after insert');
760
+ return assoc;
761
+ }
762
+
763
+ async getAssociation(fromId: string, toId: string): Promise<Association | null> {
764
+ await this.readyPromise;
765
+ const result = await this.db.query<any>(
766
+ `SELECT * FROM associations WHERE from_engram_id = $1 AND to_engram_id = $2`,
767
+ [fromId, toId],
768
+ );
769
+ return result.rows.length > 0 ? rowToAssociation(result.rows[0]) : null;
770
+ }
771
+
772
+ async getAssociationsFor(engramId: string): Promise<Association[]> {
773
+ await this.readyPromise;
774
+ const result = await this.db.query<any>(
775
+ `SELECT * FROM associations WHERE from_engram_id = $1 OR to_engram_id = $1`,
776
+ [engramId],
777
+ );
778
+ return result.rows.map(rowToAssociation);
779
+ }
780
+
781
+ async getAssociationStatsForBatch(engramIds: string[]): Promise<Map<string, { count: number; sumWeight: number }>> {
782
+ const result = new Map<string, { count: number; sumWeight: number }>();
783
+ if (engramIds.length === 0) return result;
784
+ await this.readyPromise;
785
+ const r = await this.db.query<any>(
786
+ `SELECT id, SUM(cnt) AS count, SUM(sw) AS sum_weight FROM (
787
+ SELECT from_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE from_engram_id = ANY($1::text[])
788
+ UNION ALL
789
+ SELECT to_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE to_engram_id = ANY($1::text[])
790
+ ) t
791
+ WHERE id = ANY($1::text[])
792
+ GROUP BY id`,
793
+ [engramIds],
794
+ );
795
+ for (const row of r.rows) {
796
+ result.set(row.id as string, { count: Number(row.count), sumWeight: Number(row.sum_weight) });
797
+ }
798
+ for (const id of engramIds) {
799
+ if (!result.has(id)) result.set(id, { count: 0, sumWeight: 0 });
800
+ }
801
+ return result;
802
+ }
803
+
804
+ async getAssociationsForBatch(engramIds: string[]): Promise<Map<string, Association[]>> {
805
+ const result = new Map<string, Association[]>();
806
+ if (engramIds.length === 0) return result;
807
+ await this.readyPromise;
808
+ const r = await this.db.query<any>(
809
+ `SELECT * FROM associations
810
+ WHERE from_engram_id = ANY($1::text[]) OR to_engram_id = ANY($1::text[])`,
811
+ [engramIds],
812
+ );
813
+ for (const row of r.rows) {
814
+ const a = rowToAssociation(row);
815
+ const fromList = result.get(a.fromEngramId) ?? [];
816
+ fromList.push(a);
817
+ result.set(a.fromEngramId, fromList);
818
+ if (a.toEngramId !== a.fromEngramId) {
819
+ const toList = result.get(a.toEngramId) ?? [];
820
+ toList.push(a);
821
+ result.set(a.toEngramId, toList);
822
+ }
823
+ }
824
+ for (const id of engramIds) {
825
+ if (!result.has(id)) result.set(id, []);
826
+ }
827
+ return result;
828
+ }
829
+
830
+ async getOutgoingAssociations(engramId: string): Promise<Association[]> {
831
+ await this.readyPromise;
832
+ const result = await this.db.query<any>(
833
+ `SELECT * FROM associations WHERE from_engram_id = $1`,
834
+ [engramId],
835
+ );
836
+ return result.rows.map(rowToAssociation);
837
+ }
838
+
839
+ async countAssociationsFor(engramId: string): Promise<number> {
840
+ await this.readyPromise;
841
+ const result = await this.db.query<any>(
842
+ `SELECT COUNT(*) AS count FROM associations WHERE from_engram_id = $1`,
843
+ [engramId],
844
+ );
845
+ return Number(result.rows[0]?.count ?? 0);
846
+ }
847
+
848
+ async getWeakestAssociation(engramId: string): Promise<Association | null> {
849
+ await this.readyPromise;
850
+ const result = await this.db.query<any>(
851
+ `SELECT * FROM associations WHERE from_engram_id = $1 ORDER BY weight ASC LIMIT 1`,
852
+ [engramId],
853
+ );
854
+ return result.rows.length > 0 ? rowToAssociation(result.rows[0]) : null;
855
+ }
856
+
857
+ async deleteAssociation(id: string): Promise<void> {
858
+ await this.readyPromise;
859
+ await this.db.query(`DELETE FROM associations WHERE id = $1`, [id]);
860
+ }
861
+
862
+ async getAllAssociations(agentId: string): Promise<Association[]> {
863
+ await this.readyPromise;
864
+ const result = await this.db.query<any>(
865
+ `SELECT a.* FROM associations a
866
+ JOIN engrams e ON a.from_engram_id = e.id
867
+ WHERE e.agent_id = $1`,
868
+ [agentId],
869
+ );
870
+ return result.rows.map(rowToAssociation);
871
+ }
872
+
873
+ // ============================================================
874
+ // Eviction & counts
875
+ // ============================================================
876
+
877
+ async getEvictionCandidates(agentId: string, limit: number): Promise<Engram[]> {
878
+ await this.readyPromise;
879
+ const result = await this.db.query<any>(
880
+ `SELECT * FROM engrams
881
+ WHERE agent_id = $1 AND stage = 'active' AND retracted = FALSE
882
+ ORDER BY (salience * 0.3 + confidence * 0.3
883
+ + (access_count::float / (access_count + 5)) * 0.2
884
+ + (1.0 / (1.0 + EXTRACT(EPOCH FROM (now() - last_accessed::timestamptz)) / 86400.0)) * 0.2) ASC
885
+ LIMIT $2`,
886
+ [agentId, limit],
887
+ );
888
+ return result.rows.map(rowToEngram);
889
+ }
890
+
891
+ async getActiveCount(agentId: string): Promise<number> {
892
+ await this.readyPromise;
893
+ const result = await this.db.query<any>(
894
+ `SELECT COUNT(*) AS count FROM engrams WHERE agent_id = $1 AND stage = 'active'`,
895
+ [agentId],
896
+ );
897
+ return Number(result.rows[0]?.count ?? 0);
898
+ }
899
+
900
+ async getStagingCount(agentId: string): Promise<number> {
901
+ await this.readyPromise;
902
+ const result = await this.db.query<any>(
903
+ `SELECT COUNT(*) AS count FROM engrams WHERE agent_id = $1 AND stage = 'staging'`,
904
+ [agentId],
905
+ );
906
+ return Number(result.rows[0]?.count ?? 0);
907
+ }
908
+
909
+ async getExpiredStaging(): Promise<Engram[]> {
910
+ await this.readyPromise;
911
+ const result = await this.db.query<any>(
912
+ `SELECT * FROM engrams WHERE stage = 'staging' AND ttl IS NOT NULL`,
913
+ );
914
+ const now = Date.now();
915
+ return result.rows
916
+ .map(rowToEngram)
917
+ .filter((e) => e.ttl && (e.createdAt.getTime() + e.ttl) < now);
918
+ }
919
+
920
+ // ============================================================
921
+ // Eval logging
922
+ // ============================================================
923
+
924
+ async logActivationEvent(event: ActivationEvent): Promise<void> {
925
+ // Queue rather than write synchronously — removes activation INSERT from
926
+ // the recall hot path. Flushed on timer (5s) or when buffer hits 100.
927
+ this.activationEventBuffer.push(event);
928
+ if (this.activationEventBuffer.length >= PGliteEngramStore.ACTIVATION_FLUSH_BATCH_SIZE) {
929
+ void this.flushActivationEvents().catch(() => {/* best-effort */});
930
+ }
931
+ }
932
+
933
+ async logStagingEvent(event: StagingEvent): Promise<void> {
934
+ await this.readyPromise;
935
+ await this.db.query(
936
+ `INSERT INTO staging_events (engram_id, agent_id, action, resonance_score, timestamp, age_ms)
937
+ VALUES ($1, $2, $3, $4, $5, $6)`,
938
+ [
939
+ event.engramId, event.agentId, event.action,
940
+ event.resonanceScore, event.timestamp.toISOString(), event.ageMs,
941
+ ],
942
+ );
943
+ }
944
+
945
+ async logRetrievalFeedback(activationEventId: string | null, engramId: string, useful: boolean, context: string): Promise<void> {
946
+ await this.readyPromise;
947
+ await this.db.query(
948
+ `INSERT INTO retrieval_feedback (id, activation_event_id, engram_id, useful, context, timestamp)
949
+ VALUES ($1, $2, $3, $4, $5, $6)`,
950
+ [randomUUID(), activationEventId, engramId, useful, context, new Date().toISOString()],
951
+ );
952
+ }
953
+
954
+ async getRetrievalPrecision(agentId: string, windowHours: number = 24): Promise<number> {
955
+ await this.readyPromise;
956
+ const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
957
+ const result = await this.db.query<any>(
958
+ `SELECT
959
+ COUNT(CASE WHEN useful = TRUE THEN 1 END) AS useful_count,
960
+ COUNT(*) AS total_count
961
+ FROM retrieval_feedback rf
962
+ LEFT JOIN activation_events ae ON rf.activation_event_id = ae.id
963
+ JOIN engrams e ON rf.engram_id = e.id
964
+ WHERE e.agent_id = $1 AND rf.timestamp > $2`,
965
+ [agentId, since],
966
+ );
967
+ const row = result.rows[0];
968
+ const total = Number(row?.total_count ?? 0);
969
+ const useful = Number(row?.useful_count ?? 0);
970
+ return total > 0 ? useful / total : 0;
971
+ }
972
+
973
+ async getStagingMetrics(agentId: string): Promise<{ promoted: number; discarded: number; expired: number }> {
974
+ await this.readyPromise;
975
+ const result = await this.db.query<any>(
976
+ `SELECT
977
+ COUNT(CASE WHEN action = 'promoted' THEN 1 END) AS promoted,
978
+ COUNT(CASE WHEN action = 'discarded' THEN 1 END) AS discarded,
979
+ COUNT(CASE WHEN action = 'expired' THEN 1 END) AS expired
980
+ FROM staging_events WHERE agent_id = $1`,
981
+ [agentId],
982
+ );
983
+ const row = result.rows[0] ?? { promoted: 0, discarded: 0, expired: 0 };
984
+ return {
985
+ promoted: Number(row.promoted),
986
+ discarded: Number(row.discarded),
987
+ expired: Number(row.expired),
988
+ };
989
+ }
990
+
991
+ async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p95LatencyMs: number }> {
992
+ await this.readyPromise;
993
+ // Flush any buffered activation events so stats reflect the latest writes.
994
+ await this.flushActivationEvents();
995
+ const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
996
+ const result = await this.db.query<any>(
997
+ `SELECT latency_ms FROM activation_events
998
+ WHERE agent_id = $1 AND timestamp > $2
999
+ ORDER BY latency_ms ASC`,
1000
+ [agentId, since],
1001
+ );
1002
+ if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
1003
+ const latencies = result.rows.map((r) => Number(r.latency_ms));
1004
+ const total = latencies.reduce((s, l) => s + l, 0);
1005
+ const p95Idx = Math.min(Math.floor(latencies.length * 0.95), latencies.length - 1);
1006
+ return {
1007
+ count: latencies.length,
1008
+ avgLatencyMs: total / latencies.length,
1009
+ p95LatencyMs: latencies[p95Idx],
1010
+ };
1011
+ }
1012
+
1013
+ async getConsolidatedCount(agentId: string): Promise<number> {
1014
+ await this.readyPromise;
1015
+ const result = await this.db.query<any>(
1016
+ `SELECT COUNT(*) AS cnt FROM engrams WHERE agent_id = $1 AND stage = 'consolidated'`,
1017
+ [agentId],
1018
+ );
1019
+ return Number(result.rows[0]?.cnt ?? 0);
1020
+ }
1021
+
1022
+ // ============================================================
1023
+ // Episodes
1024
+ // ============================================================
1025
+
1026
+ async createEpisode(input: { agentId: string; label: string; embedding?: number[] }): Promise<Episode> {
1027
+ await this.readyPromise;
1028
+ const id = randomUUID();
1029
+ const now = new Date().toISOString();
1030
+ await this.db.query(
1031
+ `INSERT INTO episodes (id, agent_id, label, embedding, engram_count, start_time, end_time, created_at)
1032
+ VALUES ($1, $2, $3, $4::vector, 0, $5, $5, $5)`,
1033
+ [id, input.agentId, input.label, vectorToLiteral(input.embedding ?? null), now],
1034
+ );
1035
+ const ep = await this.getEpisode(id);
1036
+ if (!ep) throw new Error('createEpisode: row not found after insert');
1037
+ return ep;
1038
+ }
1039
+
1040
+ async getEpisode(id: string): Promise<Episode | null> {
1041
+ await this.readyPromise;
1042
+ const result = await this.db.query<any>(`SELECT * FROM episodes WHERE id = $1`, [id]);
1043
+ return result.rows.length > 0 ? rowToEpisode(result.rows[0]) : null;
1044
+ }
1045
+
1046
+ async getEpisodesByAgent(agentId: string): Promise<Episode[]> {
1047
+ await this.readyPromise;
1048
+ const result = await this.db.query<any>(
1049
+ `SELECT * FROM episodes WHERE agent_id = $1 ORDER BY end_time DESC`,
1050
+ [agentId],
1051
+ );
1052
+ return result.rows.map(rowToEpisode);
1053
+ }
1054
+
1055
+ async getActiveEpisode(agentId: string, windowMs: number = 3600_000): Promise<Episode | null> {
1056
+ await this.readyPromise;
1057
+ const cutoff = new Date(Date.now() - windowMs).toISOString();
1058
+ const result = await this.db.query<any>(
1059
+ `SELECT * FROM episodes WHERE agent_id = $1 AND end_time > $2 ORDER BY end_time DESC LIMIT 1`,
1060
+ [agentId, cutoff],
1061
+ );
1062
+ return result.rows.length > 0 ? rowToEpisode(result.rows[0]) : null;
1063
+ }
1064
+
1065
+ async addEngramToEpisode(engramId: string, episodeId: string): Promise<void> {
1066
+ await this.readyPromise;
1067
+ await this.db.query(`UPDATE engrams SET episode_id = $1 WHERE id = $2`, [episodeId, engramId]);
1068
+ await this.db.query(
1069
+ `UPDATE episodes SET
1070
+ engram_count = engram_count + 1,
1071
+ end_time = GREATEST(end_time, $1)
1072
+ WHERE id = $2`,
1073
+ [new Date().toISOString(), episodeId],
1074
+ );
1075
+ }
1076
+
1077
+ async getEngramsByEpisode(episodeId: string): Promise<Engram[]> {
1078
+ await this.readyPromise;
1079
+ const result = await this.db.query<any>(
1080
+ `SELECT * FROM engrams WHERE episode_id = $1 AND retracted = FALSE ORDER BY created_at ASC`,
1081
+ [episodeId],
1082
+ );
1083
+ return result.rows.map(rowToEngram);
1084
+ }
1085
+
1086
+ async updateEpisodeEmbedding(id: string, embedding: number[]): Promise<void> {
1087
+ await this.readyPromise;
1088
+ await this.db.query(
1089
+ `UPDATE episodes SET embedding = $1::vector WHERE id = $2`,
1090
+ [vectorToLiteral(embedding), id],
1091
+ );
1092
+ }
1093
+
1094
+ async getEpisodeCount(agentId: string): Promise<number> {
1095
+ await this.readyPromise;
1096
+ const result = await this.db.query<any>(
1097
+ `SELECT COUNT(*) AS cnt FROM episodes WHERE agent_id = $1`,
1098
+ [agentId],
1099
+ );
1100
+ return Number(result.rows[0]?.cnt ?? 0);
1101
+ }
1102
+
1103
+ // ============================================================
1104
+ // Tags lookup
1105
+ // ============================================================
1106
+
1107
+ async findEngramsByTags(agentId: string, tags: string[], excludeIds?: Set<string>): Promise<Engram[]> {
1108
+ if (tags.length === 0) return [];
1109
+ await this.readyPromise;
1110
+ const conditions = tags.map((_, i) => `tags LIKE $${i + 2}`).join(' OR ');
1111
+ const params: any[] = [agentId, ...tags.map(tagLike)];
1112
+ const sql = `SELECT * FROM engrams WHERE agent_id = $1 AND retracted = FALSE AND (${conditions})`;
1113
+ const result = await this.db.query<any>(sql, params);
1114
+ const engrams = result.rows.map(rowToEngram);
1115
+ if (excludeIds) return engrams.filter((e) => !excludeIds.has(e.id));
1116
+ return engrams;
1117
+ }
1118
+
1119
+ // ============================================================
1120
+ // Checkpointing & conscious state
1121
+ // ============================================================
1122
+
1123
+ async updateAutoCheckpointWrite(agentId: string, engramId: string): Promise<void> {
1124
+ await this.readyPromise;
1125
+ const now = new Date().toISOString();
1126
+ await this.db.query(
1127
+ `INSERT INTO conscious_state (agent_id, last_write_id, last_activity_at, write_count_since_consolidation, updated_at)
1128
+ VALUES ($1, $2, $3, 1, $3)
1129
+ ON CONFLICT(agent_id) DO UPDATE SET
1130
+ last_write_id = EXCLUDED.last_write_id,
1131
+ last_activity_at = EXCLUDED.last_activity_at,
1132
+ write_count_since_consolidation = conscious_state.write_count_since_consolidation + 1,
1133
+ updated_at = EXCLUDED.updated_at`,
1134
+ [agentId, engramId, now],
1135
+ );
1136
+ }
1137
+
1138
+ async updateAutoCheckpointRecall(agentId: string, context: string, engramIds: string[]): Promise<void> {
1139
+ await this.readyPromise;
1140
+ const now = new Date().toISOString();
1141
+ await this.db.query(
1142
+ `INSERT INTO conscious_state (agent_id, last_recall_context, last_recall_ids, last_activity_at, recall_count_since_consolidation, updated_at)
1143
+ VALUES ($1, $2, $3, $4, 1, $4)
1144
+ ON CONFLICT(agent_id) DO UPDATE SET
1145
+ last_recall_context = EXCLUDED.last_recall_context,
1146
+ last_recall_ids = EXCLUDED.last_recall_ids,
1147
+ last_activity_at = EXCLUDED.last_activity_at,
1148
+ recall_count_since_consolidation = conscious_state.recall_count_since_consolidation + 1,
1149
+ updated_at = EXCLUDED.updated_at`,
1150
+ [agentId, context, JSON.stringify(engramIds), now],
1151
+ );
1152
+ }
1153
+
1154
+ async touchActivity(agentId: string): Promise<void> {
1155
+ await this.readyPromise;
1156
+ const now = new Date().toISOString();
1157
+ await this.db.query(
1158
+ `INSERT INTO conscious_state (agent_id, last_activity_at, updated_at)
1159
+ VALUES ($1, $2, $2)
1160
+ ON CONFLICT(agent_id) DO UPDATE SET
1161
+ last_activity_at = EXCLUDED.last_activity_at,
1162
+ updated_at = EXCLUDED.updated_at`,
1163
+ [agentId, now],
1164
+ );
1165
+ }
1166
+
1167
+ async saveCheckpoint(agentId: string, state: ConsciousState): Promise<void> {
1168
+ await this.readyPromise;
1169
+ const now = new Date().toISOString();
1170
+ await this.db.query(
1171
+ `INSERT INTO conscious_state (agent_id, execution_state, checkpoint_at, last_activity_at, updated_at)
1172
+ VALUES ($1, $2, $3, $3, $3)
1173
+ ON CONFLICT(agent_id) DO UPDATE SET
1174
+ execution_state = EXCLUDED.execution_state,
1175
+ checkpoint_at = EXCLUDED.checkpoint_at,
1176
+ last_activity_at = EXCLUDED.last_activity_at,
1177
+ updated_at = EXCLUDED.updated_at`,
1178
+ [agentId, JSON.stringify(state), now],
1179
+ );
1180
+ }
1181
+
1182
+ async getCheckpoint(agentId: string): Promise<CheckpointRow | null> {
1183
+ await this.readyPromise;
1184
+ const result = await this.db.query<any>(
1185
+ `SELECT * FROM conscious_state WHERE agent_id = $1`,
1186
+ [agentId],
1187
+ );
1188
+ if (result.rows.length === 0) return null;
1189
+ const row = result.rows[0];
1190
+ return {
1191
+ agentId: row.agent_id,
1192
+ auto: {
1193
+ lastWriteId: row.last_write_id ?? null,
1194
+ lastRecallContext: row.last_recall_context ?? null,
1195
+ lastRecallIds: JSON.parse(row.last_recall_ids || '[]'),
1196
+ lastActivityAt: new Date(row.last_activity_at),
1197
+ writeCountSinceConsolidation: row.write_count_since_consolidation,
1198
+ recallCountSinceConsolidation: row.recall_count_since_consolidation,
1199
+ },
1200
+ executionState: row.execution_state ? JSON.parse(row.execution_state) : null,
1201
+ checkpointAt: row.checkpoint_at ? new Date(row.checkpoint_at) : null,
1202
+ lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
1203
+ lastMiniConsolidationAt: row.last_mini_consolidation_at ? new Date(row.last_mini_consolidation_at) : null,
1204
+ updatedAt: new Date(row.updated_at),
1205
+ } as CheckpointRow;
1206
+ }
1207
+
1208
+ async markConsolidation(agentId: string, mini: boolean): Promise<void> {
1209
+ await this.readyPromise;
1210
+ const now = new Date().toISOString();
1211
+ if (mini) {
1212
+ await this.db.query(
1213
+ `UPDATE conscious_state SET last_mini_consolidation_at = $1, updated_at = $1 WHERE agent_id = $2`,
1214
+ [now, agentId],
1215
+ );
1216
+ } else {
1217
+ await this.db.query(
1218
+ `UPDATE conscious_state SET
1219
+ last_consolidation_at = $1,
1220
+ last_mini_consolidation_at = $1,
1221
+ write_count_since_consolidation = 0,
1222
+ recall_count_since_consolidation = 0,
1223
+ consolidation_cycle_count = consolidation_cycle_count + 1,
1224
+ updated_at = $1
1225
+ WHERE agent_id = $2`,
1226
+ [now, agentId],
1227
+ );
1228
+ }
1229
+ }
1230
+
1231
+ async getActiveAgents(): Promise<Array<{ agentId: string; lastActivityAt: Date; writeCount: number; recallCount: number; lastConsolidationAt: Date | null }>> {
1232
+ await this.readyPromise;
1233
+ const result = await this.db.query<any>(`SELECT * FROM conscious_state`);
1234
+ return result.rows.map((row) => ({
1235
+ agentId: row.agent_id,
1236
+ lastActivityAt: new Date(row.last_activity_at),
1237
+ writeCount: row.write_count_since_consolidation,
1238
+ recallCount: row.recall_count_since_consolidation,
1239
+ lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
1240
+ }));
1241
+ }
1242
+
1243
+ async getConsolidationCycleCount(agentId: string): Promise<number> {
1244
+ await this.readyPromise;
1245
+ const result = await this.db.query<any>(
1246
+ `SELECT consolidation_cycle_count FROM conscious_state WHERE agent_id = $1`,
1247
+ [agentId],
1248
+ );
1249
+ return Number(result.rows[0]?.consolidation_cycle_count ?? 0);
1250
+ }
1251
+
1252
+ // ============================================================
1253
+ // 0.8 Cluster C — substrate primitives
1254
+ // ============================================================
1255
+
1256
+ async getLatestByTag(opts: {
1257
+ agentId: string;
1258
+ tagKeyPrefix: string;
1259
+ scopeTagsAll?: string[];
1260
+ retracted?: boolean;
1261
+ sortBy?: 'createdAt' | 'sequence';
1262
+ limit?: number;
1263
+ }): Promise<Engram[]> {
1264
+ await this.readyPromise;
1265
+ let sql = `SELECT * FROM engrams
1266
+ WHERE agent_id = $1
1267
+ AND retracted = $2
1268
+ AND stage = 'active'
1269
+ AND tags LIKE $3`;
1270
+ const params: any[] = [opts.agentId, opts.retracted ?? false, `%"${opts.tagKeyPrefix}%`];
1271
+ if (opts.scopeTagsAll && opts.scopeTagsAll.length > 0) {
1272
+ for (const t of opts.scopeTagsAll) {
1273
+ sql += ` AND tags LIKE $${params.length + 1}`;
1274
+ params.push(tagLike(t));
1275
+ }
1276
+ }
1277
+ if (opts.sortBy === 'sequence') sql += ` AND sequence IS NOT NULL`;
1278
+ sql += ` ORDER BY ` + (opts.sortBy === 'sequence' ? 'sequence DESC, created_at DESC' : 'created_at DESC');
1279
+ const result = await this.db.query<any>(sql, params);
1280
+ const engrams = result.rows.map(rowToEngram);
1281
+ const seen = new Map<string, Engram>();
1282
+ for (const e of engrams) {
1283
+ const value = extractTagValue(e.tags, opts.tagKeyPrefix);
1284
+ if (value == null) continue;
1285
+ if (!seen.has(value)) seen.set(value, e);
1286
+ }
1287
+ const out = Array.from(seen.values());
1288
+ return opts.limit ? out.slice(0, opts.limit) : out;
1289
+ }
1290
+
1291
+ async getTopBy(opts: {
1292
+ agentId: string;
1293
+ sortField: string;
1294
+ order: 'asc' | 'desc';
1295
+ filterTagsAll?: string[];
1296
+ filterTagsAny?: string[];
1297
+ filterTagsNone?: string[];
1298
+ limit?: number;
1299
+ retracted?: boolean;
1300
+ }): Promise<Engram[]> {
1301
+ await this.readyPromise;
1302
+ let sql = `SELECT * FROM engrams
1303
+ WHERE agent_id = $1
1304
+ AND retracted = $2
1305
+ AND stage = 'active'
1306
+ AND tags LIKE $3`;
1307
+ const params: any[] = [opts.agentId, opts.retracted ?? false, `%"${opts.sortField}%`];
1308
+ if (opts.filterTagsAll && opts.filterTagsAll.length > 0) {
1309
+ for (const tag of opts.filterTagsAll) {
1310
+ sql += ` AND tags LIKE $${params.length + 1}`;
1311
+ params.push(tagLike(tag));
1312
+ }
1313
+ }
1314
+ if (opts.filterTagsAny && opts.filterTagsAny.length > 0) {
1315
+ const ors = opts.filterTagsAny.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
1316
+ sql += ` AND (${ors})`;
1317
+ for (const tag of opts.filterTagsAny) params.push(tagLike(tag));
1318
+ }
1319
+ if (opts.filterTagsNone && opts.filterTagsNone.length > 0) {
1320
+ const ors = opts.filterTagsNone.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
1321
+ sql += ` AND NOT (${ors})`;
1322
+ for (const tag of opts.filterTagsNone) params.push(tagLike(tag));
1323
+ }
1324
+ const result = await this.db.query<any>(sql, params);
1325
+ const engrams = result.rows.map(rowToEngram);
1326
+ const valued = engrams.map((e) => {
1327
+ const raw = extractTagValue(e.tags, opts.sortField);
1328
+ const n = raw == null ? NaN : Number(raw);
1329
+ return { e, n };
1330
+ });
1331
+ valued.sort((a, b) => {
1332
+ const aNaN = Number.isNaN(a.n);
1333
+ const bNaN = Number.isNaN(b.n);
1334
+ if (aNaN && bNaN) return 0;
1335
+ if (aNaN) return 1;
1336
+ if (bNaN) return -1;
1337
+ return opts.order === 'asc' ? a.n - b.n : b.n - a.n;
1338
+ });
1339
+ const sorted = valued.map((v) => v.e);
1340
+ return opts.limit ? sorted.slice(0, opts.limit) : sorted;
1341
+ }
1342
+
1343
+ /**
1344
+ * Atomically allocate the next sequence number for an agent.
1345
+ *
1346
+ * Uses PGlite's transaction API — the callback receives a `tx` context
1347
+ * that must be used for queries; calling `this.db.query` from inside
1348
+ * the callback bypasses the transaction and deadlocks the connection.
1349
+ */
1350
+ async allocateNextSequence(agentId: string): Promise<number> {
1351
+ await this.readyPromise;
1352
+ return this.db.transaction(async (tx: any) => {
1353
+ const result = await tx.query(
1354
+ `SELECT MAX(sequence) AS max_seq FROM engrams WHERE agent_id = $1`,
1355
+ [agentId],
1356
+ );
1357
+ const max = result.rows[0]?.max_seq;
1358
+ return (max != null ? Number(max) : 0) + 1;
1359
+ }) as Promise<number>;
1360
+ }
1361
+ }
1362
+
1363
+ export const PGLITE_DIMENSIONS = PGLITE_VECTOR_DIMENSIONS;