agent-working-memory 0.7.16 → 0.8.5

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 (200) hide show
  1. package/README.md +156 -15
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +2 -16
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adapters/codex.d.ts.map +1 -1
  6. package/dist/adapters/codex.js +2 -11
  7. package/dist/adapters/codex.js.map +1 -1
  8. package/dist/adapters/common.d.ts +18 -0
  9. package/dist/adapters/common.d.ts.map +1 -1
  10. package/dist/adapters/common.js +254 -141
  11. package/dist/adapters/common.js.map +1 -1
  12. package/dist/adapters/cursor.d.ts.map +1 -1
  13. package/dist/adapters/cursor.js +2 -15
  14. package/dist/adapters/cursor.js.map +1 -1
  15. package/dist/adapters/http.d.ts.map +1 -1
  16. package/dist/adapters/http.js +28 -34
  17. package/dist/adapters/http.js.map +1 -1
  18. package/dist/api/routes.d.ts +1 -1
  19. package/dist/api/routes.d.ts.map +1 -1
  20. package/dist/api/routes.js +280 -113
  21. package/dist/api/routes.js.map +1 -1
  22. package/dist/cli/migrate.d.ts +42 -0
  23. package/dist/cli/migrate.d.ts.map +1 -0
  24. package/dist/cli/migrate.js +268 -0
  25. package/dist/cli/migrate.js.map +1 -0
  26. package/dist/cli.js +45 -1
  27. package/dist/cli.js.map +1 -1
  28. package/dist/coordination/circuit-breaker.d.ts +24 -0
  29. package/dist/coordination/circuit-breaker.d.ts.map +1 -0
  30. package/dist/coordination/circuit-breaker.js +70 -0
  31. package/dist/coordination/circuit-breaker.js.map +1 -0
  32. package/dist/coordination/failure-modes.d.ts +21 -0
  33. package/dist/coordination/failure-modes.d.ts.map +1 -0
  34. package/dist/coordination/failure-modes.js +49 -0
  35. package/dist/coordination/failure-modes.js.map +1 -0
  36. package/dist/coordination/peer-decisions.js +7 -7
  37. package/dist/coordination/routes.d.ts.map +1 -1
  38. package/dist/coordination/routes.js +44 -1
  39. package/dist/coordination/routes.js.map +1 -1
  40. package/dist/coordination/schema.d.ts.map +1 -1
  41. package/dist/coordination/schema.js +18 -0
  42. package/dist/coordination/schema.js.map +1 -1
  43. package/dist/coordination/schemas.d.ts +9 -9
  44. package/dist/coordination/stale.d.ts +9 -1
  45. package/dist/coordination/stale.d.ts.map +1 -1
  46. package/dist/coordination/stale.js +53 -9
  47. package/dist/coordination/stale.js.map +1 -1
  48. package/dist/core/embeddings.d.ts +16 -10
  49. package/dist/core/embeddings.d.ts.map +1 -1
  50. package/dist/core/embeddings.js +54 -38
  51. package/dist/core/embeddings.js.map +1 -1
  52. package/dist/core/ml-worker-entry.d.ts +17 -0
  53. package/dist/core/ml-worker-entry.d.ts.map +1 -0
  54. package/dist/core/ml-worker-entry.js +193 -0
  55. package/dist/core/ml-worker-entry.js.map +1 -0
  56. package/dist/core/ml-worker.d.ts +59 -0
  57. package/dist/core/ml-worker.d.ts.map +1 -0
  58. package/dist/core/ml-worker.js +253 -0
  59. package/dist/core/ml-worker.js.map +1 -0
  60. package/dist/core/query-expander.d.ts +13 -15
  61. package/dist/core/query-expander.d.ts.map +1 -1
  62. package/dist/core/query-expander.js +48 -54
  63. package/dist/core/query-expander.js.map +1 -1
  64. package/dist/core/reranker.d.ts +9 -19
  65. package/dist/core/reranker.d.ts.map +1 -1
  66. package/dist/core/reranker.js +33 -35
  67. package/dist/core/reranker.js.map +1 -1
  68. package/dist/core/salience.d.ts +32 -5
  69. package/dist/core/salience.d.ts.map +1 -1
  70. package/dist/core/salience.js +201 -28
  71. package/dist/core/salience.js.map +1 -1
  72. package/dist/core/write-pipeline.d.ts +128 -0
  73. package/dist/core/write-pipeline.d.ts.map +1 -0
  74. package/dist/core/write-pipeline.js +405 -0
  75. package/dist/core/write-pipeline.js.map +1 -0
  76. package/dist/engine/activation.d.ts +2 -2
  77. package/dist/engine/activation.d.ts.map +1 -1
  78. package/dist/engine/activation.js +238 -115
  79. package/dist/engine/activation.js.map +1 -1
  80. package/dist/engine/confidence.d.ts +62 -0
  81. package/dist/engine/confidence.d.ts.map +1 -0
  82. package/dist/engine/confidence.js +100 -0
  83. package/dist/engine/confidence.js.map +1 -0
  84. package/dist/engine/connections.d.ts +47 -8
  85. package/dist/engine/connections.d.ts.map +1 -1
  86. package/dist/engine/connections.js +83 -22
  87. package/dist/engine/connections.js.map +1 -1
  88. package/dist/engine/consolidation-scheduler.d.ts +46 -10
  89. package/dist/engine/consolidation-scheduler.d.ts.map +1 -1
  90. package/dist/engine/consolidation-scheduler.js +170 -50
  91. package/dist/engine/consolidation-scheduler.js.map +1 -1
  92. package/dist/engine/consolidation.d.ts +16 -2
  93. package/dist/engine/consolidation.d.ts.map +1 -1
  94. package/dist/engine/consolidation.js +143 -44
  95. package/dist/engine/consolidation.js.map +1 -1
  96. package/dist/engine/eval.d.ts +2 -2
  97. package/dist/engine/eval.d.ts.map +1 -1
  98. package/dist/engine/eval.js +9 -9
  99. package/dist/engine/eval.js.map +1 -1
  100. package/dist/engine/eviction.d.ts +4 -4
  101. package/dist/engine/eviction.d.ts.map +1 -1
  102. package/dist/engine/eviction.js +15 -15
  103. package/dist/engine/eviction.js.map +1 -1
  104. package/dist/engine/retraction.d.ts +93 -5
  105. package/dist/engine/retraction.d.ts.map +1 -1
  106. package/dist/engine/retraction.js +238 -19
  107. package/dist/engine/retraction.js.map +1 -1
  108. package/dist/engine/staging.d.ts +1 -1
  109. package/dist/engine/staging.d.ts.map +1 -1
  110. package/dist/engine/staging.js +3 -3
  111. package/dist/engine/staging.js.map +1 -1
  112. package/dist/hooks/sidecar.d.ts +1 -1
  113. package/dist/hooks/sidecar.d.ts.map +1 -1
  114. package/dist/hooks/sidecar.js +4 -4
  115. package/dist/hooks/sidecar.js.map +1 -1
  116. package/dist/index.js +48 -31
  117. package/dist/index.js.map +1 -1
  118. package/dist/mcp.js +110 -155
  119. package/dist/mcp.js.map +1 -1
  120. package/dist/storage/factory.d.ts +55 -0
  121. package/dist/storage/factory.d.ts.map +1 -0
  122. package/dist/storage/factory.js +135 -0
  123. package/dist/storage/factory.js.map +1 -0
  124. package/dist/storage/pglite-schema.d.ts +20 -0
  125. package/dist/storage/pglite-schema.d.ts.map +1 -0
  126. package/dist/storage/pglite-schema.js +165 -0
  127. package/dist/storage/pglite-schema.js.map +1 -0
  128. package/dist/storage/pglite.d.ts +187 -0
  129. package/dist/storage/pglite.d.ts.map +1 -0
  130. package/dist/storage/pglite.js +1114 -0
  131. package/dist/storage/pglite.js.map +1 -0
  132. package/dist/storage/sqlite.d.ts +117 -0
  133. package/dist/storage/sqlite.d.ts.map +1 -1
  134. package/dist/storage/sqlite.js +357 -8
  135. package/dist/storage/sqlite.js.map +1 -1
  136. package/dist/storage/store.d.ts +63 -0
  137. package/dist/storage/store.d.ts.map +1 -0
  138. package/dist/storage/store.js +27 -0
  139. package/dist/storage/store.js.map +1 -0
  140. package/dist/types/engram.d.ts +92 -2
  141. package/dist/types/engram.d.ts.map +1 -1
  142. package/dist/types/engram.js.map +1 -1
  143. package/package.json +3 -1
  144. package/src/adapters/claude-code.ts +218 -234
  145. package/src/adapters/codex.ts +252 -262
  146. package/src/adapters/common.ts +411 -284
  147. package/src/adapters/cursor.ts +135 -150
  148. package/src/adapters/http.ts +93 -100
  149. package/src/adapters/index.ts +31 -31
  150. package/src/adapters/types.ts +75 -75
  151. package/src/api/index.ts +3 -3
  152. package/src/api/routes.ts +367 -120
  153. package/src/cli/migrate.ts +307 -0
  154. package/src/cli.ts +37 -1
  155. package/src/coordination/circuit-breaker.ts +83 -0
  156. package/src/coordination/events.ts +90 -90
  157. package/src/coordination/failure-modes.ts +50 -0
  158. package/src/coordination/peer-decisions.ts +105 -105
  159. package/src/coordination/plugin-loader.ts +60 -60
  160. package/src/coordination/plugin.ts +44 -44
  161. package/src/coordination/routes.ts +56 -1
  162. package/src/coordination/schema.ts +13 -0
  163. package/src/coordination/stale.ts +80 -11
  164. package/src/coordination/types.ts +311 -311
  165. package/src/coordination/write-mutex.ts +69 -69
  166. package/src/core/auto-tagger.ts +168 -168
  167. package/src/core/decay.ts +63 -63
  168. package/src/core/embeddings.ts +110 -93
  169. package/src/core/index.ts +5 -5
  170. package/src/core/logger.ts +36 -36
  171. package/src/core/ml-worker-entry.ts +194 -0
  172. package/src/core/ml-worker.ts +281 -0
  173. package/src/core/query-expander.ts +122 -128
  174. package/src/core/reranker.ts +119 -125
  175. package/src/core/salience.ts +200 -33
  176. package/src/core/write-pipeline.ts +521 -0
  177. package/src/engine/activation.ts +235 -109
  178. package/src/engine/confidence.ts +120 -0
  179. package/src/engine/connections.ts +162 -103
  180. package/src/engine/consolidation-scheduler.ts +242 -125
  181. package/src/engine/consolidation.ts +138 -45
  182. package/src/engine/eval.ts +102 -102
  183. package/src/engine/eviction.ts +101 -101
  184. package/src/engine/index.ts +8 -8
  185. package/src/engine/retraction.ts +366 -116
  186. package/src/engine/staging.ts +74 -74
  187. package/src/hooks/sidecar.ts +5 -5
  188. package/src/index.ts +42 -28
  189. package/src/mcp.ts +105 -152
  190. package/src/storage/factory.ts +147 -0
  191. package/src/storage/index.ts +3 -3
  192. package/src/storage/pglite-schema.ts +166 -0
  193. package/src/storage/pglite.ts +1363 -0
  194. package/src/storage/sqlite.ts +402 -7
  195. package/src/storage/store.ts +80 -0
  196. package/src/types/agent.ts +67 -67
  197. package/src/types/checkpoint.ts +46 -46
  198. package/src/types/engram.ts +101 -3
  199. package/src/types/eval.ts +100 -100
  200. package/src/types/index.ts +6 -6
