agent-working-memory 0.16.1 → 0.18.1

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