agent-working-memory 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1221 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Postgres-server-backed EngramStore (AWM 0.8.x — real-server backend for scale).
5
+ *
6
+ * A near-clone of the PGlite adapter (pglite.ts) over node-postgres (`pg`) +
7
+ * pgvector. Same SQL surface and the same `pglite-schema.ts` DDL — PGlite IS
8
+ * Postgres compiled to WASM, so the schema, placeholders ($1…), and row shapes
9
+ * are identical; only the driver and connection lifecycle differ. Selected via
10
+ * `AWM_STORE_BACKEND=postgres`, connection from `AWM_DATABASE_URL`.
11
+ *
12
+ * Driver differences handled here (vs. the embedded single-connection PGlite):
13
+ * - `pg.Pool` instead of an embedded WASM instance.
14
+ * - `ivfflat.probes` is a per-SESSION GUC; it's persisted once as a
15
+ * per-DATABASE default during bootstrap (before the pool opens), so every
16
+ * pooled connection inherits it at startup with no per-connection query.
17
+ * - Transactions must run on ONE client. `withTransaction` checks out a
18
+ * dedicated client and binds it to an AsyncLocalStorage scope; `this.q()`
19
+ * routes to that client only for queries issued inside the transaction's
20
+ * async context (so `fn()`'s store calls join the tx), else to the pool.
21
+ * Mirrors PGlite's single-connection serialization without sending unrelated
22
+ * concurrent queries to the tx client.
23
+ * - int8/numeric are coerced to JS numbers (pg returns them as strings by
24
+ * default) to match PGlite's number-returning behavior.
25
+ *
26
+ * The full IEngramStore contract is implemented as async methods, identical to
27
+ * the PGlite adapter.
28
+ */
29
+ import pg from 'pg';
30
+ import { AsyncLocalStorage } from 'node:async_hooks';
31
+ import { randomUUID } from 'node:crypto';
32
+ import { PGLITE_SCHEMA_DDL, PGLITE_VECTOR_DIMENSIONS } from './pglite-schema.js';
33
+ const { Pool, Client, types } = pg;
34
+ // Coerce Postgres int8 (OID 20) and numeric (OID 1700) to JS numbers so result
35
+ // rows match PGlite's number-returning shape (pg returns them as strings). The
36
+ // store already wraps COUNT(*) in Number(), so this is defense-in-depth for any
37
+ // raw `as number` cast on an aggregate/bigint column.
38
+ types.setTypeParser(20, (v) => (v == null ? null : Number(v)));
39
+ types.setTypeParser(1700, (v) => (v == null ? null : Number(v)));
40
+ let warnedNoCoordPg = false; // one-time warning when workspace/hive recall falls back (coord_agents absent)
41
+ function toISO(d) {
42
+ if (d == null)
43
+ return null;
44
+ return d instanceof Date ? d.toISOString() : d;
45
+ }
46
+ /**
47
+ * Optional calibration knob for `ts_rank_cd` → bm25-compatible score range.
48
+ * **Default is pass-through (no calibration).**
49
+ *
50
+ * Background: SQLite normalizes FTS5 BM25 rank via `|rank|/(1+|rank|)`,
51
+ * producing scores in [0.5, 0.95] for matched docs. Postgres `ts_rank_cd`
52
+ * (cover density) raw values land in [0.05, 0.5] — a similar shape but a
53
+ * different *algorithm* than BM25.
54
+ *
55
+ * I tried calibrating with `M=10` to make PGlite's bm25Score distribution
56
+ * match SQLite's (`scripts/measure-bm25.ts`, 2026-05-26). The distribution
57
+ * matched, but the test:tokens accuracy gap (PGlite 25% vs SQLite 42.5%)
58
+ * did NOT close. Per-write trace (`scripts/trace-salience.ts`) showed
59
+ * `ts_rank_cd` and FTS5 BM25 disagree on which document pairs are
60
+ * "duplicates" for short-text matches — that's an algorithmic difference,
61
+ * not a magnitude one. ts_rank (frequency-weighted) doesn't help either.
62
+ *
63
+ * Default M=1 = no calibration. The function is kept as a tuning surface
64
+ * for future work on the salience-novelty path (likely going to need
65
+ * embedding-based novelty or per-backend calibration tables).
66
+ *
67
+ * Env override: `AWM_PGLITE_BM25_M`.
68
+ */
69
+ const PGLITE_BM25_M = Number(process.env.AWM_PGLITE_BM25_M ?? 1);
70
+ function calibrateBm25(rawTsRank) {
71
+ if (!Number.isFinite(rawTsRank) || rawTsRank <= 0)
72
+ return 0;
73
+ if (PGLITE_BM25_M === 1)
74
+ return rawTsRank;
75
+ const scaled = rawTsRank * PGLITE_BM25_M;
76
+ return scaled / (1 + scaled);
77
+ }
78
+ function vectorToLiteral(v) {
79
+ if (!v || v.length === 0)
80
+ return null;
81
+ return '[' + v.join(',') + ']';
82
+ }
83
+ function literalToVector(s) {
84
+ if (!s)
85
+ return null;
86
+ return s.replace(/^\[|\]$/g, '').split(',').map(Number);
87
+ }
88
+ function rowToEngram(row) {
89
+ return {
90
+ id: row.id,
91
+ agentId: row.agent_id,
92
+ concept: row.concept,
93
+ content: row.content,
94
+ embedding: literalToVector(row.embedding),
95
+ confidence: row.confidence,
96
+ salience: row.salience,
97
+ accessCount: row.access_count,
98
+ lastAccessed: new Date(row.last_accessed),
99
+ createdAt: new Date(row.created_at),
100
+ salienceFeatures: row.salience_features ? JSON.parse(row.salience_features) : {},
101
+ reasonCodes: row.reason_codes ? JSON.parse(row.reason_codes) : [],
102
+ stage: row.stage ?? 'active',
103
+ ttl: row.ttl ?? null,
104
+ retracted: Boolean(row.retracted),
105
+ retractedBy: row.retracted_by ?? null,
106
+ retractedAt: row.retracted_at ? new Date(row.retracted_at) : null,
107
+ tags: row.tags ? JSON.parse(row.tags) : [],
108
+ memoryType: row.memory_type ?? 'unclassified',
109
+ memoryClass: row.memory_class ?? 'working',
110
+ supersededBy: row.superseded_by ?? null,
111
+ supersedes: row.supersedes ?? null,
112
+ episodeId: row.episode_id ?? null,
113
+ taskStatus: row.task_status ?? null,
114
+ taskPriority: row.task_priority ?? null,
115
+ blockedBy: row.blocked_by ?? null,
116
+ sequence: row.sequence == null ? null : Number(row.sequence),
117
+ references: row.references_json ? JSON.parse(row.references_json) : null,
118
+ };
119
+ }
120
+ function rowToAssociation(row) {
121
+ return {
122
+ id: row.id,
123
+ fromEngramId: row.from_engram_id,
124
+ toEngramId: row.to_engram_id,
125
+ weight: row.weight,
126
+ confidence: row.confidence ?? 0.5,
127
+ type: row.type,
128
+ activationCount: row.activation_count ?? 0,
129
+ createdAt: new Date(row.created_at),
130
+ lastActivated: new Date(row.last_activated),
131
+ };
132
+ }
133
+ function rowToEpisode(row) {
134
+ return {
135
+ id: row.id,
136
+ agentId: row.agent_id,
137
+ label: row.label,
138
+ embedding: literalToVector(row.embedding),
139
+ engramCount: row.engram_count,
140
+ startTime: new Date(row.start_time),
141
+ endTime: new Date(row.end_time),
142
+ createdAt: new Date(row.created_at),
143
+ };
144
+ }
145
+ function tagLike(tag) {
146
+ return `%"${tag}"%`;
147
+ }
148
+ function extractTagValue(tags, prefix) {
149
+ for (const t of tags) {
150
+ if (t.startsWith(prefix))
151
+ return t.slice(prefix.length);
152
+ }
153
+ return null;
154
+ }
155
+ export class PostgresEngramStore {
156
+ pool;
157
+ /**
158
+ * The transaction's dedicated client, scoped to the withTransaction() callback's
159
+ * async context. q() reads it via getStore() so ONLY queries issued from inside
160
+ * the transaction route to that client — background callers (the activation-flush
161
+ * timer, an overlapping recall) keep using the pool. A pg client serves one query
162
+ * at a time, so without this scoping a background query could collide with the
163
+ * in-flight tx query ("client is already executing a query"). PGlite sidestepped
164
+ * this by queuing on its single connection; the pool path needs explicit scoping.
165
+ */
166
+ txCtx = new AsyncLocalStorage();
167
+ readyPromise;
168
+ // Activation-event batching — recall path writes one event per call. On a
169
+ // network-backed server that's a round-trip per recall; we queue events in
170
+ // memory and flush every 5s or when buffer reaches 100.
171
+ // Buffer is best-effort — crash loses last batch (eval data only, not state).
172
+ activationEventBuffer = [];
173
+ activationFlushTimer = null;
174
+ static ACTIVATION_FLUSH_INTERVAL_MS = 5_000;
175
+ static ACTIVATION_FLUSH_BATCH_SIZE = 100;
176
+ /**
177
+ * @param connectionString Postgres URL (postgres://user:pass@host:port/db).
178
+ * Falls back to AWM_DATABASE_URL, then a localhost default. The PGlite
179
+ * adapter takes a directory path here; this one takes a connection string —
180
+ * that's the only constructor-signature difference.
181
+ */
182
+ constructor(connectionString) {
183
+ const url = connectionString || process.env.AWM_DATABASE_URL || 'postgres://localhost:5432/awm';
184
+ this.readyPromise = this.init(url);
185
+ }
186
+ async init(connectionString) {
187
+ // ivfflat.probes is a per-SESSION GUC. At lists=100 (pglite-schema.ts) the
188
+ // default probes=1 scans a single cluster and misses neighbors; probes=5
189
+ // trades ~10-20ms for ~5x better top-K recall in our 1K–100K engram range.
190
+ // Tunable via AWM_IVFFLAT_PROBES.
191
+ const probes = parseInt(process.env.AWM_IVFFLAT_PROBES ?? '5', 10);
192
+ // Bootstrap on a DEDICATED client, BEFORE the pool opens any connection:
193
+ // (1) run the schema DDL (shared with PGlite; CREATE EXTENSION vector is the
194
+ // first statement — pgvector image / superuser provides it), and
195
+ // (2) persist ivfflat.probes as a per-DATABASE default so every pooled
196
+ // connection inherits it at startup with NO per-connection query.
197
+ // The earlier approach (a pool 'connect' hook firing an un-awaited `SET`)
198
+ // raced the pool handing the same client to the real query → "client is
199
+ // already executing a query". A per-database default removes that path
200
+ // entirely. ivfflat.probes is a dotted (placeholder) GUC, so ALTER DATABASE
201
+ // accepts it even though the vector module isn't loaded in the bootstrap
202
+ // session; new sessions apply it once the module loads.
203
+ const boot = new Client({ connectionString });
204
+ await boot.connect();
205
+ try {
206
+ // Serialize bootstrap across ALL connections/processes. Concurrent
207
+ // CREATE EXTENSION / CREATE TABLE IF NOT EXISTS / ALTER DATABASE on shared
208
+ // system catalogs race in Postgres ("tuple concurrently updated", duplicate
209
+ // pg_type rows) — MWA news up a MwaMemory per serve endpoint, so two stores
210
+ // can bootstrap at once. A session-level advisory lock makes only one run
211
+ // the DDL at a time; the rest wait, then see everything already exists.
212
+ // (Auto-released on boot.end() / disconnect.)
213
+ await boot.query('SELECT pg_advisory_lock($1)', [4915001]); // const key: AWM schema bootstrap
214
+ await boot.query(PGLITE_SCHEMA_DDL);
215
+ if (probes > 1) {
216
+ const { rows } = await boot.query('SELECT current_database() AS db');
217
+ const db = String(rows[0]?.db ?? '');
218
+ if (db) {
219
+ try {
220
+ await boot.query(`ALTER DATABASE "${db.replace(/"/g, '""')}" SET ivfflat.probes = ${probes}`);
221
+ }
222
+ catch { /* best-effort: recall still works at the default probes=1 */ }
223
+ }
224
+ }
225
+ }
226
+ finally {
227
+ await boot.end();
228
+ }
229
+ // All pooled connections are created AFTER the per-database default is in
230
+ // place, so they inherit ivfflat.probes at startup — no connect hook needed.
231
+ this.pool = new Pool({ connectionString, max: Number(process.env.AWM_PG_POOL_MAX ?? 10) });
232
+ // A swallowed pool error event would otherwise crash the process on an idle
233
+ // client disconnect. Log-and-continue; the next query re-establishes.
234
+ this.pool.on('error', () => { });
235
+ // Periodic flush for batched activation events.
236
+ this.activationFlushTimer = setInterval(() => { void this.flushActivationEvents().catch(() => { }); }, PostgresEngramStore.ACTIVATION_FLUSH_INTERVAL_MS);
237
+ }
238
+ /**
239
+ * Single funnel for every data query. Routes to the tx client bound in the
240
+ * `txCtx` AsyncLocalStorage scope when called inside a withTransaction() callback
241
+ * (so inner store calls join the tx), else to the pool. Returns the same `{ rows }`
242
+ * shape the PGlite adapter relies on.
243
+ */
244
+ q(sql, params) {
245
+ return (this.txCtx.getStore() ?? this.pool).query(sql, params);
246
+ }
247
+ async ready() { return this.readyPromise; }
248
+ async close() {
249
+ await this.readyPromise;
250
+ if (this.activationFlushTimer) {
251
+ clearInterval(this.activationFlushTimer);
252
+ this.activationFlushTimer = null;
253
+ }
254
+ await this.flushActivationEvents();
255
+ await this.pool.end();
256
+ }
257
+ /**
258
+ * Flush queued activation events as a single multi-row INSERT.
259
+ * Idempotent — safe to call when the buffer is empty.
260
+ */
261
+ async flushActivationEvents() {
262
+ if (this.activationEventBuffer.length === 0)
263
+ return;
264
+ const batch = this.activationEventBuffer.splice(0);
265
+ const values = [];
266
+ const params = [];
267
+ for (let i = 0; i < batch.length; i++) {
268
+ const e = batch[i];
269
+ const base = i * 8;
270
+ values.push(`($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8})`);
271
+ params.push(e.id, e.agentId, e.timestamp.toISOString(), e.context, e.resultsReturned, e.topScore, e.latencyMs, JSON.stringify(e.engramIds));
272
+ }
273
+ try {
274
+ // ALWAYS the pool, never this.q(): this background flush is fire-and-forget and must
275
+ // not route to a transaction's client (a future caller that logs an activation inside a
276
+ // withTransaction would otherwise collide with the tx's in-flight query on one pg client).
277
+ await this.pool.query(`INSERT INTO activation_events (id, agent_id, timestamp, context, results_returned, top_score, latency_ms, engram_ids)
278
+ VALUES ${values.join(',')}`, params);
279
+ }
280
+ catch {
281
+ // Drop the batch on failure — eval data, not state.
282
+ }
283
+ }
284
+ /**
285
+ * Async-aware transaction wrapper that matches IEngramStore.withTransaction.
286
+ *
287
+ * Checks out ONE dedicated client and binds it to the `txCtx` AsyncLocalStorage
288
+ * scope for the duration of `fn`, so every regular store method `fn` calls — they
289
+ * all funnel through `this.q()`, which reads `txCtx.getStore()` — routes to that
290
+ * same client and joins the transaction (a Pool would otherwise hand each query a
291
+ * different connection). Mirrors PGlite's single-connection serialization. Not
292
+ * re-entrant (no nested BEGIN), same as the PGlite adapter.
293
+ */
294
+ async withTransaction(fn) {
295
+ await this.readyPromise;
296
+ const client = await this.pool.connect();
297
+ try {
298
+ await client.query('BEGIN');
299
+ // Run fn inside the async context that binds q() to THIS client, so every
300
+ // store call fn makes joins the transaction; callers outside this context
301
+ // (background flush, concurrent recall) keep using the pool.
302
+ const result = await this.txCtx.run(client, fn);
303
+ await client.query('COMMIT');
304
+ return result;
305
+ }
306
+ catch (err) {
307
+ try {
308
+ await client.query('ROLLBACK');
309
+ }
310
+ catch { /* best-effort */ }
311
+ throw err;
312
+ }
313
+ finally {
314
+ client.release();
315
+ }
316
+ }
317
+ // ============================================================
318
+ // Engram CRUD
319
+ // ============================================================
320
+ async createEngram(input) {
321
+ await this.readyPromise;
322
+ const id = input.id ?? randomUUID();
323
+ const now = new Date().toISOString();
324
+ await this.q(`INSERT INTO engrams (
325
+ id, agent_id, concept, content, embedding, embedding_model,
326
+ confidence, salience, access_count, last_accessed, created_at,
327
+ salience_features, reason_codes, stage, ttl, retracted,
328
+ tags, memory_type, memory_class, supersedes, episode_id,
329
+ task_status, task_priority, blocked_by, sequence, references_json
330
+ ) VALUES (
331
+ $1, $2, $3, $4, $5::vector, $6,
332
+ $7, $8, 0, $9, $10,
333
+ $11, $12, 'active', $13, FALSE,
334
+ $14, $15, $16, $17, $18,
335
+ $19, $20, $21, $22, $23
336
+ )`, [
337
+ id,
338
+ input.agentId,
339
+ input.concept,
340
+ input.content,
341
+ vectorToLiteral(input.embedding ?? null),
342
+ input.embeddingModel ?? null,
343
+ input.confidence ?? 0.5,
344
+ input.salience ?? 0.5,
345
+ now, now,
346
+ JSON.stringify(input.salienceFeatures ?? {}),
347
+ JSON.stringify(input.reasonCodes ?? []),
348
+ input.ttl ?? null,
349
+ JSON.stringify(input.tags ?? []),
350
+ input.memoryType ?? 'unclassified',
351
+ input.memoryClass ?? 'working',
352
+ input.supersedes ?? null,
353
+ input.episodeId ?? null,
354
+ input.taskStatus ?? null,
355
+ input.taskPriority ?? null,
356
+ input.blockedBy ?? null,
357
+ input.sequence ?? null,
358
+ input.references && input.references.length > 0
359
+ ? JSON.stringify(input.references) : null,
360
+ ]);
361
+ const row = await this.getEngram(id);
362
+ if (!row)
363
+ throw new Error(`createEngram: row ${id} not found after insert`);
364
+ return row;
365
+ }
366
+ async getEngram(id) {
367
+ await this.readyPromise;
368
+ const result = await this.q(`SELECT * FROM engrams WHERE id = $1`, [id]);
369
+ if (result.rows.length === 0)
370
+ return null;
371
+ return rowToEngram(result.rows[0]);
372
+ }
373
+ async getEngramsByAgent(agentId, stage, includeRetracted = false) {
374
+ await this.readyPromise;
375
+ let sql = `SELECT * FROM engrams WHERE agent_id = $1`;
376
+ const params = [agentId];
377
+ if (stage) {
378
+ sql += ` AND stage = $${params.length + 1}`;
379
+ params.push(stage);
380
+ }
381
+ if (!includeRetracted)
382
+ sql += ` AND retracted = FALSE`;
383
+ sql += ` ORDER BY created_at DESC`;
384
+ const result = await this.q(sql, params);
385
+ return result.rows.map(rowToEngram);
386
+ }
387
+ async getEngramsByAgentSlim(agentId, stage, includeRetracted = false) {
388
+ await this.readyPromise;
389
+ let sql = `SELECT id, concept, embedding FROM engrams WHERE agent_id = $1`;
390
+ const params = [agentId];
391
+ if (stage) {
392
+ sql += ` AND stage = $${params.length + 1}`;
393
+ params.push(stage);
394
+ }
395
+ if (!includeRetracted)
396
+ sql += ` AND retracted = FALSE`;
397
+ const result = await this.q(sql, params);
398
+ return result.rows.map((r) => ({
399
+ id: r.id,
400
+ concept: r.concept,
401
+ embedding: literalToVector(r.embedding),
402
+ }));
403
+ }
404
+ async getEngramsByAgentsSlim(agentIds, stage, includeRetracted = false) {
405
+ if (agentIds.length === 0)
406
+ return [];
407
+ if (agentIds.length === 1)
408
+ return this.getEngramsByAgentSlim(agentIds[0], stage, includeRetracted);
409
+ await this.readyPromise;
410
+ let sql = `SELECT id, concept, embedding FROM engrams WHERE agent_id = ANY($1::text[])`;
411
+ const params = [agentIds];
412
+ if (stage) {
413
+ sql += ` AND stage = $${params.length + 1}`;
414
+ params.push(stage);
415
+ }
416
+ if (!includeRetracted)
417
+ sql += ` AND retracted = FALSE`;
418
+ const result = await this.q(sql, params);
419
+ return result.rows.map((r) => ({
420
+ id: r.id,
421
+ concept: r.concept,
422
+ embedding: literalToVector(r.embedding),
423
+ }));
424
+ }
425
+ async getEngramsByIds(ids) {
426
+ if (ids.length === 0)
427
+ return [];
428
+ await this.readyPromise;
429
+ const result = await this.q(`SELECT * FROM engrams WHERE id = ANY($1::text[])`, [ids]);
430
+ return result.rows.map(rowToEngram);
431
+ }
432
+ async getEngramsByAgents(agentIds, stage, includeRetracted = false) {
433
+ if (agentIds.length === 0)
434
+ return [];
435
+ if (agentIds.length === 1)
436
+ return this.getEngramsByAgent(agentIds[0], stage, includeRetracted);
437
+ await this.readyPromise;
438
+ let sql = `SELECT * FROM engrams WHERE agent_id = ANY($1::text[])`;
439
+ const params = [agentIds];
440
+ if (stage) {
441
+ sql += ` AND stage = $${params.length + 1}`;
442
+ params.push(stage);
443
+ }
444
+ if (!includeRetracted)
445
+ sql += ` AND retracted = FALSE`;
446
+ const result = await this.q(sql, params);
447
+ return result.rows.map(rowToEngram);
448
+ }
449
+ async getWorkspaceAgentIds(agentId, workspace) {
450
+ await this.readyPromise;
451
+ try {
452
+ const result = await this.q(`SELECT DISTINCT name FROM coord_agents WHERE workspace = $1 AND status != 'dead'`, [workspace]);
453
+ const names = result.rows.map((r) => r.name);
454
+ if (!names.includes(agentId))
455
+ names.push(agentId);
456
+ return names;
457
+ }
458
+ catch {
459
+ // coord_agents isn't provisioned on Postgres — workspace/hive coordination is currently
460
+ // SQLite-only. Warn ONCE so this degrades VISIBLY (recall scoped to self) instead of silently.
461
+ if (!warnedNoCoordPg) {
462
+ warnedNoCoordPg = true;
463
+ console.warn('[awm:postgres] workspace/hive coordination is not available on the Postgres backend ' +
464
+ '(coord_agents not provisioned) — recall is scoped to THIS agent only. Hive coordination is SQLite-only for now.');
465
+ }
466
+ return [agentId];
467
+ }
468
+ }
469
+ async touchEngram(id) {
470
+ await this.readyPromise;
471
+ await this.q(`UPDATE engrams
472
+ SET access_count = access_count + 1,
473
+ last_accessed = $1,
474
+ confidence = LEAST(0.85, confidence + 0.02 / (1.0 + sqrt(access_count::float)))
475
+ WHERE id = $2`, [new Date().toISOString(), id]);
476
+ }
477
+ async updateStage(id, stage) {
478
+ await this.readyPromise;
479
+ await this.q(`UPDATE engrams SET stage = $1 WHERE id = $2`, [stage, id]);
480
+ }
481
+ /**
482
+ * Replace an engram's content. Used by the fade phase of consolidation
483
+ * (Paper 1: storage degradation) to coarsen un-recalled memories.
484
+ * The FTS trigger (BEFORE INSERT OR UPDATE OF concept, content, tags)
485
+ * automatically refreshes the tsvector index with the new content.
486
+ */
487
+ async updateContent(id, content) {
488
+ await this.readyPromise;
489
+ await this.q(`UPDATE engrams SET content = $1 WHERE id = $2`, [content, id]);
490
+ }
491
+ async updateConfidence(id, confidence) {
492
+ await this.readyPromise;
493
+ const clamped = Math.max(0, Math.min(1, confidence));
494
+ await this.q(`UPDATE engrams SET confidence = $1 WHERE id = $2`, [clamped, id]);
495
+ }
496
+ async updateEmbedding(id, embedding, modelId) {
497
+ await this.readyPromise;
498
+ if (modelId) {
499
+ await this.q(`UPDATE engrams SET embedding = $1::vector, embedding_model = $2 WHERE id = $3`, [vectorToLiteral(embedding), modelId, id]);
500
+ }
501
+ else {
502
+ await this.q(`UPDATE engrams SET embedding = $1::vector WHERE id = $2`, [vectorToLiteral(embedding), id]);
503
+ }
504
+ }
505
+ async retractEngram(id, retractedBy) {
506
+ await this.readyPromise;
507
+ await this.q(`UPDATE engrams SET retracted = TRUE, retracted_by = $1, retracted_at = $2 WHERE id = $3`, [retractedBy, new Date().toISOString(), id]);
508
+ }
509
+ async deleteEngram(id) {
510
+ await this.readyPromise;
511
+ await this.q(`DELETE FROM engrams WHERE id = $1`, [id]);
512
+ }
513
+ /**
514
+ * Time warp - shift all timestamps backward by ms milliseconds.
515
+ * Used for testing decay-dependent behavior.
516
+ */
517
+ async timeWarp(agentId, ms) {
518
+ await this.readyPromise;
519
+ const seconds = Math.round(ms / 1000);
520
+ const r1 = await this.q(`UPDATE engrams SET
521
+ created_at = to_char(($1::timestamptz - interval '1 second' * $2), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
522
+ last_accessed = to_char(($3::timestamptz - interval '1 second' * $2), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
523
+ WHERE agent_id = $4`, ['now', seconds, 'now', agentId]);
524
+ // node-postgres reports the affected-row count as `rowCount` (PGlite called it
525
+ // `affectedRows` — the one result-shape field that differs between the drivers).
526
+ return r1.rowCount ?? 0;
527
+ }
528
+ async getLatestEngram(agentId, excludeId) {
529
+ await this.readyPromise;
530
+ let sql = `SELECT * FROM engrams WHERE agent_id = $1 AND retracted = FALSE`;
531
+ const params = [agentId];
532
+ if (excludeId) {
533
+ sql += ` AND id != $${params.length + 1}`;
534
+ params.push(excludeId);
535
+ }
536
+ sql += ` ORDER BY created_at DESC LIMIT 1`;
537
+ const result = await this.q(sql, params);
538
+ return result.rows.length > 0 ? rowToEngram(result.rows[0]) : null;
539
+ }
540
+ // ============================================================
541
+ // Search
542
+ // ============================================================
543
+ async searchByVector(agentId, vec, limit = 10) {
544
+ await this.readyPromise;
545
+ // Restrict to active + fading. Faded engrams (Paper 1: storage degradation)
546
+ // retain their embedding so they still participate in semantic recall, even
547
+ // though their content has been trimmed. Excludes staging/consolidated/archived.
548
+ const result = await this.q(`SELECT *, (embedding <=> $2::vector) AS distance
549
+ FROM engrams
550
+ WHERE agent_id = $1
551
+ AND embedding IS NOT NULL
552
+ AND retracted = FALSE
553
+ AND stage IN ('active', 'fading')
554
+ ORDER BY distance ASC
555
+ LIMIT $3`, [agentId, vectorToLiteral(vec), limit]);
556
+ return result.rows.map((r) => ({ engram: rowToEngram(r), distance: r.distance }));
557
+ }
558
+ async searchBM25(agentId, query, limit = 10) {
559
+ const ranked = await this.searchBM25WithRank(agentId, query, limit);
560
+ return ranked.map((r) => r.engram);
561
+ }
562
+ async searchBM25WithRank(agentId, query, limit = 10) {
563
+ await this.readyPromise;
564
+ // SQLite FTS5 uses OR-by-default; we mirror that with websearch_to_tsquery
565
+ // and explicit OR joining. plainto_tsquery would AND all terms, missing
566
+ // documents that contain only a subset of the query (e.g., a "correction"
567
+ // engram lacking the exact word "operator" but matching "javascript",
568
+ // "equality", "type").
569
+ const tokens = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(t => t.length > 1);
570
+ if (tokens.length === 0)
571
+ return [];
572
+ const websearchQuery = tokens.join(' OR ');
573
+ const result = await this.q(`SELECT *, ts_rank_cd(fts, websearch_to_tsquery('english', $2)) AS rank
574
+ FROM engrams
575
+ WHERE agent_id = $1 AND retracted = FALSE
576
+ AND fts @@ websearch_to_tsquery('english', $2)
577
+ ORDER BY rank DESC
578
+ LIMIT $3`, [agentId, websearchQuery, limit]);
579
+ return result.rows.map((r) => ({ engram: rowToEngram(r), bm25Score: calibrateBm25(Number(r.rank)) }));
580
+ }
581
+ async searchBM25WithRankMultiAgent(agentIds, query, limit = 10) {
582
+ if (agentIds.length === 0)
583
+ return [];
584
+ if (agentIds.length === 1)
585
+ return this.searchBM25WithRank(agentIds[0], query, limit);
586
+ await this.readyPromise;
587
+ const tokens = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(t => t.length > 1);
588
+ if (tokens.length === 0)
589
+ return [];
590
+ const websearchQuery = tokens.join(' OR ');
591
+ const result = await this.q(`SELECT *, ts_rank_cd(fts, websearch_to_tsquery('english', $2)) AS rank
592
+ FROM engrams
593
+ WHERE agent_id = ANY($1::text[]) AND retracted = FALSE
594
+ AND fts @@ websearch_to_tsquery('english', $2)
595
+ ORDER BY rank DESC
596
+ LIMIT $3`, [agentIds, websearchQuery, limit]);
597
+ return result.rows.map((r) => ({ engram: rowToEngram(r), bm25Score: calibrateBm25(Number(r.rank)) }));
598
+ }
599
+ /** Deterministic search (no vector or BM25 ranking — for diagnostic / structural queries). */
600
+ async search(query) {
601
+ await this.readyPromise;
602
+ let sql = `SELECT * FROM engrams WHERE agent_id = $1`;
603
+ const params = [query.agentId];
604
+ if (query.text) {
605
+ sql += ` AND (content ILIKE $${params.length + 1} OR concept ILIKE $${params.length + 1})`;
606
+ params.push(`%${query.text}%`);
607
+ }
608
+ if (query.concept) {
609
+ sql += ` AND concept = $${params.length + 1}`;
610
+ params.push(query.concept);
611
+ }
612
+ if (query.stage) {
613
+ sql += ` AND stage = $${params.length + 1}`;
614
+ params.push(query.stage);
615
+ }
616
+ if (query.retracted !== undefined) {
617
+ sql += ` AND retracted = $${params.length + 1}`;
618
+ params.push(query.retracted);
619
+ }
620
+ const allTags = [...(query.tags ?? []), ...(query.tagsAll ?? [])];
621
+ for (const tag of allTags) {
622
+ sql += ` AND tags LIKE $${params.length + 1}`;
623
+ params.push(tagLike(tag));
624
+ }
625
+ if (query.tagsAny && query.tagsAny.length > 0) {
626
+ const ors = query.tagsAny.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
627
+ sql += ` AND (${ors})`;
628
+ for (const tag of query.tagsAny)
629
+ params.push(tagLike(tag));
630
+ }
631
+ if (query.tagsNone && query.tagsNone.length > 0) {
632
+ const ors = query.tagsNone.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
633
+ sql += ` AND NOT (${ors})`;
634
+ for (const tag of query.tagsNone)
635
+ params.push(tagLike(tag));
636
+ }
637
+ const sortCol = {
638
+ createdAt: 'created_at', sequence: 'sequence', salience: 'salience',
639
+ confidence: 'confidence', lastAccessed: 'last_accessed',
640
+ }[query.sortBy ?? 'lastAccessed'];
641
+ const dir = query.sortOrder === 'asc' ? 'ASC' : 'DESC';
642
+ if (query.sortBy === 'sequence') {
643
+ sql += ` ORDER BY (sequence IS NULL), sequence ${dir}`;
644
+ }
645
+ else {
646
+ sql += ` ORDER BY ${sortCol} ${dir}`;
647
+ }
648
+ sql += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
649
+ params.push(query.limit ?? 50, query.offset ?? 0);
650
+ const result = await this.q(sql, params);
651
+ return result.rows.map(rowToEngram);
652
+ }
653
+ // ============================================================
654
+ // Tasks
655
+ // ============================================================
656
+ async updateTaskStatus(id, status) {
657
+ await this.readyPromise;
658
+ await this.q(`UPDATE engrams SET task_status = $1 WHERE id = $2`, [status, id]);
659
+ }
660
+ async updateTaskPriority(id, priority) {
661
+ await this.readyPromise;
662
+ await this.q(`UPDATE engrams SET task_priority = $1 WHERE id = $2`, [priority, id]);
663
+ }
664
+ async updateBlockedBy(id, blockedBy) {
665
+ await this.readyPromise;
666
+ await this.q(`UPDATE engrams SET blocked_by = $1, task_status = $2 WHERE id = $3`, [blockedBy, blockedBy ? 'blocked' : 'open', id]);
667
+ }
668
+ async getTasks(agentId, status) {
669
+ await this.readyPromise;
670
+ let sql = `SELECT * FROM engrams WHERE agent_id = $1 AND task_status IS NOT NULL AND retracted = FALSE`;
671
+ const params = [agentId];
672
+ if (status) {
673
+ sql += ` AND task_status = $${params.length + 1}`;
674
+ params.push(status);
675
+ }
676
+ sql += ` ORDER BY
677
+ CASE task_priority
678
+ WHEN 'urgent' THEN 0
679
+ WHEN 'high' THEN 1
680
+ WHEN 'medium' THEN 2
681
+ WHEN 'low' THEN 3
682
+ ELSE 4
683
+ END,
684
+ created_at DESC`;
685
+ const result = await this.q(sql, params);
686
+ return result.rows.map(rowToEngram);
687
+ }
688
+ async getNextTask(agentId) {
689
+ await this.readyPromise;
690
+ const result = await this.q(`SELECT * FROM engrams
691
+ WHERE agent_id = $1 AND task_status IN ('open', 'in_progress') AND retracted = FALSE
692
+ ORDER BY
693
+ CASE task_status WHEN 'in_progress' THEN 0 ELSE 1 END,
694
+ CASE task_priority
695
+ WHEN 'urgent' THEN 0
696
+ WHEN 'high' THEN 1
697
+ WHEN 'medium' THEN 2
698
+ WHEN 'low' THEN 3
699
+ ELSE 4
700
+ END,
701
+ created_at ASC
702
+ LIMIT 1`, [agentId]);
703
+ return result.rows.length > 0 ? rowToEngram(result.rows[0]) : null;
704
+ }
705
+ // ============================================================
706
+ // Supersession & tags
707
+ // ============================================================
708
+ async supersedeEngram(oldId, newId) {
709
+ await this.readyPromise;
710
+ await this.q(`UPDATE engrams SET superseded_by = $1 WHERE id = $2`, [newId, oldId]);
711
+ await this.q(`UPDATE engrams SET supersedes = $1 WHERE id = $2`, [oldId, newId]);
712
+ }
713
+ async findActiveMatchByConcept(agentId, concept, requiredTags) {
714
+ await this.readyPromise;
715
+ let sql = `SELECT * FROM engrams
716
+ WHERE agent_id = $1
717
+ AND LOWER(TRIM(concept)) = LOWER(TRIM($2))
718
+ AND stage = 'active'
719
+ AND retracted = FALSE
720
+ AND superseded_by IS NULL`;
721
+ const params = [agentId, concept];
722
+ if (requiredTags && requiredTags.length > 0) {
723
+ for (const tag of requiredTags) {
724
+ sql += ` AND tags LIKE $${params.length + 1}`;
725
+ params.push(tagLike(tag));
726
+ }
727
+ }
728
+ sql += ` ORDER BY created_at DESC LIMIT 1`;
729
+ const result = await this.q(sql, params);
730
+ return result.rows.length > 0 ? rowToEngram(result.rows[0]) : null;
731
+ }
732
+ async isSuperseded(id) {
733
+ await this.readyPromise;
734
+ const result = await this.q(`SELECT superseded_by FROM engrams WHERE id = $1`, [id]);
735
+ return result.rows.length > 0 && result.rows[0].superseded_by != null;
736
+ }
737
+ async updateMemoryClass(id, memoryClass) {
738
+ await this.readyPromise;
739
+ await this.q(`UPDATE engrams SET memory_class = $1 WHERE id = $2`, [memoryClass, id]);
740
+ }
741
+ async updateTags(id, tags) {
742
+ await this.readyPromise;
743
+ await this.q(`UPDATE engrams SET tags = $1 WHERE id = $2`, [JSON.stringify(tags), id]);
744
+ }
745
+ // ============================================================
746
+ // Associations
747
+ // ============================================================
748
+ async upsertAssociation(fromId, toId, weight, type = 'hebbian', confidence = 0.5) {
749
+ await this.readyPromise;
750
+ const id = randomUUID();
751
+ const now = new Date().toISOString();
752
+ await this.q(`INSERT INTO associations (id, from_engram_id, to_engram_id, weight, confidence, type, activation_count, created_at, last_activated)
753
+ VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $7)
754
+ ON CONFLICT (from_engram_id, to_engram_id) DO UPDATE SET
755
+ weight = EXCLUDED.weight,
756
+ confidence = EXCLUDED.confidence,
757
+ last_activated = EXCLUDED.last_activated,
758
+ activation_count = associations.activation_count + 1`, [id, fromId, toId, weight, confidence, type, now]);
759
+ const assoc = await this.getAssociation(fromId, toId);
760
+ if (!assoc)
761
+ throw new Error('upsertAssociation: row not found after insert');
762
+ return assoc;
763
+ }
764
+ async getAssociation(fromId, toId) {
765
+ await this.readyPromise;
766
+ const result = await this.q(`SELECT * FROM associations WHERE from_engram_id = $1 AND to_engram_id = $2`, [fromId, toId]);
767
+ return result.rows.length > 0 ? rowToAssociation(result.rows[0]) : null;
768
+ }
769
+ async getAssociationsFor(engramId) {
770
+ await this.readyPromise;
771
+ const result = await this.q(`SELECT * FROM associations WHERE from_engram_id = $1 OR to_engram_id = $1`, [engramId]);
772
+ return result.rows.map(rowToAssociation);
773
+ }
774
+ async getAssociationStatsForBatch(engramIds) {
775
+ const result = new Map();
776
+ if (engramIds.length === 0)
777
+ return result;
778
+ await this.readyPromise;
779
+ const r = await this.q(`SELECT id, SUM(cnt) AS count, SUM(sw) AS sum_weight FROM (
780
+ SELECT from_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE from_engram_id = ANY($1::text[])
781
+ UNION ALL
782
+ SELECT to_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE to_engram_id = ANY($1::text[])
783
+ ) t
784
+ WHERE id = ANY($1::text[])
785
+ GROUP BY id`, [engramIds]);
786
+ for (const row of r.rows) {
787
+ result.set(row.id, { count: Number(row.count), sumWeight: Number(row.sum_weight) });
788
+ }
789
+ for (const id of engramIds) {
790
+ if (!result.has(id))
791
+ result.set(id, { count: 0, sumWeight: 0 });
792
+ }
793
+ return result;
794
+ }
795
+ async getAssociationsForBatch(engramIds) {
796
+ const result = new Map();
797
+ if (engramIds.length === 0)
798
+ return result;
799
+ await this.readyPromise;
800
+ const r = await this.q(`SELECT * FROM associations
801
+ WHERE from_engram_id = ANY($1::text[]) OR to_engram_id = ANY($1::text[])`, [engramIds]);
802
+ for (const row of r.rows) {
803
+ const a = rowToAssociation(row);
804
+ const fromList = result.get(a.fromEngramId) ?? [];
805
+ fromList.push(a);
806
+ result.set(a.fromEngramId, fromList);
807
+ if (a.toEngramId !== a.fromEngramId) {
808
+ const toList = result.get(a.toEngramId) ?? [];
809
+ toList.push(a);
810
+ result.set(a.toEngramId, toList);
811
+ }
812
+ }
813
+ for (const id of engramIds) {
814
+ if (!result.has(id))
815
+ result.set(id, []);
816
+ }
817
+ return result;
818
+ }
819
+ async getOutgoingAssociations(engramId) {
820
+ await this.readyPromise;
821
+ const result = await this.q(`SELECT * FROM associations WHERE from_engram_id = $1`, [engramId]);
822
+ return result.rows.map(rowToAssociation);
823
+ }
824
+ async countAssociationsFor(engramId) {
825
+ await this.readyPromise;
826
+ const result = await this.q(`SELECT COUNT(*) AS count FROM associations WHERE from_engram_id = $1`, [engramId]);
827
+ return Number(result.rows[0]?.count ?? 0);
828
+ }
829
+ async getWeakestAssociation(engramId) {
830
+ await this.readyPromise;
831
+ const result = await this.q(`SELECT * FROM associations WHERE from_engram_id = $1 ORDER BY weight ASC LIMIT 1`, [engramId]);
832
+ return result.rows.length > 0 ? rowToAssociation(result.rows[0]) : null;
833
+ }
834
+ async deleteAssociation(id) {
835
+ await this.readyPromise;
836
+ await this.q(`DELETE FROM associations WHERE id = $1`, [id]);
837
+ }
838
+ async getAllAssociations(agentId) {
839
+ await this.readyPromise;
840
+ const result = await this.q(`SELECT a.* FROM associations a
841
+ JOIN engrams e ON a.from_engram_id = e.id
842
+ WHERE e.agent_id = $1`, [agentId]);
843
+ return result.rows.map(rowToAssociation);
844
+ }
845
+ // ============================================================
846
+ // Eviction & counts
847
+ // ============================================================
848
+ async getEvictionCandidates(agentId, limit) {
849
+ await this.readyPromise;
850
+ const result = await this.q(`SELECT * FROM engrams
851
+ WHERE agent_id = $1 AND stage = 'active' AND retracted = FALSE
852
+ ORDER BY (salience * 0.3 + confidence * 0.3
853
+ + (access_count::float / (access_count + 5)) * 0.2
854
+ + (1.0 / (1.0 + EXTRACT(EPOCH FROM (now() - last_accessed::timestamptz)) / 86400.0)) * 0.2) ASC
855
+ LIMIT $2`, [agentId, limit]);
856
+ return result.rows.map(rowToEngram);
857
+ }
858
+ async getActiveCount(agentId) {
859
+ await this.readyPromise;
860
+ const result = await this.q(`SELECT COUNT(*) AS count FROM engrams WHERE agent_id = $1 AND stage = 'active'`, [agentId]);
861
+ return Number(result.rows[0]?.count ?? 0);
862
+ }
863
+ async getStagingCount(agentId) {
864
+ await this.readyPromise;
865
+ const result = await this.q(`SELECT COUNT(*) AS count FROM engrams WHERE agent_id = $1 AND stage = 'staging'`, [agentId]);
866
+ return Number(result.rows[0]?.count ?? 0);
867
+ }
868
+ async getExpiredStaging() {
869
+ await this.readyPromise;
870
+ const result = await this.q(`SELECT * FROM engrams WHERE stage = 'staging' AND ttl IS NOT NULL`);
871
+ const now = Date.now();
872
+ return result.rows
873
+ .map(rowToEngram)
874
+ .filter((e) => e.ttl && (e.createdAt.getTime() + e.ttl) < now);
875
+ }
876
+ // ============================================================
877
+ // Eval logging
878
+ // ============================================================
879
+ async logActivationEvent(event) {
880
+ // Queue rather than write synchronously — removes activation INSERT from
881
+ // the recall hot path. Flushed on timer (5s) or when buffer hits 100.
882
+ this.activationEventBuffer.push(event);
883
+ if (this.activationEventBuffer.length >= PostgresEngramStore.ACTIVATION_FLUSH_BATCH_SIZE) {
884
+ void this.flushActivationEvents().catch(() => { });
885
+ }
886
+ }
887
+ async logStagingEvent(event) {
888
+ await this.readyPromise;
889
+ await this.q(`INSERT INTO staging_events (engram_id, agent_id, action, resonance_score, timestamp, age_ms)
890
+ VALUES ($1, $2, $3, $4, $5, $6)`, [
891
+ event.engramId, event.agentId, event.action,
892
+ event.resonanceScore, event.timestamp.toISOString(), event.ageMs,
893
+ ]);
894
+ }
895
+ async logRetrievalFeedback(activationEventId, engramId, useful, context) {
896
+ await this.readyPromise;
897
+ await this.q(`INSERT INTO retrieval_feedback (id, activation_event_id, engram_id, useful, context, timestamp)
898
+ VALUES ($1, $2, $3, $4, $5, $6)`, [randomUUID(), activationEventId, engramId, useful, context, new Date().toISOString()]);
899
+ }
900
+ async getRetrievalPrecision(agentId, windowHours = 24) {
901
+ await this.readyPromise;
902
+ const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
903
+ const result = await this.q(`SELECT
904
+ COUNT(CASE WHEN useful = TRUE THEN 1 END) AS useful_count,
905
+ COUNT(*) AS total_count
906
+ FROM retrieval_feedback rf
907
+ LEFT JOIN activation_events ae ON rf.activation_event_id = ae.id
908
+ JOIN engrams e ON rf.engram_id = e.id
909
+ WHERE e.agent_id = $1 AND rf.timestamp > $2`, [agentId, since]);
910
+ const row = result.rows[0];
911
+ const total = Number(row?.total_count ?? 0);
912
+ const useful = Number(row?.useful_count ?? 0);
913
+ return total > 0 ? useful / total : 0;
914
+ }
915
+ async getStagingMetrics(agentId) {
916
+ await this.readyPromise;
917
+ const result = await this.q(`SELECT
918
+ COUNT(CASE WHEN action = 'promoted' THEN 1 END) AS promoted,
919
+ COUNT(CASE WHEN action = 'discarded' THEN 1 END) AS discarded,
920
+ COUNT(CASE WHEN action = 'expired' THEN 1 END) AS expired
921
+ FROM staging_events WHERE agent_id = $1`, [agentId]);
922
+ const row = result.rows[0] ?? { promoted: 0, discarded: 0, expired: 0 };
923
+ return {
924
+ promoted: Number(row.promoted),
925
+ discarded: Number(row.discarded),
926
+ expired: Number(row.expired),
927
+ };
928
+ }
929
+ async getActivationStats(agentId, windowHours = 24) {
930
+ await this.readyPromise;
931
+ // Flush any buffered activation events so stats reflect the latest writes.
932
+ await this.flushActivationEvents();
933
+ const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
934
+ const result = await this.q(`SELECT latency_ms FROM activation_events
935
+ WHERE agent_id = $1 AND timestamp > $2
936
+ ORDER BY latency_ms ASC`, [agentId, since]);
937
+ if (result.rows.length === 0)
938
+ return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
939
+ const latencies = result.rows.map((r) => Number(r.latency_ms));
940
+ const total = latencies.reduce((s, l) => s + l, 0);
941
+ const p95Idx = Math.min(Math.floor(latencies.length * 0.95), latencies.length - 1);
942
+ return {
943
+ count: latencies.length,
944
+ avgLatencyMs: total / latencies.length,
945
+ p95LatencyMs: latencies[p95Idx],
946
+ };
947
+ }
948
+ async getConsolidatedCount(agentId) {
949
+ await this.readyPromise;
950
+ const result = await this.q(`SELECT COUNT(*) AS cnt FROM engrams WHERE agent_id = $1 AND stage = 'consolidated'`, [agentId]);
951
+ return Number(result.rows[0]?.cnt ?? 0);
952
+ }
953
+ // ============================================================
954
+ // Episodes
955
+ // ============================================================
956
+ async createEpisode(input) {
957
+ await this.readyPromise;
958
+ const id = randomUUID();
959
+ const now = new Date().toISOString();
960
+ await this.q(`INSERT INTO episodes (id, agent_id, label, embedding, engram_count, start_time, end_time, created_at)
961
+ VALUES ($1, $2, $3, $4::vector, 0, $5, $5, $5)`, [id, input.agentId, input.label, vectorToLiteral(input.embedding ?? null), now]);
962
+ const ep = await this.getEpisode(id);
963
+ if (!ep)
964
+ throw new Error('createEpisode: row not found after insert');
965
+ return ep;
966
+ }
967
+ async getEpisode(id) {
968
+ await this.readyPromise;
969
+ const result = await this.q(`SELECT * FROM episodes WHERE id = $1`, [id]);
970
+ return result.rows.length > 0 ? rowToEpisode(result.rows[0]) : null;
971
+ }
972
+ async getEpisodesByAgent(agentId) {
973
+ await this.readyPromise;
974
+ const result = await this.q(`SELECT * FROM episodes WHERE agent_id = $1 ORDER BY end_time DESC`, [agentId]);
975
+ return result.rows.map(rowToEpisode);
976
+ }
977
+ async getActiveEpisode(agentId, windowMs = 3600_000) {
978
+ await this.readyPromise;
979
+ const cutoff = new Date(Date.now() - windowMs).toISOString();
980
+ const result = await this.q(`SELECT * FROM episodes WHERE agent_id = $1 AND end_time > $2 ORDER BY end_time DESC LIMIT 1`, [agentId, cutoff]);
981
+ return result.rows.length > 0 ? rowToEpisode(result.rows[0]) : null;
982
+ }
983
+ async addEngramToEpisode(engramId, episodeId) {
984
+ await this.readyPromise;
985
+ await this.q(`UPDATE engrams SET episode_id = $1 WHERE id = $2`, [episodeId, engramId]);
986
+ await this.q(`UPDATE episodes SET
987
+ engram_count = engram_count + 1,
988
+ end_time = GREATEST(end_time, $1)
989
+ WHERE id = $2`, [new Date().toISOString(), episodeId]);
990
+ }
991
+ async getEngramsByEpisode(episodeId) {
992
+ await this.readyPromise;
993
+ const result = await this.q(`SELECT * FROM engrams WHERE episode_id = $1 AND retracted = FALSE ORDER BY created_at ASC`, [episodeId]);
994
+ return result.rows.map(rowToEngram);
995
+ }
996
+ async updateEpisodeEmbedding(id, embedding) {
997
+ await this.readyPromise;
998
+ await this.q(`UPDATE episodes SET embedding = $1::vector WHERE id = $2`, [vectorToLiteral(embedding), id]);
999
+ }
1000
+ async getEpisodeCount(agentId) {
1001
+ await this.readyPromise;
1002
+ const result = await this.q(`SELECT COUNT(*) AS cnt FROM episodes WHERE agent_id = $1`, [agentId]);
1003
+ return Number(result.rows[0]?.cnt ?? 0);
1004
+ }
1005
+ // ============================================================
1006
+ // Tags lookup
1007
+ // ============================================================
1008
+ async findEngramsByTags(agentId, tags, excludeIds) {
1009
+ if (tags.length === 0)
1010
+ return [];
1011
+ await this.readyPromise;
1012
+ const conditions = tags.map((_, i) => `tags LIKE $${i + 2}`).join(' OR ');
1013
+ const params = [agentId, ...tags.map(tagLike)];
1014
+ const sql = `SELECT * FROM engrams WHERE agent_id = $1 AND retracted = FALSE AND (${conditions})`;
1015
+ const result = await this.q(sql, params);
1016
+ const engrams = result.rows.map(rowToEngram);
1017
+ if (excludeIds)
1018
+ return engrams.filter((e) => !excludeIds.has(e.id));
1019
+ return engrams;
1020
+ }
1021
+ // ============================================================
1022
+ // Checkpointing & conscious state
1023
+ // ============================================================
1024
+ async updateAutoCheckpointWrite(agentId, engramId) {
1025
+ await this.readyPromise;
1026
+ const now = new Date().toISOString();
1027
+ await this.q(`INSERT INTO conscious_state (agent_id, last_write_id, last_activity_at, write_count_since_consolidation, updated_at)
1028
+ VALUES ($1, $2, $3, 1, $3)
1029
+ ON CONFLICT(agent_id) DO UPDATE SET
1030
+ last_write_id = EXCLUDED.last_write_id,
1031
+ last_activity_at = EXCLUDED.last_activity_at,
1032
+ write_count_since_consolidation = conscious_state.write_count_since_consolidation + 1,
1033
+ updated_at = EXCLUDED.updated_at`, [agentId, engramId, now]);
1034
+ }
1035
+ async updateAutoCheckpointRecall(agentId, context, engramIds) {
1036
+ await this.readyPromise;
1037
+ const now = new Date().toISOString();
1038
+ await this.q(`INSERT INTO conscious_state (agent_id, last_recall_context, last_recall_ids, last_activity_at, recall_count_since_consolidation, updated_at)
1039
+ VALUES ($1, $2, $3, $4, 1, $4)
1040
+ ON CONFLICT(agent_id) DO UPDATE SET
1041
+ last_recall_context = EXCLUDED.last_recall_context,
1042
+ last_recall_ids = EXCLUDED.last_recall_ids,
1043
+ last_activity_at = EXCLUDED.last_activity_at,
1044
+ recall_count_since_consolidation = conscious_state.recall_count_since_consolidation + 1,
1045
+ updated_at = EXCLUDED.updated_at`, [agentId, context, JSON.stringify(engramIds), now]);
1046
+ }
1047
+ async touchActivity(agentId) {
1048
+ await this.readyPromise;
1049
+ const now = new Date().toISOString();
1050
+ await this.q(`INSERT INTO conscious_state (agent_id, last_activity_at, updated_at)
1051
+ VALUES ($1, $2, $2)
1052
+ ON CONFLICT(agent_id) DO UPDATE SET
1053
+ last_activity_at = EXCLUDED.last_activity_at,
1054
+ updated_at = EXCLUDED.updated_at`, [agentId, now]);
1055
+ }
1056
+ async saveCheckpoint(agentId, state) {
1057
+ await this.readyPromise;
1058
+ const now = new Date().toISOString();
1059
+ await this.q(`INSERT INTO conscious_state (agent_id, execution_state, checkpoint_at, last_activity_at, updated_at)
1060
+ VALUES ($1, $2, $3, $3, $3)
1061
+ ON CONFLICT(agent_id) DO UPDATE SET
1062
+ execution_state = EXCLUDED.execution_state,
1063
+ checkpoint_at = EXCLUDED.checkpoint_at,
1064
+ last_activity_at = EXCLUDED.last_activity_at,
1065
+ updated_at = EXCLUDED.updated_at`, [agentId, JSON.stringify(state), now]);
1066
+ }
1067
+ async getCheckpoint(agentId) {
1068
+ await this.readyPromise;
1069
+ const result = await this.q(`SELECT * FROM conscious_state WHERE agent_id = $1`, [agentId]);
1070
+ if (result.rows.length === 0)
1071
+ return null;
1072
+ const row = result.rows[0];
1073
+ return {
1074
+ agentId: row.agent_id,
1075
+ auto: {
1076
+ lastWriteId: row.last_write_id ?? null,
1077
+ lastRecallContext: row.last_recall_context ?? null,
1078
+ lastRecallIds: JSON.parse(row.last_recall_ids || '[]'),
1079
+ lastActivityAt: new Date(row.last_activity_at),
1080
+ writeCountSinceConsolidation: row.write_count_since_consolidation,
1081
+ recallCountSinceConsolidation: row.recall_count_since_consolidation,
1082
+ },
1083
+ executionState: row.execution_state ? JSON.parse(row.execution_state) : null,
1084
+ checkpointAt: row.checkpoint_at ? new Date(row.checkpoint_at) : null,
1085
+ lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
1086
+ lastMiniConsolidationAt: row.last_mini_consolidation_at ? new Date(row.last_mini_consolidation_at) : null,
1087
+ updatedAt: new Date(row.updated_at),
1088
+ };
1089
+ }
1090
+ async markConsolidation(agentId, mini) {
1091
+ await this.readyPromise;
1092
+ const now = new Date().toISOString();
1093
+ if (mini) {
1094
+ await this.q(`UPDATE conscious_state SET last_mini_consolidation_at = $1, updated_at = $1 WHERE agent_id = $2`, [now, agentId]);
1095
+ }
1096
+ else {
1097
+ await this.q(`UPDATE conscious_state SET
1098
+ last_consolidation_at = $1,
1099
+ last_mini_consolidation_at = $1,
1100
+ write_count_since_consolidation = 0,
1101
+ recall_count_since_consolidation = 0,
1102
+ consolidation_cycle_count = consolidation_cycle_count + 1,
1103
+ updated_at = $1
1104
+ WHERE agent_id = $2`, [now, agentId]);
1105
+ }
1106
+ }
1107
+ async getActiveAgents() {
1108
+ await this.readyPromise;
1109
+ const result = await this.q(`SELECT * FROM conscious_state`);
1110
+ return result.rows.map((row) => ({
1111
+ agentId: row.agent_id,
1112
+ lastActivityAt: new Date(row.last_activity_at),
1113
+ writeCount: row.write_count_since_consolidation,
1114
+ recallCount: row.recall_count_since_consolidation,
1115
+ lastConsolidationAt: row.last_consolidation_at ? new Date(row.last_consolidation_at) : null,
1116
+ }));
1117
+ }
1118
+ async getConsolidationCycleCount(agentId) {
1119
+ await this.readyPromise;
1120
+ const result = await this.q(`SELECT consolidation_cycle_count FROM conscious_state WHERE agent_id = $1`, [agentId]);
1121
+ return Number(result.rows[0]?.consolidation_cycle_count ?? 0);
1122
+ }
1123
+ // ============================================================
1124
+ // 0.8 Cluster C — substrate primitives
1125
+ // ============================================================
1126
+ async getLatestByTag(opts) {
1127
+ await this.readyPromise;
1128
+ let sql = `SELECT * FROM engrams
1129
+ WHERE agent_id = $1
1130
+ AND retracted = $2
1131
+ AND stage = 'active'
1132
+ AND tags LIKE $3`;
1133
+ const params = [opts.agentId, opts.retracted ?? false, `%"${opts.tagKeyPrefix}%`];
1134
+ if (opts.scopeTagsAll && opts.scopeTagsAll.length > 0) {
1135
+ for (const t of opts.scopeTagsAll) {
1136
+ sql += ` AND tags LIKE $${params.length + 1}`;
1137
+ params.push(tagLike(t));
1138
+ }
1139
+ }
1140
+ if (opts.sortBy === 'sequence')
1141
+ sql += ` AND sequence IS NOT NULL`;
1142
+ sql += ` ORDER BY ` + (opts.sortBy === 'sequence' ? 'sequence DESC, created_at DESC' : 'created_at DESC');
1143
+ const result = await this.q(sql, params);
1144
+ const engrams = result.rows.map(rowToEngram);
1145
+ const seen = new Map();
1146
+ for (const e of engrams) {
1147
+ const value = extractTagValue(e.tags, opts.tagKeyPrefix);
1148
+ if (value == null)
1149
+ continue;
1150
+ if (!seen.has(value))
1151
+ seen.set(value, e);
1152
+ }
1153
+ const out = Array.from(seen.values());
1154
+ return opts.limit ? out.slice(0, opts.limit) : out;
1155
+ }
1156
+ async getTopBy(opts) {
1157
+ await this.readyPromise;
1158
+ let sql = `SELECT * FROM engrams
1159
+ WHERE agent_id = $1
1160
+ AND retracted = $2
1161
+ AND stage = 'active'
1162
+ AND tags LIKE $3`;
1163
+ const params = [opts.agentId, opts.retracted ?? false, `%"${opts.sortField}%`];
1164
+ if (opts.filterTagsAll && opts.filterTagsAll.length > 0) {
1165
+ for (const tag of opts.filterTagsAll) {
1166
+ sql += ` AND tags LIKE $${params.length + 1}`;
1167
+ params.push(tagLike(tag));
1168
+ }
1169
+ }
1170
+ if (opts.filterTagsAny && opts.filterTagsAny.length > 0) {
1171
+ const ors = opts.filterTagsAny.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
1172
+ sql += ` AND (${ors})`;
1173
+ for (const tag of opts.filterTagsAny)
1174
+ params.push(tagLike(tag));
1175
+ }
1176
+ if (opts.filterTagsNone && opts.filterTagsNone.length > 0) {
1177
+ const ors = opts.filterTagsNone.map((_, i) => `tags LIKE $${params.length + 1 + i}`).join(' OR ');
1178
+ sql += ` AND NOT (${ors})`;
1179
+ for (const tag of opts.filterTagsNone)
1180
+ params.push(tagLike(tag));
1181
+ }
1182
+ const result = await this.q(sql, params);
1183
+ const engrams = result.rows.map(rowToEngram);
1184
+ const valued = engrams.map((e) => {
1185
+ const raw = extractTagValue(e.tags, opts.sortField);
1186
+ const n = raw == null ? NaN : Number(raw);
1187
+ return { e, n };
1188
+ });
1189
+ valued.sort((a, b) => {
1190
+ const aNaN = Number.isNaN(a.n);
1191
+ const bNaN = Number.isNaN(b.n);
1192
+ if (aNaN && bNaN)
1193
+ return 0;
1194
+ if (aNaN)
1195
+ return 1;
1196
+ if (bNaN)
1197
+ return -1;
1198
+ return opts.order === 'asc' ? a.n - b.n : b.n - a.n;
1199
+ });
1200
+ const sorted = valued.map((v) => v.e);
1201
+ return opts.limit ? sorted.slice(0, opts.limit) : sorted;
1202
+ }
1203
+ /**
1204
+ * Allocate the next sequence number for an agent (a soft ordering aid, not a
1205
+ * hard uniqueness guarantee). Runs the read inside withTransaction() to mirror
1206
+ * the PGlite adapter, but note the caller's INSERT happens AFTER this returns —
1207
+ * so two concurrent allocations can read the same MAX and collide. This
1208
+ * read-then-external-insert window is identical in the PGlite/SQLite paths; if a
1209
+ * hard guarantee is ever needed, switch to `SELECT … FOR UPDATE` or a DB sequence.
1210
+ */
1211
+ async allocateNextSequence(agentId) {
1212
+ await this.readyPromise;
1213
+ return this.withTransaction(async () => {
1214
+ const result = await this.q(`SELECT MAX(sequence) AS max_seq FROM engrams WHERE agent_id = $1`, [agentId]);
1215
+ const max = result.rows[0]?.max_seq;
1216
+ return (max != null ? Number(max) : 0) + 1;
1217
+ });
1218
+ }
1219
+ }
1220
+ export const POSTGRES_DIMENSIONS = PGLITE_VECTOR_DIMENSIONS;
1221
+ //# sourceMappingURL=postgres.js.map