@@ -9,6 +9,7 @@
9
9
 
10
10
  import Database from 'better-sqlite3';
11
11
  import { randomUUID } from 'node:crypto';
12
+ import { cosineSimilarity } from '../core/embeddings.js';
12
13
  import type {
13
14
  Engram, EngramCreate, EngramStage, Association, AssociationType,
14
15
  SearchQuery, SalienceFeatures, ActivationEvent, StagingEvent,
@@ -369,6 +370,26 @@ export class EngramStore {
369
370
  } catch {
370
371
  this.db.exec(`ALTER TABLE engrams ADD COLUMN memory_type TEXT NOT NULL DEFAULT 'unclassified'`);
371
372
  }
373
+
374
+ // Migration (0.8 Cluster A): sequence column for story-time / chronology.
375
+ // Nullable — existing engrams stay NULL. Partial index keeps the cost low.
376
+ try {
377
+ this.db.prepare('SELECT sequence FROM engrams LIMIT 0').get();
378
+ } catch {
379
+ this.db.exec(`ALTER TABLE engrams ADD COLUMN sequence INTEGER`);
380
+ this.db.exec(
381
+ `CREATE INDEX IF NOT EXISTS idx_engrams_agent_sequence
382
+ ON engrams(agent_id, sequence) WHERE sequence IS NOT NULL`
383
+ );
384
+ }
385
+
386
+ // Migration (0.8 Cluster A): references_json column for typed cross-record
387
+ // links. Wired through HTTP in Cluster D — schema slot only here.
388
+ try {
389
+ this.db.prepare('SELECT references_json FROM engrams LIMIT 0').get();
390
+ } catch {
391
+ this.db.exec(`ALTER TABLE engrams ADD COLUMN references_json TEXT`);
392
+ }
372
393
  }
