agent-working-memory 0.11.0 → 0.12.3

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