agent-working-memory 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +8 -5
  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 -446
  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 +90 -13
  63. package/dist/core/reranker.js.map +1 -1
  64. package/dist/core/trust.d.ts +32 -0
  65. package/dist/core/trust.d.ts.map +1 -0
  66. package/dist/core/trust.js +66 -0
  67. package/dist/core/trust.js.map +1 -0
  68. package/dist/core/validity.d.ts +21 -0
  69. package/dist/core/validity.d.ts.map +1 -0
  70. package/dist/core/validity.js +41 -0
  71. package/dist/core/validity.js.map +1 -0
  72. package/dist/core/whoami.d.ts +3 -0
  73. package/dist/core/whoami.d.ts.map +1 -1
  74. package/dist/core/whoami.js +14 -0
  75. package/dist/core/whoami.js.map +1 -1
  76. package/dist/core/write-pipeline.d.ts +48 -0
  77. package/dist/core/write-pipeline.d.ts.map +1 -1
  78. package/dist/core/write-pipeline.js +80 -3
  79. package/dist/core/write-pipeline.js.map +1 -1
  80. package/dist/engine/activation.d.ts.map +1 -1
  81. package/dist/engine/activation.js +27 -4
  82. package/dist/engine/activation.js.map +1 -1
  83. package/dist/engine/consolidation.d.ts.map +1 -1
  84. package/dist/engine/consolidation.js +53 -4
  85. package/dist/engine/consolidation.js.map +1 -1
  86. package/dist/hooks/prime-query.d.ts +57 -0
  87. package/dist/hooks/prime-query.d.ts.map +1 -0
  88. package/dist/hooks/prime-query.js +388 -0
  89. package/dist/hooks/prime-query.js.map +1 -0
  90. package/dist/hooks/prime.d.ts +8 -0
  91. package/dist/hooks/prime.d.ts.map +1 -1
  92. package/dist/hooks/prime.js +21 -2
  93. package/dist/hooks/prime.js.map +1 -1
  94. package/dist/hooks/sidecar.d.ts +18 -0
  95. package/dist/hooks/sidecar.d.ts.map +1 -1
  96. package/dist/hooks/sidecar.js +58 -2
  97. package/dist/hooks/sidecar.js.map +1 -1
  98. package/dist/index.js +15 -3
  99. package/dist/index.js.map +1 -1
  100. package/dist/mcp.js +157 -104
  101. package/dist/mcp.js.map +1 -1
  102. package/dist/model-daemon.d.ts +8 -0
  103. package/dist/model-daemon.d.ts.map +1 -0
  104. package/dist/model-daemon.js +45 -0
  105. package/dist/model-daemon.js.map +1 -0
  106. package/dist/storage/factory.d.ts.map +1 -1
  107. package/dist/storage/factory.js +5 -0
  108. package/dist/storage/factory.js.map +1 -1
  109. package/dist/storage/pglite-schema.d.ts.map +1 -1
  110. package/dist/storage/pglite-schema.js +176 -170
  111. package/dist/storage/pglite-schema.js.map +1 -1
  112. package/dist/storage/pglite.d.ts +5 -0
  113. package/dist/storage/pglite.d.ts.map +1 -1
  114. package/dist/storage/pglite.js +165 -153
  115. package/dist/storage/pglite.js.map +1 -1
  116. package/dist/storage/postgres.d.ts +5 -0
  117. package/dist/storage/postgres.d.ts.map +1 -1
  118. package/dist/storage/postgres.js +165 -153
  119. package/dist/storage/postgres.js.map +1 -1
  120. package/dist/storage/sqlite.d.ts +22 -0
  121. package/dist/storage/sqlite.d.ts.map +1 -1
  122. package/dist/storage/sqlite.js +415 -363
  123. package/dist/storage/sqlite.js.map +1 -1
  124. package/dist/types/engram.d.ts +33 -0
  125. package/dist/types/engram.d.ts.map +1 -1
  126. package/dist/types/engram.js.map +1 -1
  127. package/package.json +1 -1
  128. package/plugin/.claude-plugin/plugin.json +1 -1
  129. package/plugin/README.md +1 -1
  130. package/plugin/bin/awm-mcp-launcher.cjs +2 -2
  131. package/plugin/skills/awm-memory/SKILL.md +11 -2
  132. package/src/adapters/common.ts +881 -802
  133. package/src/api/routes.ts +20 -0
  134. package/src/cli/migrate.ts +309 -307
  135. package/src/core/asked-value.ts +74 -0
  136. package/src/core/conflicts.ts +280 -0
  137. package/src/core/embeddings.ts +35 -10
  138. package/src/core/format-recall.ts +83 -25
  139. package/src/core/ml-worker.ts +458 -281
  140. package/src/core/model-daemon-client.ts +374 -0
  141. package/src/core/model-daemon-server.ts +344 -0
  142. package/src/core/model-daemon.ts +243 -0
  143. package/src/core/query-expander.ts +127 -126
  144. package/src/core/recall-config.ts +5 -0
  145. package/src/core/rerank-window.ts +164 -159
  146. package/src/core/rerank2.ts +143 -82
  147. package/src/core/reranker.ts +92 -12
  148. package/src/core/trust.ts +65 -0
  149. package/src/core/validity.ts +47 -0
  150. package/src/core/whoami.ts +136 -120
  151. package/src/core/write-pipeline.ts +119 -3
  152. package/src/engine/activation.ts +27 -4
  153. package/src/engine/consolidation.ts +950 -899
  154. package/src/hooks/prime-query.ts +375 -0
  155. package/src/hooks/prime.ts +25 -2
  156. package/src/hooks/sidecar.ts +57 -3
  157. package/src/index.ts +258 -248
  158. package/src/mcp.ts +1529 -1475
  159. package/src/model-daemon.ts +48 -0
  160. package/src/storage/factory.ts +25 -19
  161. package/src/storage/pglite-schema.ts +199 -193
  162. package/src/storage/pglite.ts +1513 -1498
  163. package/src/storage/postgres.ts +1613 -1598
  164. package/src/storage/sqlite.ts +2069 -2019
  165. package/src/types/engram.ts +30 -0
@@ -37,6 +37,21 @@ export class EngramStore {
37
37
  this.db.pragma('busy_timeout = 5000');
38
38
  this.db.pragma('synchronous = NORMAL');
39
39
  this.db.pragma('wal_autocheckpoint = 1000');
40
+ // Page cache and mmap (0.19). Until now the store ran on SQLite's 2 MB default
41
+ // page cache against a ~270 MB file, so the BM25 and association-stats
42
+ // phases of a recall paid page faults on every query: 42 ms / 14 ms at p50
43
+ // but 156 ms / 167 ms at p95 in the 2026-10-19 profile. 64 MB of cache
44
+ // holds the hot FTS and association pages; mmap lets reads come straight
45
+ // from the OS file cache without a copy. Both are per-connection settings
46
+ // with no effect on results; mmap is safe with WAL and multiple processes.
47
+ // AWM_SQLITE_CACHE_MB / AWM_SQLITE_MMAP_MB override (0 = SQLite default).
48
+ const cacheMb = Number(process.env.AWM_SQLITE_CACHE_MB ?? 64);
49
+ const mmapMb = Number(process.env.AWM_SQLITE_MMAP_MB ?? 256);
50
+ if (Number.isFinite(cacheMb) && cacheMb > 0)
51
+ this.db.pragma(`cache_size = ${-Math.round(cacheMb * 1024)}`);
52
+ if (Number.isFinite(mmapMb) && mmapMb > 0)
53
+ this.db.pragma(`mmap_size = ${Math.round(mmapMb * 1024 * 1024)}`);
54
+ this.db.pragma('temp_store = MEMORY');
40
55
  this.init();
41
56
  this.startWalCheckpointTimer();
42
57
  }
@@ -154,123 +169,123 @@ export class EngramStore {
154
169
  }
155
170
  }