373
394
 
374
395
  // --- Engram CRUD ---
@@ -383,8 +404,9 @@ export class EngramStore {
383
404
  this.db.prepare(`
384
405
  INSERT INTO engrams (id, agent_id, concept, content, embedding, confidence, salience,
385
406
  access_count, last_accessed, created_at, salience_features, reason_codes, stage, tags, episode_id,
386
- ttl, memory_class, supersedes, task_status, task_priority, blocked_by, memory_type)
387
- VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
407
+ ttl, memory_class, supersedes, task_status, task_priority, blocked_by, memory_type,
408
+ sequence, references_json)
409
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
388
410
  `).run(
389
411
  id, input.agentId, input.concept, input.content, embeddingBlob,
390
412
  input.confidence ?? 0.5,
@@ -401,6 +423,9 @@ export class EngramStore {
401
423
  input.taskPriority ?? null,
402
424
  input.blockedBy ?? null,
403
425
  input.memoryType ?? 'unclassified',
426
+ input.sequence ?? null,
427
+ input.references && input.references.length > 0
428
+ ? JSON.stringify(input.references) : null,
404
429
  );
405
430
 
406
431
  // Add to slim cache (skip if not yet populated — first slim fetch will load it)
@@ -575,7 +600,7 @@ export class EngramStore {
575
600
  if (agentIds.length === 1) return this.searchBM25WithRank(agentIds[0], query, limit);
576
601
 
577
602
  const sanitized = query
578
- .replace(/[^\w\s]/g, '')
603
+ .replace(/[^\w\s]/g, ' ') // Split on punctuation so hyphenated IDs (PROJ-1000) match FTS5's separator-split tokens.
579
604
  .split(/\s+/)
580
605
  .filter(w => w.length > 1)
581
606
  .map(w => `"${w}"`)
@@ -650,6 +675,16 @@ export class EngramStore {
650
675
  this.cacheUpdateStage(id, stage);
651
676
  }
652
677
 
678
+ /**
679
+ * Replace an engram's content. Used by the fade phase of consolidation
680
+ * (Paper 1: storage degradation) to coarsen un-recalled memories without
681
+ * deleting them — concept + tags stay intact for tag-based retrieval, but
682
+ * BM25 content surface area shrinks.
683
+ */
684
+ updateContent(id: string, content: string): void {
685
+ this.db.prepare('UPDATE engrams SET content = ? WHERE id = ?').run(content, id);
686
+ }
687
+
653
688
  updateConfidence(id: string, confidence: number): void {
654
689
  this.db.prepare('UPDATE engrams SET confidence = ? WHERE id = ?').run(
655
690
  Math.max(0, Math.min(1, confidence)), id
@@ -712,6 +747,46 @@ export class EngramStore {
712
747
  return this.searchBM25WithRank(agentId, query, limit).map(r => r.engram);
713
748
  }
714
749
 
750
+ /**
751
+ * Vector similarity top-K search. SQLite has no native vector index, so we
752
+ * iterate over the slim cache, compute cosine similarity in-process, and
753
+ * return the top-K nearest neighbors. PGlite uses pgvector + ivfflat for
754
+ * the same operation in O(log N).
755
+ *
756
+ * Returns engrams with their cosine distance (0 = identical, 2 = opposite)
757
+ * to match the PGlite contract. Activation engine consumes `1 - distance`
758
+ * as the cosine similarity score.
759
+ */
760
+ searchByVector(
761
+ agentId: string,
762
+ vec: number[],
763
+ limit: number = 10,
764
+ ): Array<{ engram: Engram; distance: number }> {
765
+ if (!vec || vec.length === 0) return [];
766
+ // Include both 'active' and 'fading' — faded engrams retain their embedding
767
+ // (Paper 1: storage degradation; only content is trimmed) so they should still
768
+ // participate in vector recall. Concept + tags + truncated content remain in BM25.
769
+ const activeSlim = this.getEngramsByAgentSlim(agentId, 'active', false);
770
+ const fadingSlim = this.getEngramsByAgentSlim(agentId, 'fading', false);
771
+ const slim = activeSlim.concat(fadingSlim);
772
+ const scored: Array<{ id: string; sim: number }> = [];
773
+ for (const e of slim) {
774
+ if (!e.embedding) continue;
775
+ const sim = cosineSimilarity(vec, e.embedding);
776
+ scored.push({ id: e.id, sim });
777
+ }
778
+ scored.sort((a, b) => b.sim - a.sim);
779
+ const topIds = scored.slice(0, limit).map(s => s.id);
780
+ if (topIds.length === 0) return [];
781
+ const engrams = this.getEngramsByIds(topIds);
782
+ const engramMap = new Map(engrams.map(e => [e.id, e]));
783
+ return scored
784
+ .slice(0, limit)
785
+ .map(s => ({ engram: engramMap.get(s.id), sim: s.sim }))
786
+ .filter((x): x is { engram: Engram; sim: number } => x.engram !== undefined)
787
+ .map(x => ({ engram: x.engram, distance: 1 - x.sim }));
788
+ }
789
+
715
790
  /**
716
791
  * BM25 search returning rank scores alongside engrams.
717
792
  * FTS5 rank is negative (lower = better match).
@@ -720,7 +795,7 @@ export class EngramStore {
720
795
  searchBM25WithRank(agentId: string, query: string, limit: number = 10): { engram: Engram; bm25Score: number }[] {
721
796
  // Sanitize query for FTS5: quote each word to prevent column name interpretation
722
797
  const sanitized = query
723
- .replace(/[^\w\s]/g, '')
798
+ .replace(/[^\w\s]/g, ' ') // Split on punctuation so hyphenated IDs (PROJ-1000) match FTS5's separator-split tokens.
724
799
  .split(/\s+/)
725
800
  .filter(w => w.length > 1)
726
801
  .map(w => `"${w}"`)
@@ -789,14 +864,48 @@ export class EngramStore {
789
864
  sql += ' AND retracted = ?';
790
865
  params.push(query.retracted ? 1 : 0);
791
866
  }
792
- if (query.tags && query.tags.length > 0) {
793
- for (const tag of query.tags) {
867
+
868
+ // ── Tag filters (0.8 Cluster B) ──
869
+ // AND-set: merge legacy `tags` + new `tagsAll`. Both apply (intersection).
870
+ const allTags = [...(query.tags ?? []), ...(query.tagsAll ?? [])];
871
+ if (allTags.length > 0) {
872
+ for (const tag of allTags) {
794
873
  sql += ' AND tags LIKE ?';
795
874
  params.push(`%"${tag}"%`);
796
875
  }
797
876
  }
877
+ // OR-set: at least one must match.
878
+ if (query.tagsAny && query.tagsAny.length > 0) {
879
+ const ors = query.tagsAny.map(() => 'tags LIKE ?').join(' OR ');
880
+ sql += ` AND (${ors})`;
881
+ for (const tag of query.tagsAny) params.push(`%"${tag}"%`);
882
+ }
883
+ // NOT-set: none may match.
884
+ if (query.tagsNone && query.tagsNone.length > 0) {
885
+ const ors = query.tagsNone.map(() => 'tags LIKE ?').join(' OR ');
886
+ sql += ` AND NOT (${ors})`;
887
+ for (const tag of query.tagsNone) params.push(`%"${tag}"%`);
888
+ }
889
+
890
+ // ── Sort (0.8 Cluster B) ──
891
+ // Default preserves legacy behavior (last_accessed DESC). Map TS camelCase
892
+ // to SQL snake_case. `sequence` puts NULLs last via (col IS NULL) trick.
893
+ const sortCol = ({
894
+ createdAt: 'created_at',
895
+ sequence: 'sequence',
896
+ salience: 'salience',
897
+ confidence: 'confidence',
898
+ lastAccessed: 'last_accessed',
899
+ } as const)[query.sortBy ?? 'lastAccessed'];
900
+ const dir = query.sortOrder === 'asc' ? 'ASC' : 'DESC';
901
+ if (query.sortBy === 'sequence') {
902
+ // NULLs last regardless of direction so engrams without a story-time
903
+ // value don't shuffle into the middle of sequenced results.
904
+ sql += ` ORDER BY (sequence IS NULL), sequence ${dir}`;
905
+ } else {
906
+ sql += ` ORDER BY ${sortCol} ${dir}`;
907
+ }
798
908
 
799
- sql += ' ORDER BY last_accessed DESC';
800
909
  sql += ` LIMIT ? OFFSET ?`;
801
910
  params.push(query.limit ?? 50, query.offset ?? 0);
802
911
 
@@ -889,6 +998,37 @@ export class EngramStore {
889
998
  this.db.prepare('UPDATE engrams SET supersedes = ? WHERE id = ?').run(oldId, newId);
890
999
  }
891
1000
 
1001
+ /**
1002
+ * Find the most recent active engram matching `concept` (case-insensitive,
1003
+ * trimmed) and all of `requiredTags`. Excludes superseded and retracted
1004
+ * engrams. Used by /memory/supersede Form B (0.8 Cluster D) and by
1005
+ * /memory/write's references[] resolution.
1006
+ *
1007
+ * Returns null if no match.
1008
+ */
1009
+ findActiveMatchByConcept(
1010
+ agentId: string,
1011
+ concept: string,
1012
+ requiredTags?: string[],
1013
+ ): Engram | null {
1014
+ let sql = `SELECT * FROM engrams
1015
+ WHERE agent_id = ?
1016
+ AND LOWER(TRIM(concept)) = LOWER(TRIM(?))
1017
+ AND stage = 'active'
1018
+ AND retracted = 0
1019
+ AND superseded_by IS NULL`;
1020
+ const params: any[] = [agentId, concept];
1021
+ if (requiredTags && requiredTags.length > 0) {
1022
+ for (const tag of requiredTags) {
1023
+ sql += ' AND tags LIKE ?';
1024
+ params.push(`%"${tag}"%`);
1025
+ }
1026
+ }
1027
+ sql += ' ORDER BY created_at DESC LIMIT 1';
1028
+ const row = this.db.prepare(sql).get(...params) as any;
1029
+ return row ? this.rowToEngram(row) : null;
1030
+ }
1031
+
892
1032
  /**
893
1033
  * Check if an engram has been superseded.
894
1034
  */
@@ -1223,6 +1363,8 @@ export class EngramStore {
1223
1363
  taskStatus: row.task_status ?? null,
1224
1364
  taskPriority: row.task_priority ?? null,
1225
1365
  blockedBy: row.blocked_by ?? null,
1366
+ sequence: row.sequence == null ? null : Number(row.sequence),
1367
+ references: row.references_json ? JSON.parse(row.references_json) : null,
1226
1368
  };
1227
1369
  }
1228
1370
 
@@ -1463,4 +1605,257 @@ export class EngramStore {
1463
1605
  this.walCheckpoint();
1464
1606
  this.db.close();
1465
1607
  }
1608
+
1609
+ /**
1610
+ * Run `fn` in a SQL transaction. All writes succeed atomically or roll back
1611
+ * on throw. Synchronous (matches better-sqlite3 semantics).
1612
+ *
1613
+ * Used by 0.8 Cluster D's `/memory/supersede` Form B to wrap find-match +
1614
+ * performWrite + supersede + causal-edge + confidence-decay so callers
1615
+ * never observe an intermediate state where new engram exists but old
1616
+ * isn't yet superseded.
1617
+ */
1618
+ transaction<T>(fn: () => T): T {
1619
+ return this.db.transaction(fn)();
1620
+ }
1621
+
1622
+ /**
1623
+ * Async-aware transaction wrapper. `fn` may await — we hold the SQLite
1624
+ * lock across awaits by issuing raw BEGIN/COMMIT/ROLLBACK statements rather
1625
+ * than using better-sqlite3's sync `db.transaction()`.
1626
+ *
1627
+ * Used by Form B (atomic write + supersede) where the body calls
1628
+ * async store methods through the IEngramStore contract.
1629
+ */
1630
+ async withTransaction<T>(fn: () => Promise<T>): Promise<T> {
1631
+ this.db.exec('BEGIN');
1632
+ try {
1633
+ const result = await fn();
1634
+ this.db.exec('COMMIT');
1635
+ return result;
1636
+ } catch (err) {
1637
+ try { this.db.exec('ROLLBACK'); } catch { /* best-effort */ }
1638
+ throw err;
1639
+ }
1640
+ }
1641
+
1642
+ // ============================================================
1643
+ // 0.8 Cluster C — materialized-view + atomic-counter primitives
1644
+ // ============================================================
1645
+
1646
+ /**
1647
+ * For each distinct value of the tag whose key prefix is `tagKeyPrefix`
1648
+ * (e.g. `"character="`), return the most-recent active engram. Used by
1649
+ * NovelForge's "latest emotional state per character", "latest motif
1650
+ * phase per motif", "recent chapter summaries".
1651
+ *
1652
+ * `sortBy` defaults to `createdAt`. With `"sequence"`, engrams without a
1653
+ * sequence are excluded (they have no story-time anchor).
1654
+ */
1655
+ getLatestByTag(opts: {
1656
+ agentId: string;
1657
+ tagKeyPrefix: string; // e.g. "character=", "motif=", "chapter="
1658
+ scopeTagsAll?: string[]; // optional narrowing (e.g. ["topic=motif-use"])
1659
+ retracted?: boolean;
1660
+ sortBy?: 'createdAt' | 'sequence';
1661
+ limit?: number;
1662
+ }): Engram[] {
1663
+ let sql = `SELECT * FROM engrams
1664
+ WHERE agent_id = ?
1665
+ AND retracted = ?
1666
+ AND stage = 'active'
1667
+ AND tags LIKE ?`;
1668
+ const params: any[] = [
1669
+ opts.agentId,
1670
+ opts.retracted ? 1 : 0,
1671
+ `%"${opts.tagKeyPrefix}%`,
1672
+ ];
1673
+ if (opts.scopeTagsAll && opts.scopeTagsAll.length > 0) {
1674
+ for (const t of opts.scopeTagsAll) {
1675
+ sql += ' AND tags LIKE ?';
1676
+ params.push(`%"${t}"%`);
1677
+ }
1678
+ }
1679
+ if (opts.sortBy === 'sequence') {
1680
+ sql += ' AND sequence IS NOT NULL';
1681
+ }
1682
+ sql += ' ORDER BY ' + (opts.sortBy === 'sequence' ? 'sequence DESC, created_at DESC' : 'created_at DESC');
1683
+
1684
+ const rows = this.db.prepare(sql).all(...params) as any[];
1685
+ const engrams = rows.map(r => this.rowToEngram(r));
1686
+
1687
+ // Group by extracted tag value, take first (already sorted newest first).
1688
+ const seen = new Map<string, Engram>();
1689
+ for (const e of engrams) {
1690
+ const value = this.extractTagValue(e.tags, opts.tagKeyPrefix);
1691
+ if (value == null) continue;
1692
+ if (!seen.has(value)) seen.set(value, e);
1693
+ }
1694
+ const out = Array.from(seen.values());
1695
+ return opts.limit ? out.slice(0, opts.limit) : out;
1696
+ }
1697
+
1698
+ /**
1699
+ * Filter engrams by tag-set operators and sort by numeric value extracted
1700
+ * from a tag prefix (e.g. `"weight="` → numeric value of `weight=8`).
1701
+ * Used by NovelForge's "top 40 active promises by weight".
1702
+ *
1703
+ * Missing or unparseable numeric values sort last.
1704
+ */
1705
+ getTopBy(opts: {
1706
+ agentId: string;
1707
+ sortField: string; // tag prefix, e.g. "weight="
1708
+ order: 'asc' | 'desc';
1709
+ filterTagsAll?: string[];
1710
+ filterTagsAny?: string[];
1711
+ filterTagsNone?: string[];
1712
+ limit?: number;
1713
+ retracted?: boolean;
1714
+ }): Engram[] {
1715
+ // Build the base query inline (mirrors search() but adds the topBy sort).
1716
+ let sql = `SELECT * FROM engrams
1717
+ WHERE agent_id = ?
1718
+ AND retracted = ?
1719
+ AND stage = 'active'
1720
+ AND tags LIKE ?`;
1721
+ const params: any[] = [
1722
+ opts.agentId,
1723
+ opts.retracted ? 1 : 0,
1724
+ `%"${opts.sortField}%`, // must have a tag with this prefix
1725
+ ];
1726
+
1727
+ if (opts.filterTagsAll && opts.filterTagsAll.length > 0) {
1728
+ for (const tag of opts.filterTagsAll) {
1729
+ sql += ' AND tags LIKE ?';
1730
+ params.push(`%"${tag}"%`);
1731
+ }
1732
+ }
1733
+ if (opts.filterTagsAny && opts.filterTagsAny.length > 0) {
1734
+ const ors = opts.filterTagsAny.map(() => 'tags LIKE ?').join(' OR ');
1735
+ sql += ` AND (${ors})`;
1736
+ for (const tag of opts.filterTagsAny) params.push(`%"${tag}"%`);
1737
+ }
1738
+ if (opts.filterTagsNone && opts.filterTagsNone.length > 0) {
1739
+ const ors = opts.filterTagsNone.map(() => 'tags LIKE ?').join(' OR ');
1740
+ sql += ` AND NOT (${ors})`;
1741
+ for (const tag of opts.filterTagsNone) params.push(`%"${tag}"%`);
1742
+ }
1743
+
1744
+ const rows = this.db.prepare(sql).all(...params) as any[];
1745
+ const engrams = rows.map(r => this.rowToEngram(r));
1746
+
1747
+ // Extract numeric value from the sortField tag; missing or NaN sorts last.
1748
+ const valued = engrams.map(e => {
1749
+ const raw = this.extractTagValue(e.tags, opts.sortField);
1750
+ const n = raw == null ? NaN : Number(raw);
1751
+ return { e, n };
1752
+ });
1753
+ valued.sort((a, b) => {
1754
+ const aNaN = Number.isNaN(a.n);
1755
+ const bNaN = Number.isNaN(b.n);
1756
+ if (aNaN && bNaN) return 0;
1757
+ if (aNaN) return 1; // NaN last
1758
+ if (bNaN) return -1;
1759
+ return opts.order === 'asc' ? a.n - b.n : b.n - a.n;
1760
+ });
1761
+ const sorted = valued.map(v => v.e);
1762
+ return opts.limit ? sorted.slice(0, opts.limit) : sorted;
1763
+ }
1764
+
1765
+ /**
1766
+ * Compute the effective state of an engram from referenced events.
1767
+ *
1768
+ * - 'superseded' if engram.supersededBy is set
1769
+ * - else: scan engrams whose references_json names this engram with a
1770
+ * terminal relation type (resolves / subverts / abandons). Take the
1771
+ * latest by createdAt. effectiveState = that type.
1772
+ * - else: 'active'
1773
+ *
1774
+ * Returns `null` if target engram not found.
1775
+ */
1776
+ resolveEffectiveState(targetEngramId: string): {
1777
+ engram: Engram;
1778
+ effectiveState: 'active' | 'resolved' | 'subverted' | 'abandoned' | 'superseded';
1779
+ resolvingEvents: Array<{ id: string; type: string; createdAt: string }>;
1780
+ } | null {
1781
+ const target = this.getEngram(targetEngramId);
1782
+ if (!target) return null;
1783
+
1784
+ if (target.supersededBy) {
1785
+ return { engram: target, effectiveState: 'superseded', resolvingEvents: [] };
1786
+ }
1787
+
1788
+ // Find candidate referencing engrams. LIKE-filter first to narrow,
1789
+ // then JSON.parse each candidate's references_json to verify the
1790
+ // matchEngramId and type. Cheap enough at NovelForge's scale.
1791
+ const candidates = this.db.prepare(
1792
+ `SELECT id, references_json, created_at FROM engrams
1793
+ WHERE agent_id = ?
1794
+ AND retracted = 0
1795
+ AND stage = 'active'
1796
+ AND references_json IS NOT NULL
1797
+ AND references_json LIKE ?`
1798
+ ).all(target.agentId, `%"${targetEngramId}"%`) as Array<{
1799
+ id: string;
1800
+ references_json: string;
1801
+ created_at: string;
1802
+ }>;
1803
+
1804
+ const terminalTypes = new Set(['resolves', 'subverts', 'abandons']);
1805
+ const resolvingEvents: Array<{ id: string; type: string; createdAt: string }> = [];
1806
+ for (const c of candidates) {
1807
+ try {
1808
+ const refs = JSON.parse(c.references_json) as Array<{ type: string; matchEngramId?: string }>;
1809
+ for (const ref of refs) {
1810
+ if (ref.matchEngramId === targetEngramId && terminalTypes.has(ref.type)) {
1811
+ resolvingEvents.push({ id: c.id, type: ref.type, createdAt: c.created_at });
1812
+ }
1813
+ }
1814
+ } catch { /* malformed references_json — skip */ }
1815
+ }
1816
+
1817
+ if (resolvingEvents.length === 0) {
1818
+ return { engram: target, effectiveState: 'active', resolvingEvents: [] };
1819
+ }
1820
+
1821
+ // Latest event wins.
1822
+ resolvingEvents.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
1823
+ const latestType = resolvingEvents[0]!.type as 'resolves' | 'subverts' | 'abandons';
1824
+ const effectiveState = (
1825
+ latestType === 'resolves' ? 'resolved'
1826
+ : latestType === 'subverts' ? 'subverted'
1827
+ : 'abandoned'
1828
+ );
1829
+ return { engram: target, effectiveState, resolvingEvents };
1830
+ }
1831
+
1832
+ /**
1833
+ * Atomically allocate the next sequence number for an agent.
1834
+ *
1835
+ * Uses BEGIN IMMEDIATE to serialize concurrent callers — better-sqlite3's
1836
+ * transaction() acquires a RESERVED lock which prevents other writers
1837
+ * from racing. Returns MAX(sequence)+1 for the agent, or 1 if no engrams
1838
+ * have a sequence yet.
1839
+ */
1840
+ allocateNextSequence(agentId: string): number {
1841
+ return this.transaction(() => {
1842
+ const row = this.db.prepare(
1843
+ 'SELECT MAX(sequence) AS max_seq FROM engrams WHERE agent_id = ?'
1844
+ ).get(agentId) as { max_seq: number | null };
1845
+ return (row?.max_seq ?? 0) + 1;
1846
+ });
1847
+ }
1848
+
1849
+ /**
1850
+ * Extract the value following a tag prefix, e.g. `["weight=8"]` + `"weight="`
1851
+ * → `"8"`. Returns null if no tag with that prefix is present.
1852
+ */
1853
+ private extractTagValue(tags: string[], tagKeyPrefix: string): string | null {
1854
+ for (const t of tags) {
1855
+ if (t.startsWith(tagKeyPrefix)) {
1856
+ return t.slice(tagKeyPrefix.length);
1857
+ }
1858
+ }
1859
+ return null;
1860
+ }
1466
1861
  }
@@ -0,0 +1,80 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Backend-agnostic storage contract for AWM.
5
+ *
6
+ * AWM 0.8.x introduces a pluggable storage layer:
7
+ * - SQLiteEngramStore: better-sqlite3 + FTS5 + BLOB embeddings (current default)
8
+ * - PGliteEngramStore: @electric-sql/pglite + pgvector + tsvector (opt-in via
9
+ * `AWM_STORE_BACKEND=pglite`, planned default in 0.9.x)
10
+ * - PostgresEngramStore: real Postgres backend for scale (planned, post-1.0)
11
+ *
12
+ * All backends provide the same public surface — defined here as `IEngramStore`.
13
+ * The cognitive engines (activation, consolidation, Hebbian, eviction, etc.)
14
+ * accept `IEngramStore` and work against any conforming backend.
15
+ *
16
+ * The interface is derived from the SQLite implementation via TypeScript's
17
+ * `Omit<>` so it stays in sync automatically. SQLite-specific methods
18
+ * (DB handle access, WAL checkpointing, slim-cache management, integrity
19
+ * checks) are excluded — these are implementation-internal and don't belong
20
+ * in a backend-agnostic contract.
21
+ *
22
+ * Future backends MUST implement every method on `IEngramStore`. They MAY
23
+ * additionally expose backend-specific methods (e.g., PGlite-specific tooling,
24
+ * Postgres pool management) — those are not part of the contract.
25
+ */
26
+
27
+ import type { EngramStore as SqliteEngramStore } from './sqlite.js';
28
+
29
+ /**
30
+ * Backend-specific methods on SqliteEngramStore that are NOT part of the
31
+ * shared contract. Other backends may provide functionally-similar methods
32
+ * under different names or not at all.
33
+ */
34
+ type SqliteSpecificMethods =
35
+ | 'getDb' // Returns better-sqlite3 Database — SQLite-only API
36
+ | 'integrityCheck' // SQLite PRAGMA integrity_check
37
+ | 'walCheckpoint' // SQLite WAL checkpoint
38
+ | 'stopWalCheckpointTimer'
39
+ | 'backup' // SQLite backup API; PGlite/Postgres use pg_dump
40
+ | 'warmSlimCache' // In-memory cache pre-population (SQLite-specific perf opt)
41
+ | 'resetSlimCache'
42
+ | 'getSlimCacheStats'
43
+ | 'transaction'; // SQLite sync transaction helper — PGlite uses withTransaction
44
+
45
+ /**
46
+ * MaybePromise — covariant union that lets sync and async backends share one contract.
47
+ *
48
+ * Engines `await` every store call. `await T` resolves to T immediately when
49
+ * the backend is sync (SQLite, returning bare values) and resolves the Promise
50
+ * when the backend is async (PGlite). Both shapes satisfy the same interface.
51
+ */
52
+ type MaybePromise<T> = T | Promise<T>;
53
+
54
+ /**
55
+ * Turn every method return type R into `MaybePromise<Awaited<R>>` so the
56
+ * contract accepts both sync and async backends.
57
+ */
58
+ type AsyncifyMethods<T> = {
59
+ [K in keyof T]: T[K] extends (...args: infer A) => infer R
60
+ ? (...args: A) => MaybePromise<Awaited<R>>
61
+ : T[K];
62
+ };
63
+
64
+ /**
65
+ * The backend-agnostic storage contract.
66
+ *
67
+ * Any class with this shape can be used as the EngramStore for the AWM
68
+ * cognitive engines. New backends should `implements IEngramStore` to get
69
+ * compile-time enforcement of the full surface.
70
+ */
71
+ export type IEngramStore = AsyncifyMethods<Omit<SqliteEngramStore, SqliteSpecificMethods>>;
72
+
73
+ /**
74
+ * Convenience type-only re-export so consumers can `import type { EngramStore }`
75
+ * from this module and get the backend-agnostic contract instead of the
76
+ * SQLite-specific class. Existing imports from `'../storage/sqlite.js'`
77
+ * continue to work and resolve to the SQLite class (which is a structural
78
+ * supertype of IEngramStore).
79
+ */
80
+ export type EngramStore = IEngramStore;