156
171
  init() {
157
- this.db.exec(`
158
- CREATE TABLE IF NOT EXISTS engrams (
159
- id TEXT PRIMARY KEY,
160
- agent_id TEXT NOT NULL,
161
- concept TEXT NOT NULL,
162
- content TEXT NOT NULL,
163
- embedding BLOB,
164
- confidence REAL NOT NULL DEFAULT 0.5,
165
- salience REAL NOT NULL DEFAULT 0.5,
166
- access_count INTEGER NOT NULL DEFAULT 0,
167
- last_accessed TEXT NOT NULL,
168
- created_at TEXT NOT NULL,
169
- salience_features TEXT NOT NULL DEFAULT '{}',
170
- reason_codes TEXT NOT NULL DEFAULT '[]',
171
- stage TEXT NOT NULL DEFAULT 'active',
172
- ttl INTEGER,
173
- retracted INTEGER NOT NULL DEFAULT 0,
174
- retracted_by TEXT,
175
- retracted_at TEXT,
176
- tags TEXT NOT NULL DEFAULT '[]',
177
- memory_type TEXT NOT NULL DEFAULT 'unclassified'
178
- );
179
-
180
- CREATE INDEX IF NOT EXISTS idx_engrams_agent ON engrams(agent_id);
181
- CREATE INDEX IF NOT EXISTS idx_engrams_stage ON engrams(agent_id, stage);
182
- CREATE INDEX IF NOT EXISTS idx_engrams_concept ON engrams(concept);
183
- CREATE INDEX IF NOT EXISTS idx_engrams_retracted ON engrams(agent_id, retracted);
184
- -- Serves getTopAccessedTags: the recall path's pronoun branch needs the ten
185
- -- most-accessed engrams, and without this it filters then sorts the whole
186
- -- agent partition on every query containing "it", "this" or "that".
187
- CREATE INDEX IF NOT EXISTS idx_engrams_access ON engrams(agent_id, stage, access_count DESC);
188
-
189
- CREATE TABLE IF NOT EXISTS associations (
190
- id TEXT PRIMARY KEY,
191
- from_engram_id TEXT NOT NULL REFERENCES engrams(id) ON DELETE CASCADE,
192
- to_engram_id TEXT NOT NULL REFERENCES engrams(id) ON DELETE CASCADE,
193
- weight REAL NOT NULL DEFAULT 0.1,
194
- confidence REAL NOT NULL DEFAULT 0.5,
195
- type TEXT NOT NULL DEFAULT 'hebbian',
196
- activation_count INTEGER NOT NULL DEFAULT 0,
197
- created_at TEXT NOT NULL,
198
- last_activated TEXT NOT NULL
199
- );
200
-
201
- CREATE INDEX IF NOT EXISTS idx_assoc_from ON associations(from_engram_id);
202
- CREATE INDEX IF NOT EXISTS idx_assoc_to ON associations(to_engram_id);
203
- CREATE UNIQUE INDEX IF NOT EXISTS idx_assoc_pair ON associations(from_engram_id, to_engram_id);
204
-
205
- CREATE TABLE IF NOT EXISTS agents (
206
- id TEXT PRIMARY KEY,
207
- name TEXT NOT NULL,
208
- created_at TEXT NOT NULL,
209
- config TEXT NOT NULL DEFAULT '{}'
210
- );
211
-
212
- -- FTS5 for full-text search (BM25 ranking built in)
213
- CREATE VIRTUAL TABLE IF NOT EXISTS engrams_fts USING fts5(
214
- concept, content, tags,
215
- content=engrams,
216
- content_rowid=rowid
217
- );
218
-
219
- -- Triggers to keep FTS in sync
220
- CREATE TRIGGER IF NOT EXISTS engrams_ai AFTER INSERT ON engrams BEGIN
221
- INSERT INTO engrams_fts(rowid, concept, content, tags) VALUES (new.rowid, new.concept, new.content, new.tags);
222
- END;
223
- CREATE TRIGGER IF NOT EXISTS engrams_ad AFTER DELETE ON engrams BEGIN
224
- INSERT INTO engrams_fts(engrams_fts, rowid, concept, content, tags) VALUES('delete', old.rowid, old.concept, old.content, old.tags);
225
- END;
226
- CREATE TRIGGER IF NOT EXISTS engrams_au AFTER UPDATE ON engrams BEGIN
227
- INSERT INTO engrams_fts(engrams_fts, rowid, concept, content, tags) VALUES('delete', old.rowid, old.concept, old.content, old.tags);
228
- INSERT INTO engrams_fts(rowid, concept, content, tags) VALUES (new.rowid, new.concept, new.content, new.tags);
229
- END;
230
-
231
- -- Eval event logs
232
- CREATE TABLE IF NOT EXISTS activation_events (
233
- id TEXT PRIMARY KEY,
234
- agent_id TEXT NOT NULL,
235
- timestamp TEXT NOT NULL,
236
- context TEXT NOT NULL,
237
- results_returned INTEGER NOT NULL,
238
- top_score REAL,
239
- latency_ms REAL NOT NULL,
240
- engram_ids TEXT NOT NULL DEFAULT '[]'
241
- );
242
-
243
- CREATE TABLE IF NOT EXISTS staging_events (
244
- engram_id TEXT NOT NULL,
245
- agent_id TEXT NOT NULL,
246
- action TEXT NOT NULL,
247
- resonance_score REAL,
248
- timestamp TEXT NOT NULL,
249
- age_ms INTEGER NOT NULL
250
- );
251
-
252
- CREATE TABLE IF NOT EXISTS retrieval_feedback (
253
- id TEXT PRIMARY KEY,
254
- activation_event_id TEXT,
255
- engram_id TEXT NOT NULL,
256
- useful INTEGER NOT NULL,
257
- context TEXT,
258
- timestamp TEXT NOT NULL
259
- );
260
-
261
- CREATE TABLE IF NOT EXISTS episodes (
262
- id TEXT PRIMARY KEY,
263
- agent_id TEXT NOT NULL,
264
- label TEXT NOT NULL,
265
- embedding BLOB,
266
- engram_count INTEGER NOT NULL DEFAULT 0,
267
- start_time TEXT NOT NULL,
268
- end_time TEXT NOT NULL,
269
- created_at TEXT NOT NULL
270
- );
271
-
272
- CREATE INDEX IF NOT EXISTS idx_episodes_agent ON episodes(agent_id);
273
- CREATE INDEX IF NOT EXISTS idx_episodes_time ON episodes(agent_id, end_time);
172
+ this.db.exec(`
173
+ CREATE TABLE IF NOT EXISTS engrams (
174
+ id TEXT PRIMARY KEY,
175
+ agent_id TEXT NOT NULL,
176
+ concept TEXT NOT NULL,
177
+ content TEXT NOT NULL,
178
+ embedding BLOB,
179
+ confidence REAL NOT NULL DEFAULT 0.5,
180
+ salience REAL NOT NULL DEFAULT 0.5,
181
+ access_count INTEGER NOT NULL DEFAULT 0,
182
+ last_accessed TEXT NOT NULL,
183
+ created_at TEXT NOT NULL,
184
+ salience_features TEXT NOT NULL DEFAULT '{}',
185
+ reason_codes TEXT NOT NULL DEFAULT '[]',
186
+ stage TEXT NOT NULL DEFAULT 'active',
187
+ ttl INTEGER,
188
+ retracted INTEGER NOT NULL DEFAULT 0,
189
+ retracted_by TEXT,
190
+ retracted_at TEXT,
191
+ tags TEXT NOT NULL DEFAULT '[]',
192
+ memory_type TEXT NOT NULL DEFAULT 'unclassified'
193
+ );
194
+
195
+ CREATE INDEX IF NOT EXISTS idx_engrams_agent ON engrams(agent_id);
196
+ CREATE INDEX IF NOT EXISTS idx_engrams_stage ON engrams(agent_id, stage);
197
+ CREATE INDEX IF NOT EXISTS idx_engrams_concept ON engrams(concept);
198
+ CREATE INDEX IF NOT EXISTS idx_engrams_retracted ON engrams(agent_id, retracted);
199
+ -- Serves getTopAccessedTags: the recall path's pronoun branch needs the ten
200
+ -- most-accessed engrams, and without this it filters then sorts the whole
201
+ -- agent partition on every query containing "it", "this" or "that".
202
+ CREATE INDEX IF NOT EXISTS idx_engrams_access ON engrams(agent_id, stage, access_count DESC);
203
+
204
+ CREATE TABLE IF NOT EXISTS associations (
205
+ id TEXT PRIMARY KEY,
206
+ from_engram_id TEXT NOT NULL REFERENCES engrams(id) ON DELETE CASCADE,
207
+ to_engram_id TEXT NOT NULL REFERENCES engrams(id) ON DELETE CASCADE,
208
+ weight REAL NOT NULL DEFAULT 0.1,
209
+ confidence REAL NOT NULL DEFAULT 0.5,
210
+ type TEXT NOT NULL DEFAULT 'hebbian',
211
+ activation_count INTEGER NOT NULL DEFAULT 0,
212
+ created_at TEXT NOT NULL,
213
+ last_activated TEXT NOT NULL
214
+ );
215
+
216
+ CREATE INDEX IF NOT EXISTS idx_assoc_from ON associations(from_engram_id);
217
+ CREATE INDEX IF NOT EXISTS idx_assoc_to ON associations(to_engram_id);
218
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_assoc_pair ON associations(from_engram_id, to_engram_id);
219
+
220
+ CREATE TABLE IF NOT EXISTS agents (
221
+ id TEXT PRIMARY KEY,
222
+ name TEXT NOT NULL,
223
+ created_at TEXT NOT NULL,
224
+ config TEXT NOT NULL DEFAULT '{}'
225
+ );
226
+
227
+ -- FTS5 for full-text search (BM25 ranking built in)
228
+ CREATE VIRTUAL TABLE IF NOT EXISTS engrams_fts USING fts5(
229
+ concept, content, tags,
230
+ content=engrams,
231
+ content_rowid=rowid
232
+ );
233
+
234
+ -- Triggers to keep FTS in sync
235
+ CREATE TRIGGER IF NOT EXISTS engrams_ai AFTER INSERT ON engrams BEGIN
236
+ INSERT INTO engrams_fts(rowid, concept, content, tags) VALUES (new.rowid, new.concept, new.content, new.tags);
237
+ END;
238
+ CREATE TRIGGER IF NOT EXISTS engrams_ad AFTER DELETE ON engrams BEGIN
239
+ INSERT INTO engrams_fts(engrams_fts, rowid, concept, content, tags) VALUES('delete', old.rowid, old.concept, old.content, old.tags);
240
+ END;
241
+ CREATE TRIGGER IF NOT EXISTS engrams_au AFTER UPDATE ON engrams BEGIN
242
+ INSERT INTO engrams_fts(engrams_fts, rowid, concept, content, tags) VALUES('delete', old.rowid, old.concept, old.content, old.tags);
243
+ INSERT INTO engrams_fts(rowid, concept, content, tags) VALUES (new.rowid, new.concept, new.content, new.tags);
244
+ END;
245
+
246
+ -- Eval event logs
247
+ CREATE TABLE IF NOT EXISTS activation_events (
248
+ id TEXT PRIMARY KEY,
249
+ agent_id TEXT NOT NULL,
250
+ timestamp TEXT NOT NULL,
251
+ context TEXT NOT NULL,
252
+ results_returned INTEGER NOT NULL,
253
+ top_score REAL,
254
+ latency_ms REAL NOT NULL,
255
+ engram_ids TEXT NOT NULL DEFAULT '[]'
256
+ );
257
+
258
+ CREATE TABLE IF NOT EXISTS staging_events (
259
+ engram_id TEXT NOT NULL,
260
+ agent_id TEXT NOT NULL,
261
+ action TEXT NOT NULL,
262
+ resonance_score REAL,
263
+ timestamp TEXT NOT NULL,
264
+ age_ms INTEGER NOT NULL
265
+ );
266
+
267
+ CREATE TABLE IF NOT EXISTS retrieval_feedback (
268
+ id TEXT PRIMARY KEY,
269
+ activation_event_id TEXT,
270
+ engram_id TEXT NOT NULL,
271
+ useful INTEGER NOT NULL,
272
+ context TEXT,
273
+ timestamp TEXT NOT NULL
274
+ );
275
+
276
+ CREATE TABLE IF NOT EXISTS episodes (
277
+ id TEXT PRIMARY KEY,
278
+ agent_id TEXT NOT NULL,
279
+ label TEXT NOT NULL,
280
+ embedding BLOB,
281
+ engram_count INTEGER NOT NULL DEFAULT 0,
282
+ start_time TEXT NOT NULL,
283
+ end_time TEXT NOT NULL,
284
+ created_at TEXT NOT NULL
285
+ );
286
+
287
+ CREATE INDEX IF NOT EXISTS idx_episodes_agent ON episodes(agent_id);
288
+ CREATE INDEX IF NOT EXISTS idx_episodes_time ON episodes(agent_id, end_time);
274
289
  `);
275
290
  // Migration: add episode_id column if missing
276
291
  try {
@@ -284,10 +299,10 @@ export class EngramStore {
284
299
  this.db.prepare('SELECT task_status FROM engrams LIMIT 0').get();
285
300
  }
286
301
  catch {
287
- this.db.exec(`
288
- ALTER TABLE engrams ADD COLUMN task_status TEXT;
289
- ALTER TABLE engrams ADD COLUMN task_priority TEXT;
290
- ALTER TABLE engrams ADD COLUMN blocked_by TEXT;
302
+ this.db.exec(`
303
+ ALTER TABLE engrams ADD COLUMN task_status TEXT;
304
+ ALTER TABLE engrams ADD COLUMN task_priority TEXT;
305
+ ALTER TABLE engrams ADD COLUMN blocked_by TEXT;
291
306
  `);
292
307
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_engrams_task ON engrams(agent_id, task_status)');
293
308
  }
@@ -296,10 +311,10 @@ export class EngramStore {
296
311
  this.db.prepare('SELECT memory_class FROM engrams LIMIT 0').get();
297
312
  }
298
313
  catch {
299
- this.db.exec(`
300
- ALTER TABLE engrams ADD COLUMN memory_class TEXT NOT NULL DEFAULT 'working';
301
- ALTER TABLE engrams ADD COLUMN superseded_by TEXT;
302
- ALTER TABLE engrams ADD COLUMN supersedes TEXT;
314
+ this.db.exec(`
315
+ ALTER TABLE engrams ADD COLUMN memory_class TEXT NOT NULL DEFAULT 'working';
316
+ ALTER TABLE engrams ADD COLUMN superseded_by TEXT;
317
+ ALTER TABLE engrams ADD COLUMN supersedes TEXT;
303
318
  `);
304
319
  }
305
320
  // Migration: add embedding_model for version tracking (prevents drift on model change)
@@ -311,49 +326,57 @@ export class EngramStore {
311
326
  }
312
327
  // Entity inverted index + alias table (D9, 2026-07-30) — write-time
313
328
  // bookkeeping only; retrieval unchanged until D11. Idempotent CREATEs.
314
- this.db.exec(`
315
- CREATE TABLE IF NOT EXISTS entity_mentions (
316
- entity TEXT NOT NULL,
317
- engram_id TEXT NOT NULL,
318
- agent_id TEXT NOT NULL,
319
- PRIMARY KEY (entity, engram_id)
320
- );
321
- CREATE INDEX IF NOT EXISTS idx_entity_mentions_agent ON entity_mentions(agent_id, entity);
322
- CREATE TABLE IF NOT EXISTS entity_aliases (
323
- alias TEXT PRIMARY KEY,
324
- entity TEXT NOT NULL
325
- );
329
+ this.db.exec(`
330
+ CREATE TABLE IF NOT EXISTS entity_mentions (
331
+ entity TEXT NOT NULL,
332
+ engram_id TEXT NOT NULL,
333
+ agent_id TEXT NOT NULL,
334
+ PRIMARY KEY (entity, engram_id)
335
+ );
336
+ CREATE INDEX IF NOT EXISTS idx_entity_mentions_agent ON entity_mentions(agent_id, entity);
337
+ CREATE TABLE IF NOT EXISTS entity_aliases (
338
+ alias TEXT PRIMARY KEY,
339
+ entity TEXT NOT NULL
340
+ );
326
341
  `);
327
342
  // Migration: memory-spine provenance + temporal validity (D5/D8, 2026-07-30)
328
343
  try {
329
344
  this.db.prepare('SELECT origin_class FROM engrams LIMIT 0').get();
330
345
  }
331
346
  catch {
332
- this.db.exec(`
333
- ALTER TABLE engrams ADD COLUMN origin_class TEXT;
334
- ALTER TABLE engrams ADD COLUMN writer_session TEXT;
335
- ALTER TABLE engrams ADD COLUMN recipe_id TEXT;
336
- ALTER TABLE engrams ADD COLUMN valid_from TEXT;
337
- ALTER TABLE engrams ADD COLUMN valid_to TEXT;
347
+ this.db.exec(`
348
+ ALTER TABLE engrams ADD COLUMN origin_class TEXT;
349
+ ALTER TABLE engrams ADD COLUMN writer_session TEXT;
350
+ ALTER TABLE engrams ADD COLUMN recipe_id TEXT;
351
+ ALTER TABLE engrams ADD COLUMN valid_from TEXT;
352
+ ALTER TABLE engrams ADD COLUMN valid_to TEXT;
338
353
  `);
339
354
  }
355
+ // Migration: stage-transition timestamp (A-4, 2026-10-12). Additive, nullable,
356
+ // no backfill — null means "never transitioned", so readers fall back to created_at.
357
+ try {
358
+ this.db.prepare('SELECT stage_changed_at FROM engrams LIMIT 0').get();
359
+ }
360
+ catch {
361
+ this.db.exec(`ALTER TABLE engrams ADD COLUMN stage_changed_at TEXT`);
362
+ }
340
363
  // Migration: add conscious_state table for checkpointing
341
- this.db.exec(`
342
- CREATE TABLE IF NOT EXISTS conscious_state (
343
- agent_id TEXT PRIMARY KEY,
344
- last_write_id TEXT,
345
- last_recall_context TEXT,
346
- last_recall_ids TEXT NOT NULL DEFAULT '[]',
347
- last_activity_at TEXT NOT NULL DEFAULT (datetime('now')),
348
- write_count_since_consolidation INTEGER NOT NULL DEFAULT 0,
349
- recall_count_since_consolidation INTEGER NOT NULL DEFAULT 0,
350
- execution_state TEXT,
351
- checkpoint_at TEXT,
352
- last_consolidation_at TEXT,
353
- last_mini_consolidation_at TEXT,
354
- consolidation_cycle_count INTEGER NOT NULL DEFAULT 0,
355
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
356
- )
364
+ this.db.exec(`
365
+ CREATE TABLE IF NOT EXISTS conscious_state (
366
+ agent_id TEXT PRIMARY KEY,
367
+ last_write_id TEXT,
368
+ last_recall_context TEXT,
369
+ last_recall_ids TEXT NOT NULL DEFAULT '[]',
370
+ last_activity_at TEXT NOT NULL DEFAULT (datetime('now')),
371
+ write_count_since_consolidation INTEGER NOT NULL DEFAULT 0,
372
+ recall_count_since_consolidation INTEGER NOT NULL DEFAULT 0,
373
+ execution_state TEXT,
374
+ checkpoint_at TEXT,
375
+ last_consolidation_at TEXT,
376
+ last_mini_consolidation_at TEXT,
377
+ consolidation_cycle_count INTEGER NOT NULL DEFAULT 0,
378
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
379
+ )
357
380
  `);
358
381
  // Migration: add consolidation_cycle_count if missing (existing DBs)
359
382
  try {
@@ -374,7 +397,7 @@ export class EngramStore {
374
397
  }
375
398
  catch {
376
399
  this.db.exec(`ALTER TABLE engrams ADD COLUMN sequence INTEGER`);
377
- this.db.exec(`CREATE INDEX IF NOT EXISTS idx_engrams_agent_sequence
400
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_engrams_agent_sequence
378
401
  ON engrams(agent_id, sequence) WHERE sequence IS NOT NULL`);
379
402
  }
380
403
  // Migration (0.8 Cluster A): references_json column for typed cross-record
@@ -393,12 +416,12 @@ export class EngramStore {
393
416
  const embeddingBlob = input.embedding
394
417
  ? Buffer.from(new Float32Array(input.embedding).buffer)
395
418
  : null;
396
- this.db.prepare(`
397
- INSERT INTO engrams (id, agent_id, concept, content, embedding, confidence, salience,
398
- access_count, last_accessed, created_at, salience_features, reason_codes, stage, tags, episode_id,
399
- ttl, memory_class, supersedes, task_status, task_priority, blocked_by, memory_type,
400
- sequence, references_json, origin_class, writer_session, recipe_id, valid_from, valid_to)
401
- VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
419
+ this.db.prepare(`
420
+ INSERT INTO engrams (id, agent_id, concept, content, embedding, confidence, salience,
421
+ access_count, last_accessed, created_at, salience_features, reason_codes, stage, tags, episode_id,
422
+ ttl, memory_class, supersedes, task_status, task_priority, blocked_by, memory_type,
423
+ sequence, references_json, origin_class, writer_session, recipe_id, valid_from, valid_to)
424
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
402
425
  `).run(id, input.agentId, input.concept, input.content, embeddingBlob, input.confidence ?? 0.5, input.salience ?? 0.5, now, now, JSON.stringify(input.salienceFeatures ?? DEFAULT_SALIENCE_FEATURES), JSON.stringify(input.reasonCodes ?? []), JSON.stringify(input.tags ?? []), input.episodeId ?? null, input.ttl ?? null, input.memoryClass ?? 'working', input.supersedes ?? null, input.taskStatus ?? null, input.taskPriority ?? null, input.blockedBy ?? null, input.memoryType ?? 'unclassified', input.sequence ?? null, input.references && input.references.length > 0
403
426
  ? JSON.stringify(input.references) : null, input.originClass ?? null, input.writerSession ?? null, input.recipeId ?? null, input.validFrom ?? null, input.validTo ?? null);
404
427
  // Add to slim cache (skip if not yet populated — first slim fetch will load it)
@@ -578,11 +601,11 @@ export class EngramStore {
578
601
  getTopAccessedTags(agentIds, topN, stage = 'active') {
579
602
  if (agentIds.length === 0 || topN <= 0)
580
603
  return [];
581
- const stmt = this.db.prepare(`
582
- SELECT tags, access_count AS accessCount, id FROM engrams
583
- WHERE agent_id = ? AND stage = ? AND retracted = 0
584
- ORDER BY access_count DESC, id
585
- LIMIT ?
604
+ const stmt = this.db.prepare(`
605
+ SELECT tags, access_count AS accessCount, id FROM engrams
606
+ WHERE agent_id = ? AND stage = ? AND retracted = 0
607
+ ORDER BY access_count DESC, id
608
+ LIMIT ?
586
609
  `);
587
610
  const rows = [];
588
611
  for (const agentId of agentIds) {
@@ -620,15 +643,15 @@ export class EngramStore {
620
643
  try {
621
644
  const placeholders = agentIds.map(() => '?').join(',');
622
645
  const innerLimit = Math.max(limit * 5, 50);
623
- const rows = this.db.prepare(`
624
- WITH top_fts AS (
625
- SELECT rowid, rank FROM engrams_fts WHERE engrams_fts MATCH ? ORDER BY rank LIMIT ?
626
- )
627
- SELECT e.*, top_fts.rank FROM top_fts
628
- JOIN engrams e ON e.rowid = top_fts.rowid
629
- WHERE e.agent_id IN (${placeholders}) AND e.retracted = 0
630
- ORDER BY top_fts.rank
631
- LIMIT ?
646
+ const rows = this.db.prepare(`
647
+ WITH top_fts AS (
648
+ SELECT rowid, rank FROM engrams_fts WHERE engrams_fts MATCH ? ORDER BY rank LIMIT ?
649
+ )
650
+ SELECT e.*, top_fts.rank FROM top_fts
651
+ JOIN engrams e ON e.rowid = top_fts.rowid
652
+ WHERE e.agent_id IN (${placeholders}) AND e.retracted = 0
653
+ ORDER BY top_fts.rank
654
+ LIMIT ?
632
655
  `).all(sanitized, innerLimit, ...agentIds, limit);
633
656
  return rows.map(r => ({
634
657
  engram: this.rowToEngram(r),
@@ -667,16 +690,22 @@ export class EngramStore {
667
690
  * Boost: +0.02 per access, scaled by 1/sqrt(accessCount+1), capped at 0.85.
668
691
  */
669
692
  touchEngram(id) {
670
- this.db.prepare(`
671
- UPDATE engrams
672
- SET access_count = access_count + 1,
673
- last_accessed = ?,
674
- confidence = MIN(0.85, confidence + 0.02 / (1.0 + sqrt(access_count)))
675
- WHERE id = ?
693
+ this.db.prepare(`
694
+ UPDATE engrams
695
+ SET access_count = access_count + 1,
696
+ last_accessed = ?,
697
+ confidence = MIN(0.85, confidence + 0.02 / (1.0 + sqrt(access_count)))
698
+ WHERE id = ?
676
699
  `).run(new Date().toISOString(), id);
677
700
  }
701
+ /**
702
+ * Move an engram to a new stage, recording WHEN. The timestamp is written here
703
+ * rather than at the six consolidation call sites so a future transition cannot
704
+ * forget it; see `Engram.stageChangedAt` for why the column exists.
705
+ */
678
706
  updateStage(id, stage) {
679
- this.db.prepare('UPDATE engrams SET stage = ? WHERE id = ?').run(stage, id);
707
+ this.db.prepare('UPDATE engrams SET stage = ?, stage_changed_at = ? WHERE id = ?')
708
+ .run(stage, new Date().toISOString(), id);
680
709
  this.cacheUpdateStage(id, stage);
681
710
  }
682
711
  /**
@@ -702,13 +731,35 @@ export class EngramStore {
702
731
  this.cacheUpdateEmbedding(id, embedding);
703
732
  }
704
733
  retractEngram(id, retractedBy) {
705
- this.db.prepare(`
706
- UPDATE engrams SET retracted = 1, retracted_by = ?, retracted_at = ? WHERE id = ?
734
+ this.db.prepare(`
735
+ UPDATE engrams SET retracted = 1, retracted_by = ?, retracted_at = ? WHERE id = ?
707
736
  `).run(retractedBy, new Date().toISOString(), id);
708
737
  this.cacheRetract(id);
709
738
  }
739
+ /**
740
+ * Delete an engram and everything that points at it.
741
+ *
742
+ * `associations` cascades (its FKs declare ON DELETE CASCADE), but
743
+ * `retrieval_feedback`, `entity_mentions` and `staging_events` carry a bare
744
+ * `engram_id TEXT` with no foreign key, so deleting an engram used to strand their
745
+ * rows. Measured on the live store 2026-10-12: **571 orphaned retrieval_feedback
746
+ * rows and 3 orphaned entity_mentions**, present in the pre-purge backup too, so
747
+ * they accumulated over time rather than in one incident. Inert for recall, but
748
+ * they corrupt any join that assumes referential integrity — including the
749
+ * feedback-weighted scoring channel's own counts.
750
+ *
751
+ * One transaction, so a crash cannot leave the half-deleted state this is fixing.
752
+ * `activation_events` is deliberately NOT touched: its `engram_ids` is a JSON array
753
+ * describing a recall that really happened, and editing history to remove one id
754
+ * would falsify the event rather than tidy it.
755
+ */
710
756
  deleteEngram(id) {
711
- this.db.prepare('DELETE FROM engrams WHERE id = ?').run(id);
757
+ this.db.transaction(() => {
758
+ this.db.prepare('DELETE FROM retrieval_feedback WHERE engram_id = ?').run(id);
759
+ this.db.prepare('DELETE FROM entity_mentions WHERE engram_id = ?').run(id);
760
+ this.db.prepare('DELETE FROM staging_events WHERE engram_id = ?').run(id);
761
+ this.db.prepare('DELETE FROM engrams WHERE id = ?').run(id);
762
+ })();
712
763
  this.cacheRemove(id);
713
764
  }
714
765
  /**
@@ -720,20 +771,20 @@ export class EngramStore {
720
771
  let count = 0;
721
772
  const shiftSec = Math.round(ms / 1000);
722
773
  // Shift engram timestamps
723
- const r1 = this.db.prepare(`
724
- UPDATE engrams SET
725
- created_at = datetime(created_at, '-${shiftSec} seconds'),
726
- last_accessed = datetime(last_accessed, '-${shiftSec} seconds')
727
- WHERE agent_id = ?
774
+ const r1 = this.db.prepare(`
775
+ UPDATE engrams SET
776
+ created_at = datetime(created_at, '-${shiftSec} seconds'),
777
+ last_accessed = datetime(last_accessed, '-${shiftSec} seconds')
778
+ WHERE agent_id = ?
728
779
  `).run(agentId);
729
780
  count += r1.changes;
730
781
  // Shift association timestamps
731
- const r2 = this.db.prepare(`
732
- UPDATE associations SET
733
- created_at = datetime(created_at, '-${shiftSec} seconds'),
734
- last_activated = datetime(last_activated, '-${shiftSec} seconds')
735
- WHERE from_engram_id IN (SELECT id FROM engrams WHERE agent_id = ?)
736
- OR to_engram_id IN (SELECT id FROM engrams WHERE agent_id = ?)
782
+ const r2 = this.db.prepare(`
783
+ UPDATE associations SET
784
+ created_at = datetime(created_at, '-${shiftSec} seconds'),
785
+ last_activated = datetime(last_activated, '-${shiftSec} seconds')
786
+ WHERE from_engram_id IN (SELECT id FROM engrams WHERE agent_id = ?)
787
+ OR to_engram_id IN (SELECT id FROM engrams WHERE agent_id = ?)
737
788
  `).run(agentId, agentId);
738
789
  count += r2.changes;
739
790
  return count;
@@ -812,15 +863,15 @@ export class EngramStore {
812
863
  // dominant, low retracted rate).
813
864
  try {
814
865
  const innerLimit = Math.max(limit * 5, 50);
815
- const rows = this.db.prepare(`
816
- WITH top_fts AS (
817
- SELECT rowid, rank FROM engrams_fts WHERE engrams_fts MATCH ? ORDER BY rank LIMIT ?
818
- )
819
- SELECT e.*, top_fts.rank FROM top_fts
820
- JOIN engrams e ON e.rowid = top_fts.rowid
821
- WHERE e.agent_id = ? AND e.retracted = 0
822
- ORDER BY top_fts.rank
823
- LIMIT ?
866
+ const rows = this.db.prepare(`
867
+ WITH top_fts AS (
868
+ SELECT rowid, rank FROM engrams_fts WHERE engrams_fts MATCH ? ORDER BY rank LIMIT ?
869
+ )
870
+ SELECT e.*, top_fts.rank FROM top_fts
871
+ JOIN engrams e ON e.rowid = top_fts.rowid
872
+ WHERE e.agent_id = ? AND e.retracted = 0
873
+ ORDER BY top_fts.rank
874
+ LIMIT ?
824
875
  `).all(sanitized, innerLimit, agentId, limit);
825
876
  return rows.map(r => ({
826
877
  engram: this.rowToEngram(r),
@@ -940,14 +991,14 @@ export class EngramStore {
940
991
  sql += ' AND task_status = ?';
941
992
  params.push(status);
942
993
  }
943
- sql += ` ORDER BY
944
- CASE task_priority
945
- WHEN 'urgent' THEN 0
946
- WHEN 'high' THEN 1
947
- WHEN 'medium' THEN 2
948
- WHEN 'low' THEN 3
949
- ELSE 4
950
- END,
994
+ sql += ` ORDER BY
995
+ CASE task_priority
996
+ WHEN 'urgent' THEN 0
997
+ WHEN 'high' THEN 1
998
+ WHEN 'medium' THEN 2
999
+ WHEN 'low' THEN 3
1000
+ ELSE 4
1001
+ END,
951
1002
  created_at DESC`;
952
1003
  return this.db.prepare(sql).all(...params).map(r => this.rowToEngram(r));
953
1004
  }
@@ -955,20 +1006,20 @@ export class EngramStore {
955
1006
  * Get the next actionable task — highest priority that's not blocked or done.
956
1007
  */
957
1008
  getNextTask(agentId) {
958
- const row = this.db.prepare(`
959
- SELECT * FROM engrams
960
- WHERE agent_id = ? AND task_status IN ('open', 'in_progress') AND retracted = 0
961
- ORDER BY
962
- CASE task_status WHEN 'in_progress' THEN 0 ELSE 1 END,
963
- CASE task_priority
964
- WHEN 'urgent' THEN 0
965
- WHEN 'high' THEN 1
966
- WHEN 'medium' THEN 2
967
- WHEN 'low' THEN 3
968
- ELSE 4
969
- END,
970
- created_at ASC
971
- LIMIT 1
1009
+ const row = this.db.prepare(`
1010
+ SELECT * FROM engrams
1011
+ WHERE agent_id = ? AND task_status IN ('open', 'in_progress') AND retracted = 0
1012
+ ORDER BY
1013
+ CASE task_status WHEN 'in_progress' THEN 0 ELSE 1 END,
1014
+ CASE task_priority
1015
+ WHEN 'urgent' THEN 0
1016
+ WHEN 'high' THEN 1
1017
+ WHEN 'medium' THEN 2
1018
+ WHEN 'low' THEN 3
1019
+ ELSE 4
1020
+ END,
1021
+ created_at ASC
1022
+ LIMIT 1
972
1023
  `).get(agentId);
973
1024
  return row ? this.rowToEngram(row) : null;
974
1025
  }
@@ -990,11 +1041,11 @@ export class EngramStore {
990
1041
  * Returns null if no match.
991
1042
  */
992
1043
  findActiveMatchByConcept(agentId, concept, requiredTags) {
993
- let sql = `SELECT * FROM engrams
994
- WHERE agent_id = ?
995
- AND LOWER(TRIM(concept)) = LOWER(TRIM(?))
996
- AND stage = 'active'
997
- AND retracted = 0
1044
+ let sql = `SELECT * FROM engrams
1045
+ WHERE agent_id = ?
1046
+ AND LOWER(TRIM(concept)) = LOWER(TRIM(?))
1047
+ AND stage = 'active'
1048
+ AND retracted = 0
998
1049
  AND superseded_by IS NULL`;
999
1050
  const params = [agentId, concept];
1000
1051
  if (requiredTags && requiredTags.length > 0) {
@@ -1029,11 +1080,11 @@ export class EngramStore {
1029
1080
  upsertAssociation(fromId, toId, weight, type = 'hebbian', confidence = 0.5) {
1030
1081
  const now = new Date().toISOString();
1031
1082
  const id = randomUUID();
1032
- this.db.prepare(`
1033
- INSERT INTO associations (id, from_engram_id, to_engram_id, weight, confidence, type, activation_count, created_at, last_activated)
1034
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
1035
- ON CONFLICT(from_engram_id, to_engram_id) DO UPDATE SET
1036
- weight = ?, confidence = ?, last_activated = ?, activation_count = activation_count + 1
1083
+ this.db.prepare(`
1084
+ INSERT INTO associations (id, from_engram_id, to_engram_id, weight, confidence, type, activation_count, created_at, last_activated)
1085
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
1086
+ ON CONFLICT(from_engram_id, to_engram_id) DO UPDATE SET
1087
+ weight = ?, confidence = ?, last_activated = ?, activation_count = activation_count + 1
1037
1088
  `).run(id, fromId, toId, weight, confidence, type, now, now, weight, confidence, now);
1038
1089
  return this.getAssociation(fromId, toId);
1039
1090
  }
@@ -1073,12 +1124,12 @@ export class EngramStore {
1073
1124
  // associations under both their from and to engram. Self-loops would be
1074
1125
  // double-counted, but they're rare in practice and the prior code handled them
1075
1126
  // identically.
1076
- const rows = this.db.prepare(`SELECT id, SUM(cnt) AS count, SUM(sw) AS sum_weight FROM (
1077
- SELECT from_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE from_engram_id IN (${placeholders})
1078
- UNION ALL
1079
- SELECT to_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE to_engram_id IN (${placeholders})
1080
- )
1081
- WHERE id IN (${placeholders})
1127
+ const rows = this.db.prepare(`SELECT id, SUM(cnt) AS count, SUM(sw) AS sum_weight FROM (
1128
+ SELECT from_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE from_engram_id IN (${placeholders})
1129
+ UNION ALL
1130
+ SELECT to_engram_id AS id, 1 AS cnt, weight AS sw FROM associations WHERE to_engram_id IN (${placeholders})
1131
+ )
1132
+ WHERE id IN (${placeholders})
1082
1133
  GROUP BY id`).all(...chunk, ...chunk, ...chunk);
1083
1134
  for (const r of rows) {
1084
1135
  result.set(r.id, { count: r.count, sumWeight: r.sum_weight });
@@ -1109,7 +1160,7 @@ export class EngramStore {
1109
1160
  for (let i = 0; i < engramIds.length; i += CHUNK) {
1110
1161
  const chunk = engramIds.slice(i, i + CHUNK);
1111
1162
  const placeholders = chunk.map(() => '?').join(',');
1112
- const rows = this.db.prepare(`SELECT * FROM associations
1163
+ const rows = this.db.prepare(`SELECT * FROM associations
1113
1164
  WHERE from_engram_id IN (${placeholders}) OR to_engram_id IN (${placeholders})`).all(...chunk, ...chunk);
1114
1165
  for (const r of rows) {
1115
1166
  const a = this.rowToAssociation(r);
@@ -1148,22 +1199,22 @@ export class EngramStore {
1148
1199
  this.db.prepare('DELETE FROM associations WHERE id = ?').run(id);
1149
1200
  }
1150
1201
  getAllAssociations(agentId) {
1151
- const rows = this.db.prepare(`
1152
- SELECT a.* FROM associations a
1153
- JOIN engrams e ON a.from_engram_id = e.id
1154
- WHERE e.agent_id = ?
1202
+ const rows = this.db.prepare(`
1203
+ SELECT a.* FROM associations a
1204
+ JOIN engrams e ON a.from_engram_id = e.id
1205
+ WHERE e.agent_id = ?
1155
1206
  `).all(agentId);
1156
1207
  return rows.map(r => this.rowToAssociation(r));
1157
1208
  }
1158
1209
  // --- Eviction ---
1159
1210
  getEvictionCandidates(agentId, limit) {
1160
1211
  // Lowest combined score: low salience + low access + low confidence + oldest
1161
- const rows = this.db.prepare(`
1162
- SELECT * FROM engrams
1163
- WHERE agent_id = ? AND stage = 'active' AND retracted = 0
1164
- ORDER BY (salience * 0.3 + confidence * 0.3 + (CAST(access_count AS REAL) / (access_count + 5)) * 0.2 +
1165
- (1.0 / (1.0 + (julianday('now') - julianday(last_accessed)))) * 0.2) ASC
1166
- LIMIT ?
1212
+ const rows = this.db.prepare(`
1213
+ SELECT * FROM engrams
1214
+ WHERE agent_id = ? AND stage = 'active' AND retracted = 0
1215
+ ORDER BY (salience * 0.3 + confidence * 0.3 + (CAST(access_count AS REAL) / (access_count + 5)) * 0.2 +
1216
+ (1.0 / (1.0 + (julianday('now') - julianday(last_accessed)))) * 0.2) ASC
1217
+ LIMIT ?
1167
1218
  `).all(agentId, limit);
1168
1219
  return rows.map(r => this.rowToEngram(r));
1169
1220
  }
@@ -1178,8 +1229,8 @@ export class EngramStore {
1178
1229
  // --- Staging buffer ---
1179
1230
  getExpiredStaging() {
1180
1231
  const now = Date.now();
1181
- const rows = this.db.prepare(`
1182
- SELECT * FROM engrams WHERE stage = 'staging' AND ttl IS NOT NULL
1232
+ const rows = this.db.prepare(`
1233
+ SELECT * FROM engrams WHERE stage = 'staging' AND ttl IS NOT NULL
1183
1234
  `).all();
1184
1235
  return rows
1185
1236
  .map(r => this.rowToEngram(r))
@@ -1187,53 +1238,53 @@ export class EngramStore {
1187
1238
  }
1188
1239
  // --- Eval event logging ---
1189
1240
  logActivationEvent(event) {
1190
- this.db.prepare(`
1191
- INSERT INTO activation_events (id, agent_id, timestamp, context, results_returned, top_score, latency_ms, engram_ids)
1192
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1241
+ this.db.prepare(`
1242
+ INSERT INTO activation_events (id, agent_id, timestamp, context, results_returned, top_score, latency_ms, engram_ids)
1243
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1193
1244
  `).run(event.id, event.agentId, event.timestamp.toISOString(), event.context, event.resultsReturned, event.topScore, event.latencyMs, JSON.stringify(event.engramIds));
1194
1245
  }
1195
1246
  logStagingEvent(event) {
1196
- this.db.prepare(`
1197
- INSERT INTO staging_events (engram_id, agent_id, action, resonance_score, timestamp, age_ms)
1198
- VALUES (?, ?, ?, ?, ?, ?)
1247
+ this.db.prepare(`
1248
+ INSERT INTO staging_events (engram_id, agent_id, action, resonance_score, timestamp, age_ms)
1249
+ VALUES (?, ?, ?, ?, ?, ?)
1199
1250
  `).run(event.engramId, event.agentId, event.action, event.resonanceScore, event.timestamp.toISOString(), event.ageMs);
1200
1251
  }
1201
1252
  logRetrievalFeedback(activationEventId, engramId, useful, context) {
1202
- this.db.prepare(`
1203
- INSERT INTO retrieval_feedback (id, activation_event_id, engram_id, useful, context, timestamp)
1204
- VALUES (?, ?, ?, ?, ?, ?)
1253
+ this.db.prepare(`
1254
+ INSERT INTO retrieval_feedback (id, activation_event_id, engram_id, useful, context, timestamp)
1255
+ VALUES (?, ?, ?, ?, ?, ?)
1205
1256
  `).run(randomUUID(), activationEventId, engramId, useful ? 1 : 0, context, new Date().toISOString());
1206
1257
  }
1207
1258
  // --- Eval metrics queries ---
1208
1259
  getRetrievalPrecision(agentId, windowHours = 24) {
1209
1260
  const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
1210
- const row = this.db.prepare(`
1211
- SELECT
1212
- COUNT(CASE WHEN useful = 1 THEN 1 END) as useful_count,
1213
- COUNT(*) as total_count
1214
- FROM retrieval_feedback rf
1215
- LEFT JOIN activation_events ae ON rf.activation_event_id = ae.id
1216
- JOIN engrams e ON rf.engram_id = e.id
1217
- WHERE e.agent_id = ? AND rf.timestamp > ?
1261
+ const row = this.db.prepare(`
1262
+ SELECT
1263
+ COUNT(CASE WHEN useful = 1 THEN 1 END) as useful_count,
1264
+ COUNT(*) as total_count
1265
+ FROM retrieval_feedback rf
1266
+ LEFT JOIN activation_events ae ON rf.activation_event_id = ae.id
1267
+ JOIN engrams e ON rf.engram_id = e.id
1268
+ WHERE e.agent_id = ? AND rf.timestamp > ?
1218
1269
  `).get(agentId, since);
1219
1270
  return row.total_count > 0 ? row.useful_count / row.total_count : 0;
1220
1271
  }
1221
1272
  getStagingMetrics(agentId) {
1222
- const row = this.db.prepare(`
1223
- SELECT
1224
- COUNT(CASE WHEN action = 'promoted' THEN 1 END) as promoted,
1225
- COUNT(CASE WHEN action = 'discarded' THEN 1 END) as discarded,
1226
- COUNT(CASE WHEN action = 'expired' THEN 1 END) as expired
1227
- FROM staging_events WHERE agent_id = ?
1273
+ const row = this.db.prepare(`
1274
+ SELECT
1275
+ COUNT(CASE WHEN action = 'promoted' THEN 1 END) as promoted,
1276
+ COUNT(CASE WHEN action = 'discarded' THEN 1 END) as discarded,
1277
+ COUNT(CASE WHEN action = 'expired' THEN 1 END) as expired
1278
+ FROM staging_events WHERE agent_id = ?
1228
1279
  `).get(agentId);
1229
1280
  return { promoted: row.promoted, discarded: row.discarded, expired: row.expired };
1230
1281
  }
1231
1282
  getActivationStats(agentId, windowHours = 24) {
1232
1283
  const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
1233
- const rows = this.db.prepare(`
1234
- SELECT latency_ms FROM activation_events
1235
- WHERE agent_id = ? AND timestamp > ?
1236
- ORDER BY latency_ms ASC
1284
+ const rows = this.db.prepare(`
1285
+ SELECT latency_ms FROM activation_events
1286
+ WHERE agent_id = ? AND timestamp > ?
1287
+ ORDER BY latency_ms ASC
1237
1288
  `).all(agentId, since);
1238
1289
  if (rows.length === 0)
1239
1290
  return { count: 0, avgLatencyMs: 0, p50LatencyMs: 0, p90LatencyMs: 0, p95LatencyMs: 0 };
@@ -1258,11 +1309,11 @@ export class EngramStore {
1258
1309
  */
1259
1310
  getLinkedFeedbackStats(agentId, windowHours = 24 * 7) {
1260
1311
  const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
1261
- const row = this.db.prepare(`
1262
- SELECT COUNT(*) AS total, COUNT(CASE WHEN rf.useful = 1 THEN 1 END) AS useful
1263
- FROM retrieval_feedback rf
1264
- JOIN activation_events ae ON ae.id = rf.activation_event_id
1265
- WHERE ae.agent_id = ? AND rf.timestamp > ?
1312
+ const row = this.db.prepare(`
1313
+ SELECT COUNT(*) AS total, COUNT(CASE WHEN rf.useful = 1 THEN 1 END) AS useful
1314
+ FROM retrieval_feedback rf
1315
+ JOIN activation_events ae ON ae.id = rf.activation_event_id
1316
+ WHERE ae.agent_id = ? AND rf.timestamp > ?
1266
1317
  `).get(agentId, since);
1267
1318
  return { total: row.total, useful: row.useful };
1268
1319
  }
@@ -1308,6 +1359,7 @@ export class EngramStore {
1308
1359
  recipeId: row.recipe_id ?? null,
1309
1360
  validFrom: row.valid_from ?? null,
1310
1361
  validTo: row.valid_to ?? null,
1362
+ stageChangedAt: row.stage_changed_at ?? null,
1311
1363
  };
1312
1364
  }
1313
1365
  rowToAssociation(row) {
@@ -1330,9 +1382,9 @@ export class EngramStore {
1330
1382
  const embeddingBlob = input.embedding
1331
1383
  ? Buffer.from(new Float32Array(input.embedding).buffer)
1332
1384
  : null;
1333
- this.db.prepare(`
1334
- INSERT INTO episodes (id, agent_id, label, embedding, engram_count, start_time, end_time, created_at)
1335
- VALUES (?, ?, ?, ?, 0, ?, ?, ?)
1385
+ this.db.prepare(`
1386
+ INSERT INTO episodes (id, agent_id, label, embedding, engram_count, start_time, end_time, created_at)
1387
+ VALUES (?, ?, ?, ?, 0, ?, ?, ?)
1336
1388
  `).run(id, input.agentId, input.label, embeddingBlob, now, now, now);
1337
1389
  return this.getEpisode(id);
1338
1390
  }
@@ -1347,19 +1399,19 @@ export class EngramStore {
1347
1399
  getActiveEpisode(agentId, windowMs = 3600_000) {
1348
1400
  // Find most recent episode that ended within the time window
1349
1401
  const cutoff = new Date(Date.now() - windowMs).toISOString();
1350
- const row = this.db.prepare(`
1351
- SELECT * FROM episodes WHERE agent_id = ? AND end_time > ?
1352
- ORDER BY end_time DESC LIMIT 1
1402
+ const row = this.db.prepare(`
1403
+ SELECT * FROM episodes WHERE agent_id = ? AND end_time > ?
1404
+ ORDER BY end_time DESC LIMIT 1
1353
1405
  `).get(agentId, cutoff);
1354
1406
  return row ? this.rowToEpisode(row) : null;
1355
1407
  }
1356
1408
  addEngramToEpisode(engramId, episodeId) {
1357
1409
  this.db.prepare('UPDATE engrams SET episode_id = ? WHERE id = ?').run(episodeId, engramId);
1358
- this.db.prepare(`
1359
- UPDATE episodes SET
1360
- engram_count = engram_count + 1,
1361
- end_time = MAX(end_time, ?)
1362
- WHERE id = ?
1410
+ this.db.prepare(`
1411
+ UPDATE episodes SET
1412
+ engram_count = engram_count + 1,
1413
+ end_time = MAX(end_time, ?)
1414
+ WHERE id = ?
1363
1415
  `).run(new Date().toISOString(), episodeId);
1364
1416
  }
1365
1417
  getEngramsByEpisode(episodeId) {
@@ -1410,49 +1462,49 @@ export class EngramStore {
1410
1462
  // --- Checkpointing ---
1411
1463
  updateAutoCheckpointWrite(agentId, engramId) {
1412
1464
  const now = new Date().toISOString();
1413
- this.db.prepare(`
1414
- INSERT INTO conscious_state (agent_id, last_write_id, last_activity_at, write_count_since_consolidation, updated_at)
1415
- VALUES (?, ?, ?, 1, ?)
1416
- ON CONFLICT(agent_id) DO UPDATE SET
1417
- last_write_id = excluded.last_write_id,
1418
- last_activity_at = excluded.last_activity_at,
1419
- write_count_since_consolidation = write_count_since_consolidation + 1,
1420
- updated_at = excluded.updated_at
1465
+ this.db.prepare(`
1466
+ INSERT INTO conscious_state (agent_id, last_write_id, last_activity_at, write_count_since_consolidation, updated_at)
1467
+ VALUES (?, ?, ?, 1, ?)
1468
+ ON CONFLICT(agent_id) DO UPDATE SET
1469
+ last_write_id = excluded.last_write_id,
1470
+ last_activity_at = excluded.last_activity_at,
1471
+ write_count_since_consolidation = write_count_since_consolidation + 1,
1472
+ updated_at = excluded.updated_at
1421
1473
  `).run(agentId, engramId, now, now);
1422
1474
  }
1423
1475
  updateAutoCheckpointRecall(agentId, context, engramIds) {
1424
1476
  const now = new Date().toISOString();
1425
- this.db.prepare(`
1426
- INSERT INTO conscious_state (agent_id, last_recall_context, last_recall_ids, last_activity_at, recall_count_since_consolidation, updated_at)
1427
- VALUES (?, ?, ?, ?, 1, ?)
1428
- ON CONFLICT(agent_id) DO UPDATE SET
1429
- last_recall_context = excluded.last_recall_context,
1430
- last_recall_ids = excluded.last_recall_ids,
1431
- last_activity_at = excluded.last_activity_at,
1432
- recall_count_since_consolidation = recall_count_since_consolidation + 1,
1433
- updated_at = excluded.updated_at
1477
+ this.db.prepare(`
1478
+ INSERT INTO conscious_state (agent_id, last_recall_context, last_recall_ids, last_activity_at, recall_count_since_consolidation, updated_at)
1479
+ VALUES (?, ?, ?, ?, 1, ?)
1480
+ ON CONFLICT(agent_id) DO UPDATE SET
1481
+ last_recall_context = excluded.last_recall_context,
1482
+ last_recall_ids = excluded.last_recall_ids,
1483
+ last_activity_at = excluded.last_activity_at,
1484
+ recall_count_since_consolidation = recall_count_since_consolidation + 1,
1485
+ updated_at = excluded.updated_at
1434
1486
  `).run(agentId, context, JSON.stringify(engramIds), now, now);
1435
1487
  }
1436
1488
  touchActivity(agentId) {
1437
1489
  const now = new Date().toISOString();
1438
- this.db.prepare(`
1439
- INSERT INTO conscious_state (agent_id, last_activity_at, updated_at)
1440
- VALUES (?, ?, ?)
1441
- ON CONFLICT(agent_id) DO UPDATE SET
1442
- last_activity_at = excluded.last_activity_at,
1443
- updated_at = excluded.updated_at
1490
+ this.db.prepare(`
1491
+ INSERT INTO conscious_state (agent_id, last_activity_at, updated_at)
1492
+ VALUES (?, ?, ?)
1493
+ ON CONFLICT(agent_id) DO UPDATE SET
1494
+ last_activity_at = excluded.last_activity_at,
1495
+ updated_at = excluded.updated_at
1444
1496
  `).run(agentId, now, now);
1445
1497
  }
1446
1498
  saveCheckpoint(agentId, state) {
1447
1499
  const now = new Date().toISOString();
1448
- this.db.prepare(`
1449
- INSERT INTO conscious_state (agent_id, execution_state, checkpoint_at, last_activity_at, updated_at)
1450
- VALUES (?, ?, ?, ?, ?)
1451
- ON CONFLICT(agent_id) DO UPDATE SET
1452
- execution_state = excluded.execution_state,
1453
- checkpoint_at = excluded.checkpoint_at,
1454
- last_activity_at = excluded.last_activity_at,
1455
- updated_at = excluded.updated_at
1500
+ this.db.prepare(`
1501
+ INSERT INTO conscious_state (agent_id, execution_state, checkpoint_at, last_activity_at, updated_at)
1502
+ VALUES (?, ?, ?, ?, ?)
1503
+ ON CONFLICT(agent_id) DO UPDATE SET
1504
+ execution_state = excluded.execution_state,
1505
+ checkpoint_at = excluded.checkpoint_at,
1506
+ last_activity_at = excluded.last_activity_at,
1507
+ updated_at = excluded.updated_at
1456
1508
  `).run(agentId, JSON.stringify(state), now, now, now);
1457
1509
  }
1458
1510
  /** Record entity mentions for an engram (D9 inverted index; idempotent). */
@@ -1469,10 +1521,10 @@ export class EngramStore {
1469
1521
  const t = term.toLowerCase().replace(/[%_]/g, '');
1470
1522
  if (!t)
1471
1523
  return [];
1472
- const rows = this.db.prepare(`
1473
- SELECT DISTINCT entity FROM (
1474
- SELECT entity FROM entity_mentions WHERE entity LIKE ?
1475
- UNION SELECT entity FROM entity_aliases WHERE alias LIKE ?
1524
+ const rows = this.db.prepare(`
1525
+ SELECT DISTINCT entity FROM (
1526
+ SELECT entity FROM entity_mentions WHERE entity LIKE ?
1527
+ UNION SELECT entity FROM entity_aliases WHERE alias LIKE ?
1476
1528
  ) LIMIT ?`).all(`%${t}%`, `%${t}%`, limit);
1477
1529
  return rows.map(r => r.entity);
1478
1530
  }
@@ -1514,20 +1566,20 @@ export class EngramStore {
1514
1566
  markConsolidation(agentId, mini) {
1515
1567
  const now = new Date().toISOString();
1516
1568
  if (mini) {
1517
- this.db.prepare(`
1518
- UPDATE conscious_state SET last_mini_consolidation_at = ?, updated_at = ? WHERE agent_id = ?
1569
+ this.db.prepare(`
1570
+ UPDATE conscious_state SET last_mini_consolidation_at = ?, updated_at = ? WHERE agent_id = ?
1519
1571
  `).run(now, now, agentId);
1520
1572
  }
1521
1573
  else {
1522
- this.db.prepare(`
1523
- UPDATE conscious_state SET
1524
- last_consolidation_at = ?,
1525
- last_mini_consolidation_at = ?,
1526
- write_count_since_consolidation = 0,
1527
- recall_count_since_consolidation = 0,
1528
- consolidation_cycle_count = consolidation_cycle_count + 1,
1529
- updated_at = ?
1530
- WHERE agent_id = ?
1574
+ this.db.prepare(`
1575
+ UPDATE conscious_state SET
1576
+ last_consolidation_at = ?,
1577
+ last_mini_consolidation_at = ?,
1578
+ write_count_since_consolidation = 0,
1579
+ recall_count_since_consolidation = 0,
1580
+ consolidation_cycle_count = consolidation_cycle_count + 1,
1581
+ updated_at = ?
1582
+ WHERE agent_id = ?
1531
1583
  `).run(now, now, now, agentId);
1532
1584
  }
1533
1585
  }
@@ -1598,10 +1650,10 @@ export class EngramStore {
1598
1650
  * sequence are excluded (they have no story-time anchor).
1599
1651
  */
1600
1652
  getLatestByTag(opts) {
1601
- let sql = `SELECT * FROM engrams
1602
- WHERE agent_id = ?
1603
- AND retracted = ?
1604
- AND stage = 'active'
1653
+ let sql = `SELECT * FROM engrams
1654
+ WHERE agent_id = ?
1655
+ AND retracted = ?
1656
+ AND stage = 'active'
1605
1657
  AND tags LIKE ?`;
1606
1658
  const params = [
1607
1659
  opts.agentId,
@@ -1641,10 +1693,10 @@ export class EngramStore {
1641
1693
  */
1642
1694
  getTopBy(opts) {
1643
1695
  // Build the base query inline (mirrors search() but adds the topBy sort).
1644
- let sql = `SELECT * FROM engrams
1645
- WHERE agent_id = ?
1646
- AND retracted = ?
1647
- AND stage = 'active'
1696
+ let sql = `SELECT * FROM engrams
1697
+ WHERE agent_id = ?
1698
+ AND retracted = ?
1699
+ AND stage = 'active'
1648
1700
  AND tags LIKE ?`;
1649
1701
  const params = [
1650
1702
  opts.agentId,
@@ -1712,11 +1764,11 @@ export class EngramStore {
1712
1764
  // Find candidate referencing engrams. LIKE-filter first to narrow,
1713
1765
  // then JSON.parse each candidate's references_json to verify the
1714
1766
  // matchEngramId and type. Cheap enough at NovelForge's scale.
1715
- const candidates = this.db.prepare(`SELECT id, references_json, created_at FROM engrams
1716
- WHERE agent_id = ?
1717
- AND retracted = 0
1718
- AND stage = 'active'
1719
- AND references_json IS NOT NULL
1767
+ const candidates = this.db.prepare(`SELECT id, references_json, created_at FROM engrams
1768
+ WHERE agent_id = ?
1769
+ AND retracted = 0
1770
+ AND stage = 'active'
1771
+ AND references_json IS NOT NULL
1720
1772
  AND references_json LIKE ?`).all(target.agentId, `%"${targetEngramId}"%`);
1721
1773
  const terminalTypes = new Set(['resolves', 'subverts', 'abandons']);
1722
1774
  const resolvingEvents = [];