atlas-mcp 0.1.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 (54) hide show
  1. package/.env.example +32 -0
  2. package/README.md +282 -0
  3. package/package.json +72 -0
  4. package/public/app/assets/app-CxbS1w9p.js +3981 -0
  5. package/public/app/assets/index-BA6nxCuI.css +1 -0
  6. package/public/app/assets/index-BXmIRrQH.js +177 -0
  7. package/public/app/index.html +27 -0
  8. package/public/assets/brain-atlas.LICENSE.txt +16 -0
  9. package/public/assets/brain-atlas.glb +0 -0
  10. package/public/assets/brain.obj +27282 -0
  11. package/public/fonts/DepartureMono-Regular.woff +0 -0
  12. package/public/fonts/DepartureMono-Regular.woff2 +0 -0
  13. package/scripts/sync-memory-vectors.js +46 -0
  14. package/src/audit.js +9 -0
  15. package/src/cli/args.js +87 -0
  16. package/src/cli/commands/add.js +103 -0
  17. package/src/cli/commands/config.js +228 -0
  18. package/src/cli/commands/delete.js +75 -0
  19. package/src/cli/commands/entities.js +39 -0
  20. package/src/cli/commands/entity.js +47 -0
  21. package/src/cli/commands/get.js +46 -0
  22. package/src/cli/commands/list.js +53 -0
  23. package/src/cli/commands/related.js +56 -0
  24. package/src/cli/commands/search.js +68 -0
  25. package/src/cli/commands/update.js +58 -0
  26. package/src/cli/deps.js +114 -0
  27. package/src/cli/env-file.js +44 -0
  28. package/src/cli/format.js +246 -0
  29. package/src/cli.js +187 -0
  30. package/src/cognitive-worker.js +381 -0
  31. package/src/db.js +2674 -0
  32. package/src/extraction-context.js +31 -0
  33. package/src/ingestion-service.js +387 -0
  34. package/src/ingestion-worker.js +225 -0
  35. package/src/llm-config.js +31 -0
  36. package/src/llm.js +789 -0
  37. package/src/logger.js +51 -0
  38. package/src/mcp-server.js +577 -0
  39. package/src/memory-comparison.js +421 -0
  40. package/src/related-memories.js +232 -0
  41. package/src/run-cognitive-worker.js +12 -0
  42. package/src/run-ingestion-worker.js +13 -0
  43. package/src/run-vector-worker.js +12 -0
  44. package/src/schemas.js +413 -0
  45. package/src/semantic-validation.js +430 -0
  46. package/src/server.js +827 -0
  47. package/src/shared/brain-regions.js +61 -0
  48. package/src/shared/entity-lens.js +249 -0
  49. package/src/shared/memory-placement.js +171 -0
  50. package/src/shared/memory-search.js +55 -0
  51. package/src/shared/region-anchors.js +112 -0
  52. package/src/shared/region-mapper.js +247 -0
  53. package/src/vector-store.js +546 -0
  54. package/src/vector-worker.js +71 -0
package/src/db.js ADDED
@@ -0,0 +1,2674 @@
1
+ import Database from "better-sqlite3";
2
+ import { fileURLToPath } from "url";
3
+ import { dirname, join } from "path";
4
+ import {
5
+ REGION_MAPPING_VERSION,
6
+ mapExtractionToRegions,
7
+ } from "./shared/region-mapper.js";
8
+ import { EXTRACTION_SCHEMA_VERSION } from "./schemas.js";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const DB_PATH = process.env.ENGRAM_DB_PATH || join(__dirname, "..", "engram.db");
12
+
13
+ let db;
14
+
15
+ export function getDb() {
16
+ if (!db) {
17
+ db = new Database(DB_PATH);
18
+ db.pragma("journal_mode = WAL");
19
+ db.pragma("foreign_keys = ON");
20
+ initSchema();
21
+ }
22
+ return db;
23
+ }
24
+
25
+ function initSchema() {
26
+ db.exec(`
27
+ CREATE TABLE IF NOT EXISTS memories (
28
+ id TEXT PRIMARY KEY,
29
+ raw_text TEXT NOT NULL,
30
+ ingestion_date TEXT NOT NULL,
31
+ summary TEXT,
32
+ type TEXT NOT NULL DEFAULT 'fact',
33
+ title TEXT NOT NULL DEFAULT '',
34
+ confidence REAL NOT NULL DEFAULT 0.6,
35
+ tags TEXT NOT NULL DEFAULT '[]',
36
+ scope TEXT NOT NULL DEFAULT 'agent',
37
+ source TEXT NOT NULL DEFAULT 'ui',
38
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
39
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS memory_extractions (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ memory_id TEXT NOT NULL,
45
+ extraction_json TEXT NOT NULL,
46
+ model TEXT NOT NULL,
47
+ schema_version INTEGER NOT NULL DEFAULT 1,
48
+ authoritative INTEGER NOT NULL DEFAULT 0,
49
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
50
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS entities (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ canonical_name TEXT NOT NULL,
56
+ kind TEXT NOT NULL CHECK (kind IN ('person', 'place', 'object', 'concept', 'organization')),
57
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
58
+ UNIQUE(canonical_name, kind)
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS entity_aliases (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ entity_id INTEGER NOT NULL,
64
+ alias TEXT NOT NULL,
65
+ normalized_alias TEXT NOT NULL,
66
+ canonical INTEGER NOT NULL DEFAULT 0,
67
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
68
+ FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
69
+ UNIQUE(entity_id, normalized_alias)
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS entity_resolution_suggestions (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ source_entity_id INTEGER NOT NULL,
75
+ target_entity_id INTEGER NOT NULL,
76
+ source_name TEXT NOT NULL,
77
+ target_name TEXT NOT NULL,
78
+ kind TEXT NOT NULL,
79
+ observed_alias TEXT NOT NULL,
80
+ normalized_alias TEXT NOT NULL,
81
+ status TEXT NOT NULL DEFAULT 'pending'
82
+ CHECK (status IN ('pending', 'merged', 'rejected')),
83
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
84
+ resolved_at TEXT,
85
+ CHECK (source_entity_id <> target_entity_id),
86
+ UNIQUE(source_entity_id, target_entity_id)
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS memory_entities (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ memory_id TEXT NOT NULL,
92
+ entity_id INTEGER NOT NULL,
93
+ mention TEXT NOT NULL,
94
+ role TEXT,
95
+ confidence REAL NOT NULL DEFAULT 1.0,
96
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
97
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE,
98
+ FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
99
+ UNIQUE(memory_id, entity_id, mention)
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS relationships (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ source_entity_id INTEGER NOT NULL,
105
+ target_entity_id INTEGER NOT NULL,
106
+ predicate TEXT NOT NULL,
107
+ memory_id TEXT NOT NULL,
108
+ confidence REAL NOT NULL DEFAULT 1.0,
109
+ evidence TEXT,
110
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
111
+ FOREIGN KEY (source_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
112
+ FOREIGN KEY (target_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
113
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE,
114
+ UNIQUE(source_entity_id, target_entity_id, predicate, memory_id)
115
+ );
116
+
117
+ CREATE TABLE IF NOT EXISTS region_activations (
118
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
119
+ memory_id TEXT NOT NULL,
120
+ region TEXT NOT NULL,
121
+ weight REAL NOT NULL,
122
+ left_weight REAL,
123
+ right_weight REAL,
124
+ mapping_version INTEGER NOT NULL DEFAULT 1,
125
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
126
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE,
127
+ UNIQUE(memory_id, region, mapping_version)
128
+ );
129
+
130
+ CREATE TABLE IF NOT EXISTS memory_comparisons (
131
+ left_memory_id TEXT NOT NULL,
132
+ right_memory_id TEXT NOT NULL,
133
+ input_hash TEXT NOT NULL,
134
+ model TEXT NOT NULL,
135
+ schema_version INTEGER NOT NULL,
136
+ comparison_json TEXT NOT NULL,
137
+ generated_at TEXT NOT NULL,
138
+ PRIMARY KEY (
139
+ left_memory_id,
140
+ right_memory_id,
141
+ input_hash,
142
+ model,
143
+ schema_version
144
+ ),
145
+ FOREIGN KEY (left_memory_id) REFERENCES memories(id) ON DELETE CASCADE,
146
+ FOREIGN KEY (right_memory_id) REFERENCES memories(id) ON DELETE CASCADE
147
+ );
148
+
149
+ CREATE TABLE IF NOT EXISTS memory_revisions (
150
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
151
+ memory_id TEXT NOT NULL,
152
+ revision_number INTEGER NOT NULL,
153
+ snapshot_json TEXT NOT NULL,
154
+ created_at TEXT NOT NULL,
155
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE,
156
+ UNIQUE(memory_id, revision_number)
157
+ );
158
+
159
+ CREATE TABLE IF NOT EXISTS memory_sources (
160
+ id TEXT PRIMARY KEY,
161
+ text TEXT NOT NULL,
162
+ source TEXT NOT NULL CHECK (source IN ('ui', 'mcp', 'cli', 'import')),
163
+ ingestion_date TEXT NOT NULL,
164
+ metadata_json TEXT NOT NULL DEFAULT '{}',
165
+ extraction_status TEXT NOT NULL DEFAULT 'pending'
166
+ CHECK (extraction_status IN ('pending', 'processing', 'completed', 'failed')),
167
+ extraction_attempts INTEGER NOT NULL DEFAULT 0,
168
+ extraction_error TEXT,
169
+ extraction_model TEXT,
170
+ extraction_schema_version INTEGER,
171
+ created_at TEXT NOT NULL,
172
+ updated_at TEXT NOT NULL
173
+ );
174
+
175
+ CREATE TABLE IF NOT EXISTS source_revisions (
176
+ id TEXT PRIMARY KEY,
177
+ source_id TEXT NOT NULL,
178
+ text TEXT NOT NULL,
179
+ author TEXT,
180
+ reason TEXT,
181
+ metadata_json TEXT NOT NULL DEFAULT '{}',
182
+ created_at TEXT NOT NULL,
183
+ FOREIGN KEY (source_id) REFERENCES memory_sources(id) ON DELETE CASCADE
184
+ );
185
+
186
+ CREATE TABLE IF NOT EXISTS source_memory_links (
187
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
188
+ source_id TEXT NOT NULL,
189
+ source_revision_id TEXT,
190
+ memory_id TEXT NOT NULL,
191
+ action TEXT NOT NULL CHECK (action IN ('created', 'updated', 'unchanged')),
192
+ evidence_json TEXT NOT NULL,
193
+ extraction_model TEXT,
194
+ extraction_schema_version INTEGER NOT NULL,
195
+ decision_confidence REAL NOT NULL,
196
+ decision_reason TEXT NOT NULL,
197
+ created_at TEXT NOT NULL,
198
+ FOREIGN KEY (source_id) REFERENCES memory_sources(id) ON DELETE CASCADE,
199
+ FOREIGN KEY (source_revision_id) REFERENCES source_revisions(id) ON DELETE SET NULL,
200
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
201
+ );
202
+
203
+ CREATE TABLE IF NOT EXISTS cognitive_annotations (
204
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
205
+ memory_id TEXT NOT NULL,
206
+ memory_version INTEGER NOT NULL,
207
+ annotation_json TEXT NOT NULL,
208
+ model TEXT NOT NULL,
209
+ schema_version INTEGER NOT NULL,
210
+ created_at TEXT NOT NULL,
211
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE,
212
+ UNIQUE(memory_id, memory_version, schema_version)
213
+ );
214
+
215
+ CREATE TABLE IF NOT EXISTS annotation_jobs (
216
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
217
+ memory_id TEXT NOT NULL,
218
+ source_id TEXT,
219
+ memory_version INTEGER NOT NULL,
220
+ model TEXT NOT NULL,
221
+ schema_version INTEGER NOT NULL,
222
+ status TEXT NOT NULL DEFAULT 'pending'
223
+ CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
224
+ attempts INTEGER NOT NULL DEFAULT 0,
225
+ max_attempts INTEGER NOT NULL DEFAULT 5,
226
+ available_at TEXT NOT NULL,
227
+ claimed_at TEXT,
228
+ completed_at TEXT,
229
+ last_error TEXT,
230
+ created_at TEXT NOT NULL,
231
+ updated_at TEXT NOT NULL,
232
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE,
233
+ FOREIGN KEY (source_id) REFERENCES memory_sources(id) ON DELETE SET NULL,
234
+ UNIQUE(memory_id, memory_version, schema_version)
235
+ );
236
+
237
+ CREATE TABLE IF NOT EXISTS vector_index_jobs (
238
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
239
+ memory_id TEXT NOT NULL,
240
+ source_id TEXT,
241
+ memory_version INTEGER NOT NULL,
242
+ status TEXT NOT NULL DEFAULT 'pending'
243
+ CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
244
+ attempts INTEGER NOT NULL DEFAULT 0,
245
+ max_attempts INTEGER NOT NULL DEFAULT 8,
246
+ available_at TEXT NOT NULL,
247
+ claimed_at TEXT,
248
+ completed_at TEXT,
249
+ last_error TEXT,
250
+ created_at TEXT NOT NULL,
251
+ updated_at TEXT NOT NULL,
252
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE,
253
+ FOREIGN KEY (source_id) REFERENCES memory_sources(id) ON DELETE SET NULL,
254
+ UNIQUE(memory_id, memory_version)
255
+ );
256
+
257
+ CREATE TABLE IF NOT EXISTS ingestion_jobs (
258
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
259
+ source_id TEXT NOT NULL,
260
+ status TEXT NOT NULL DEFAULT 'pending'
261
+ CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
262
+ attempts INTEGER NOT NULL DEFAULT 0,
263
+ max_attempts INTEGER NOT NULL DEFAULT 5,
264
+ available_at TEXT NOT NULL,
265
+ claimed_at TEXT,
266
+ completed_at TEXT,
267
+ last_error TEXT,
268
+ created_at TEXT NOT NULL,
269
+ updated_at TEXT NOT NULL,
270
+ FOREIGN KEY (source_id) REFERENCES memory_sources(id) ON DELETE CASCADE,
271
+ UNIQUE(source_id)
272
+ );
273
+
274
+ CREATE INDEX IF NOT EXISTS idx_extractions_memory ON memory_extractions(memory_id);
275
+ CREATE INDEX IF NOT EXISTS idx_extractions_memory_latest
276
+ ON memory_extractions(memory_id, created_at DESC, id DESC);
277
+ CREATE INDEX IF NOT EXISTS idx_entities_kind ON entities(kind);
278
+ CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(canonical_name);
279
+ CREATE INDEX IF NOT EXISTS idx_entity_aliases_entity
280
+ ON entity_aliases(entity_id);
281
+ CREATE INDEX IF NOT EXISTS idx_entity_aliases_normalized
282
+ ON entity_aliases(normalized_alias);
283
+ CREATE INDEX IF NOT EXISTS idx_entity_resolution_status
284
+ ON entity_resolution_suggestions(status, created_at DESC);
285
+ CREATE INDEX IF NOT EXISTS idx_memory_entities_memory ON memory_entities(memory_id);
286
+ CREATE INDEX IF NOT EXISTS idx_memory_entities_entity ON memory_entities(entity_id);
287
+ CREATE INDEX IF NOT EXISTS idx_relationships_source ON relationships(source_entity_id);
288
+ CREATE INDEX IF NOT EXISTS idx_relationships_target ON relationships(target_entity_id);
289
+ CREATE INDEX IF NOT EXISTS idx_relationships_memory ON relationships(memory_id);
290
+ CREATE INDEX IF NOT EXISTS idx_region_activations_memory ON region_activations(memory_id);
291
+ CREATE INDEX IF NOT EXISTS idx_memory_comparisons_left ON memory_comparisons(left_memory_id);
292
+ CREATE INDEX IF NOT EXISTS idx_memory_comparisons_right ON memory_comparisons(right_memory_id);
293
+ CREATE INDEX IF NOT EXISTS idx_memory_revisions_memory
294
+ ON memory_revisions(memory_id, revision_number DESC);
295
+ CREATE INDEX IF NOT EXISTS idx_sources_status
296
+ ON memory_sources(extraction_status, updated_at);
297
+ CREATE INDEX IF NOT EXISTS idx_source_revisions_source
298
+ ON source_revisions(source_id, created_at DESC);
299
+ CREATE INDEX IF NOT EXISTS idx_source_links_source
300
+ ON source_memory_links(source_id, created_at ASC);
301
+ CREATE INDEX IF NOT EXISTS idx_source_links_memory
302
+ ON source_memory_links(memory_id, created_at DESC);
303
+ CREATE INDEX IF NOT EXISTS idx_annotation_jobs_claim
304
+ ON annotation_jobs(status, available_at, id);
305
+ CREATE INDEX IF NOT EXISTS idx_vector_jobs_claim
306
+ ON vector_index_jobs(status, available_at, id);
307
+ CREATE INDEX IF NOT EXISTS idx_ingestion_jobs_claim
308
+ ON ingestion_jobs(status, available_at, id);
309
+ `);
310
+
311
+ const memoryColumns = db.prepare("PRAGMA table_info(memories)").all();
312
+ const memoryMigrations = [
313
+ ["source", "TEXT NOT NULL DEFAULT 'ui'"],
314
+ ["type", "TEXT NOT NULL DEFAULT 'fact'"],
315
+ ["title", "TEXT NOT NULL DEFAULT ''"],
316
+ ["confidence", "REAL NOT NULL DEFAULT 0.6"],
317
+ ["tags", "TEXT NOT NULL DEFAULT '[]'"],
318
+ ["scope", "TEXT NOT NULL DEFAULT 'agent'"],
319
+ ["version", "INTEGER NOT NULL DEFAULT 1"],
320
+ ];
321
+ for (const [name, definition] of memoryMigrations) {
322
+ if (!memoryColumns.some((column) => column.name === name)) {
323
+ db.exec(`ALTER TABLE memories ADD COLUMN ${name} ${definition}`);
324
+ }
325
+ }
326
+ db.exec(`
327
+ UPDATE memories
328
+ SET title = substr(raw_text, 1, 50)
329
+ WHERE title = ''
330
+ `);
331
+
332
+ const regionActivationColumns = db
333
+ .prepare("PRAGMA table_info(region_activations)")
334
+ .all();
335
+ if (!regionActivationColumns.some((column) => column.name === "left_weight")) {
336
+ db.exec("ALTER TABLE region_activations ADD COLUMN left_weight REAL");
337
+ }
338
+ if (!regionActivationColumns.some((column) => column.name === "right_weight")) {
339
+ db.exec("ALTER TABLE region_activations ADD COLUMN right_weight REAL");
340
+ }
341
+
342
+ migrateSourceTables();
343
+
344
+ db.exec(`
345
+ CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source);
346
+ `);
347
+
348
+ mergeDuplicateEntities();
349
+ backfillCanonicalEntityAliases();
350
+ backfillLegacySources();
351
+
352
+ db.exec(`
353
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
354
+ memory_id UNINDEXED,
355
+ title,
356
+ summary,
357
+ raw_text,
358
+ tags,
359
+ tokenize='porter unicode61'
360
+ );
361
+ `);
362
+ syncMemoriesToFts();
363
+ }
364
+
365
+ function addMissingColumns(table, columns) {
366
+ const existingColumns = db.prepare(`PRAGMA table_info(${table})`).all();
367
+ for (const [name, definition] of columns) {
368
+ if (!existingColumns.some((column) => column.name === name)) {
369
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
370
+ }
371
+ }
372
+ }
373
+
374
+ function hasColumn(table, columnName) {
375
+ return db.prepare(`PRAGMA table_info(${table})`).all()
376
+ .some((column) => column.name === columnName);
377
+ }
378
+
379
+ function migrateSourceTables() {
380
+ addMissingColumns("memory_sources", [
381
+ ["text", "TEXT NOT NULL DEFAULT ''"],
382
+ ["source", "TEXT NOT NULL DEFAULT 'import'"],
383
+ ["ingestion_date", "TEXT NOT NULL DEFAULT ''"],
384
+ ["extraction_error", "TEXT"],
385
+ ["extraction_model", "TEXT"],
386
+ ["extraction_schema_version", "INTEGER"],
387
+ ]);
388
+ addMissingColumns("source_revisions", [
389
+ ["text", "TEXT NOT NULL DEFAULT ''"],
390
+ ["author", "TEXT"],
391
+ ]);
392
+ addMissingColumns("source_memory_links", [
393
+ ["extraction_model", "TEXT"],
394
+ ["extraction_schema_version", "INTEGER NOT NULL DEFAULT 1"],
395
+ ["decision_confidence", "REAL NOT NULL DEFAULT 0"],
396
+ ["decision_reason", "TEXT NOT NULL DEFAULT ''"],
397
+ ]);
398
+ migrateAnnotationJobs();
399
+ }
400
+
401
+ function migrateAnnotationJobs() {
402
+ const columns = db.prepare("PRAGMA table_info(annotation_jobs)").all();
403
+ const currentColumns = ["source_id", "memory_version", "available_at"];
404
+ if (currentColumns.every((name) =>
405
+ columns.some((column) => column.name === name))) {
406
+ return;
407
+ }
408
+
409
+ db.exec(`
410
+ DROP INDEX IF EXISTS idx_annotation_jobs_claim;
411
+ DROP INDEX IF EXISTS idx_annotation_jobs_memory;
412
+ ALTER TABLE annotation_jobs RENAME TO annotation_jobs_legacy;
413
+
414
+ CREATE TABLE annotation_jobs (
415
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
416
+ memory_id TEXT NOT NULL,
417
+ source_id TEXT,
418
+ memory_version INTEGER NOT NULL,
419
+ model TEXT NOT NULL,
420
+ schema_version INTEGER NOT NULL,
421
+ status TEXT NOT NULL DEFAULT 'pending'
422
+ CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
423
+ attempts INTEGER NOT NULL DEFAULT 0,
424
+ max_attempts INTEGER NOT NULL DEFAULT 5,
425
+ available_at TEXT NOT NULL,
426
+ claimed_at TEXT,
427
+ completed_at TEXT,
428
+ last_error TEXT,
429
+ created_at TEXT NOT NULL,
430
+ updated_at TEXT NOT NULL,
431
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE,
432
+ FOREIGN KEY (source_id) REFERENCES memory_sources(id) ON DELETE SET NULL,
433
+ UNIQUE(memory_id, memory_version, schema_version)
434
+ );
435
+
436
+ INSERT OR IGNORE INTO annotation_jobs (
437
+ memory_id, source_id, memory_version, model, schema_version, status,
438
+ attempts, max_attempts, available_at, claimed_at, completed_at,
439
+ last_error, created_at, updated_at
440
+ )
441
+ SELECT
442
+ legacy.memory_id,
443
+ NULL,
444
+ COALESCE(memory.version, 1),
445
+ legacy.model,
446
+ legacy.schema_version,
447
+ CASE legacy.status WHEN 'retry' THEN 'pending' ELSE legacy.status END,
448
+ legacy.attempts,
449
+ legacy.max_attempts,
450
+ COALESCE(legacy.retry_at, legacy.created_at),
451
+ legacy.claimed_at,
452
+ legacy.completed_at,
453
+ legacy.last_error,
454
+ legacy.created_at,
455
+ legacy.updated_at
456
+ FROM annotation_jobs_legacy AS legacy
457
+ JOIN memories AS memory ON memory.id = legacy.memory_id
458
+ ORDER BY legacy.created_at, legacy.id;
459
+
460
+ DROP TABLE annotation_jobs_legacy;
461
+ CREATE INDEX idx_annotation_jobs_claim
462
+ ON annotation_jobs(status, available_at, id);
463
+ CREATE INDEX idx_annotation_jobs_memory
464
+ ON annotation_jobs(memory_id, created_at DESC);
465
+ `);
466
+ }
467
+
468
+ export function withTransaction(callback) {
469
+ return getDb().transaction(callback)();
470
+ }
471
+
472
+ // --- Immutable sources and durable work queues ---
473
+
474
+ export function createMemorySource({ id, text, source = "ui", ingestionDate, metadata = {} }) {
475
+ const now = new Date().toISOString();
476
+ const database = getDb();
477
+ if (hasColumn("memory_sources", "content")) {
478
+ database.prepare(`
479
+ INSERT INTO memory_sources (
480
+ id, text, content, source, ingestion_date, metadata_json, created_at, updated_at
481
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
482
+ `).run(id, text, text, source, ingestionDate, JSON.stringify(metadata), now, now);
483
+ } else {
484
+ database.prepare(`
485
+ INSERT INTO memory_sources (
486
+ id, text, source, ingestion_date, metadata_json, created_at, updated_at
487
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
488
+ `).run(id, text, source, ingestionDate, JSON.stringify(metadata), now, now);
489
+ }
490
+ return getMemorySource(id);
491
+ }
492
+
493
+ export function getMemorySource(id, { includeRevisions = false } = {}) {
494
+ const database = getDb();
495
+ const row = database.prepare("SELECT * FROM memory_sources WHERE id = ?").get(id);
496
+ if (!row) return null;
497
+ const result = { ...row, metadata_json: parseJson(row.metadata_json, {}) };
498
+ if (includeRevisions) {
499
+ result.revisions = database.prepare(`
500
+ SELECT * FROM source_revisions
501
+ WHERE source_id = ? ORDER BY created_at DESC, id DESC
502
+ `).all(id).map((revision) => ({
503
+ ...revision,
504
+ metadata_json: parseJson(revision.metadata_json, {}),
505
+ }));
506
+ }
507
+ return result;
508
+ }
509
+
510
+ export function createSourceRevision({
511
+ id, sourceId, text, author = null, reason = null, metadata = {},
512
+ }) {
513
+ const createdAt = new Date().toISOString();
514
+ const database = getDb();
515
+ if (hasColumn("source_revisions", "content")) {
516
+ const revisionNumber = database.prepare(`
517
+ SELECT COALESCE(MAX(revision_number), 0) + 1 AS value
518
+ FROM source_revisions WHERE source_id = ?
519
+ `).get(sourceId).value;
520
+ database.prepare(`
521
+ INSERT INTO source_revisions (
522
+ id, source_id, text, content, revision_number, author, reason,
523
+ metadata_json, created_at
524
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
525
+ `).run(id, sourceId, text, text, revisionNumber, author, reason,
526
+ JSON.stringify(metadata), createdAt);
527
+ } else {
528
+ database.prepare(`
529
+ INSERT INTO source_revisions (
530
+ id, source_id, text, author, reason, metadata_json, created_at
531
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
532
+ `).run(id, sourceId, text, author, reason, JSON.stringify(metadata), createdAt);
533
+ }
534
+ return database.prepare("SELECT * FROM source_revisions WHERE id = ?").get(id);
535
+ }
536
+
537
+ export function updateMemorySourceStatus(id, status, {
538
+ incrementAttempts = false,
539
+ error = null,
540
+ model = null,
541
+ schemaVersion = null,
542
+ } = {}) {
543
+ const normalizedStatus = normalizeSourceStatus(status);
544
+ const updatedAt = new Date().toISOString();
545
+ getDb().prepare(`
546
+ UPDATE memory_sources
547
+ SET extraction_status = ?, extraction_attempts = extraction_attempts + ?,
548
+ extraction_error = ?, extraction_model = COALESCE(?, extraction_model),
549
+ extraction_schema_version = COALESCE(?, extraction_schema_version),
550
+ updated_at = ?
551
+ WHERE id = ?
552
+ `).run(normalizedStatus, incrementAttempts ? 1 : 0, error, model,
553
+ schemaVersion, updatedAt, id);
554
+ return getMemorySource(id);
555
+ }
556
+
557
+ function normalizeSourceStatus(status) {
558
+ if (status !== "failed" && status !== "extraction_failed") return status;
559
+ const tableSql = getDb().prepare(`
560
+ SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_sources'
561
+ `).get()?.sql || "";
562
+ return tableSql.includes("'extraction_failed'") ? "extraction_failed" : "failed";
563
+ }
564
+
565
+ export function linkSourceMemory({
566
+ sourceId, sourceRevisionId = null, memoryId, action, evidence,
567
+ model = null, schemaVersion = EXTRACTION_SCHEMA_VERSION,
568
+ confidence = 0, reason = "",
569
+ }) {
570
+ const result = getDb().prepare(`
571
+ INSERT INTO source_memory_links (
572
+ source_id, source_revision_id, memory_id, action, evidence_json,
573
+ extraction_model, extraction_schema_version, decision_confidence,
574
+ decision_reason, created_at
575
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
576
+ `).run(sourceId, sourceRevisionId, memoryId, action,
577
+ JSON.stringify(evidence || []), model, schemaVersion, confidence, reason,
578
+ new Date().toISOString());
579
+ return result.lastInsertRowid;
580
+ }
581
+
582
+ export function getSourceMemoryLinks(sourceId) {
583
+ return getDb().prepare(`
584
+ SELECT * FROM source_memory_links WHERE source_id = ? ORDER BY id ASC
585
+ `).all(sourceId).map((row) => ({
586
+ ...row,
587
+ evidence_json: parseJson(row.evidence_json, []),
588
+ }));
589
+ }
590
+
591
+ function memoryVersion(memoryId) {
592
+ const memory = getDb().prepare("SELECT version FROM memories WHERE id = ?").get(memoryId);
593
+ if (!memory) throw new Error(`Memory not found: ${memoryId}`);
594
+ return memory.version;
595
+ }
596
+
597
+ export function enqueueAnnotationJob({ memoryId, sourceId = null, model, schemaVersion }) {
598
+ const now = new Date().toISOString();
599
+ getDb().prepare(`
600
+ INSERT INTO annotation_jobs (
601
+ memory_id, source_id, memory_version, model, schema_version,
602
+ available_at, created_at, updated_at
603
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
604
+ ON CONFLICT(memory_id, memory_version, schema_version) DO NOTHING
605
+ `).run(memoryId, sourceId, memoryVersion(memoryId), model, schemaVersion,
606
+ now, now, now);
607
+ }
608
+
609
+ export function enqueueVectorIndexJob({ memoryId, sourceId = null }) {
610
+ const now = new Date().toISOString();
611
+ getDb().prepare(`
612
+ INSERT INTO vector_index_jobs (
613
+ memory_id, source_id, memory_version, available_at, created_at, updated_at
614
+ ) VALUES (?, ?, ?, ?, ?, ?)
615
+ ON CONFLICT(memory_id, memory_version) DO NOTHING
616
+ `).run(memoryId, sourceId, memoryVersion(memoryId), now, now, now);
617
+ }
618
+
619
+ export function enqueueIngestionJob({ sourceId }) {
620
+ const now = new Date().toISOString();
621
+ getDb().prepare(`
622
+ INSERT INTO ingestion_jobs (
623
+ source_id, available_at, created_at, updated_at
624
+ ) VALUES (?, ?, ?, ?)
625
+ ON CONFLICT(source_id) DO NOTHING
626
+ `).run(sourceId, now, now, now);
627
+ }
628
+
629
+ export function getIngestionStatus(sourceId) {
630
+ return getDb().prepare(`
631
+ SELECT status FROM ingestion_jobs WHERE source_id = ?
632
+ ORDER BY id DESC LIMIT 1
633
+ `).get(sourceId)?.status ?? null;
634
+ }
635
+
636
+ export function getAnnotationStatus(memoryId) {
637
+ return getDb().prepare(`
638
+ SELECT status FROM annotation_jobs WHERE memory_id = ?
639
+ ORDER BY memory_version DESC, id DESC LIMIT 1
640
+ `).get(memoryId)?.status ?? null;
641
+ }
642
+
643
+ export function getVectorIndexStatus(memoryId) {
644
+ return getDb().prepare(`
645
+ SELECT status FROM vector_index_jobs WHERE memory_id = ?
646
+ ORDER BY memory_version DESC, id DESC LIMIT 1
647
+ `).get(memoryId)?.status ?? null;
648
+ }
649
+
650
+ function claimJob(table, now) {
651
+ const database = getDb();
652
+ return database.transaction(() => {
653
+ const job = database.prepare(`
654
+ SELECT * FROM ${table} WHERE status = 'pending' AND available_at <= ?
655
+ ORDER BY available_at ASC, id ASC LIMIT 1
656
+ `).get(now);
657
+ if (!job) return null;
658
+ database.prepare(`
659
+ UPDATE ${table} SET status = 'processing', attempts = attempts + 1,
660
+ claimed_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'
661
+ `).run(now, now, job.id);
662
+ return database.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(job.id);
663
+ })();
664
+ }
665
+
666
+ export function claimAnnotationJob({ now = new Date().toISOString() } = {}) {
667
+ return claimJob("annotation_jobs", now);
668
+ }
669
+
670
+ export function claimVectorIndexJob({ now = new Date().toISOString() } = {}) {
671
+ return claimJob("vector_index_jobs", now);
672
+ }
673
+
674
+ export function claimIngestionJob({ now = new Date().toISOString() } = {}) {
675
+ return claimJob("ingestion_jobs", now);
676
+ }
677
+
678
+ function recoverJobs(table, { now, retryAt = now, staleBefore }) {
679
+ return getDb().prepare(`
680
+ UPDATE ${table} SET status = 'pending', available_at = ?, claimed_at = NULL,
681
+ updated_at = ? WHERE status = 'processing' AND claimed_at < ?
682
+ `).run(retryAt, now, staleBefore).changes;
683
+ }
684
+
685
+ export function recoverAnnotationJobs(options) {
686
+ return recoverJobs("annotation_jobs", options);
687
+ }
688
+
689
+ export function recoverVectorIndexJobs(options) {
690
+ return recoverJobs("vector_index_jobs", options);
691
+ }
692
+
693
+ export function recoverIngestionJobs(options) {
694
+ return recoverJobs("ingestion_jobs", options);
695
+ }
696
+
697
+ function retryJob(table, { jobId, error, retryAt, terminal = false, updatedAt }) {
698
+ getDb().prepare(`
699
+ UPDATE ${table} SET status = ?, available_at = ?, claimed_at = NULL,
700
+ last_error = ?, updated_at = ? WHERE id = ?
701
+ `).run(terminal ? "failed" : "pending", retryAt, error, updatedAt, jobId);
702
+ }
703
+
704
+ export function retryAnnotationJob(options) {
705
+ return retryJob("annotation_jobs", options);
706
+ }
707
+
708
+ export function retryVectorIndexJob(options) {
709
+ return retryJob("vector_index_jobs", options);
710
+ }
711
+
712
+ export function retryIngestionJob(options) {
713
+ return retryJob("ingestion_jobs", options);
714
+ }
715
+
716
+ function completeJob(table, jobId, completedAt) {
717
+ getDb().prepare(`
718
+ UPDATE ${table} SET status = 'completed', completed_at = ?, claimed_at = NULL,
719
+ last_error = NULL, updated_at = ? WHERE id = ?
720
+ `).run(completedAt, completedAt, jobId);
721
+ }
722
+
723
+ export function completeAnnotationJob({ jobId, completedAt }) {
724
+ return completeJob("annotation_jobs", jobId, completedAt);
725
+ }
726
+
727
+ export function completeVectorIndexJob({ jobId, completedAt }) {
728
+ return completeJob("vector_index_jobs", jobId, completedAt);
729
+ }
730
+
731
+ export function completeIngestionJob({ jobId, completedAt }) {
732
+ return completeJob("ingestion_jobs", jobId, completedAt);
733
+ }
734
+
735
+ function failJob(table, jobId, { error, failedAt }) {
736
+ getDb().prepare(`
737
+ UPDATE ${table} SET status = 'failed', last_error = ?, claimed_at = NULL,
738
+ updated_at = ? WHERE id = ?
739
+ `).run(error, failedAt, jobId);
740
+ }
741
+
742
+ export function failAnnotationJob(jobId, options) {
743
+ return failJob("annotation_jobs", jobId, options);
744
+ }
745
+
746
+ export function failVectorIndexJob(jobId, options) {
747
+ return failJob("vector_index_jobs", jobId, options);
748
+ }
749
+
750
+ export function failIngestionJob(jobId, options) {
751
+ return failJob("ingestion_jobs", jobId, options);
752
+ }
753
+
754
+ export function saveCognitiveAnnotation({ memoryId, annotation, model, schemaVersion }) {
755
+ const database = getDb();
756
+ database.prepare(`
757
+ INSERT INTO cognitive_annotations (
758
+ memory_id, memory_version, annotation_json, model, schema_version, created_at
759
+ ) VALUES (?, ?, ?, ?, ?, ?)
760
+ ON CONFLICT(memory_id, memory_version, schema_version) DO UPDATE SET
761
+ annotation_json = excluded.annotation_json, model = excluded.model,
762
+ created_at = excluded.created_at
763
+ `).run(memoryId, memoryVersion(memoryId), JSON.stringify(annotation), model,
764
+ schemaVersion, new Date().toISOString());
765
+ }
766
+
767
+ export function completeCognitiveAnnotation({
768
+ memoryId,
769
+ annotation,
770
+ activations,
771
+ mappingVersion,
772
+ model,
773
+ schemaVersion,
774
+ jobId,
775
+ completedAt,
776
+ }) {
777
+ return getDb().transaction(() => {
778
+ saveCognitiveAnnotation({ memoryId, annotation, model, schemaVersion });
779
+ saveRegionActivations(memoryId, activations, mappingVersion);
780
+ completeAnnotationJob({ jobId, completedAt });
781
+ })();
782
+ }
783
+
784
+ function parseJson(value, fallback) {
785
+ try {
786
+ return JSON.parse(value);
787
+ } catch {
788
+ return fallback;
789
+ }
790
+ }
791
+
792
+ function backfillLegacySources() {
793
+ const database = getDb();
794
+ const rows = database.prepare(`
795
+ SELECT m.id, m.raw_text, m.source, m.ingestion_date, m.created_at,
796
+ e.model, e.schema_version
797
+ FROM memories m
798
+ LEFT JOIN memory_extractions e ON e.id = (
799
+ SELECT latest.id FROM memory_extractions latest
800
+ WHERE latest.memory_id = m.id ORDER BY latest.id DESC LIMIT 1
801
+ )
802
+ WHERE NOT EXISTS (
803
+ SELECT 1 FROM source_memory_links link WHERE link.memory_id = m.id
804
+ )
805
+ `).all();
806
+ const migrate = database.transaction(() => {
807
+ for (const row of rows) {
808
+ const sourceId = `legacy:${row.id}`;
809
+ if (hasColumn("memory_sources", "content")) {
810
+ database.prepare(`
811
+ INSERT OR IGNORE INTO memory_sources (
812
+ id, text, content, source, ingestion_date, extraction_status,
813
+ extraction_attempts, extraction_model, extraction_schema_version,
814
+ created_at, updated_at
815
+ ) VALUES (?, ?, ?, 'import', ?, 'completed', 0, ?, ?, ?, ?)
816
+ `).run(sourceId, row.raw_text, row.raw_text, row.ingestion_date,
817
+ row.model || "legacy", row.schema_version || 1, row.created_at, row.created_at);
818
+ } else {
819
+ database.prepare(`
820
+ INSERT OR IGNORE INTO memory_sources (
821
+ id, text, source, ingestion_date, extraction_status,
822
+ extraction_attempts, extraction_model, extraction_schema_version,
823
+ created_at, updated_at
824
+ ) VALUES (?, ?, 'import', ?, 'completed', 0, ?, ?, ?, ?)
825
+ `).run(sourceId, row.raw_text, row.ingestion_date, row.model || "legacy",
826
+ row.schema_version || 1, row.created_at, row.created_at);
827
+ }
828
+ database.prepare(`
829
+ INSERT INTO source_memory_links (
830
+ source_id, memory_id, action, evidence_json, extraction_model,
831
+ extraction_schema_version, decision_confidence, decision_reason, created_at
832
+ ) VALUES (?, ?, 'created', ?, ?, ?, 1, 'Synthetic legacy provenance', ?)
833
+ `).run(sourceId, row.id, JSON.stringify([{
834
+ start: 0,
835
+ end: row.raw_text.length,
836
+ text: row.raw_text,
837
+ }]), row.model || "legacy", row.schema_version || 1, row.created_at);
838
+ }
839
+ });
840
+ migrate();
841
+ return rows.length;
842
+ }
843
+
844
+ // --- Memory CRUD ---
845
+
846
+ export function createMemory(
847
+ id,
848
+ rawText,
849
+ ingestionDate,
850
+ summary = null,
851
+ source = "ui",
852
+ metadata = {},
853
+ ) {
854
+ const db = getDb();
855
+ const now = new Date().toISOString();
856
+ const title = metadata.title || String(rawText).slice(0, 50);
857
+ const tags = Array.isArray(metadata.tags) ? metadata.tags : [];
858
+ const stmt = db.prepare(`
859
+ INSERT INTO memories (
860
+ id,
861
+ raw_text,
862
+ ingestion_date,
863
+ summary,
864
+ type,
865
+ title,
866
+ confidence,
867
+ tags,
868
+ scope,
869
+ source,
870
+ created_at,
871
+ updated_at
872
+ )
873
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'agent', ?, ?, ?)
874
+ `);
875
+ stmt.run(
876
+ id,
877
+ rawText,
878
+ ingestionDate,
879
+ summary,
880
+ metadata.type || "fact",
881
+ title,
882
+ metadata.confidence ?? 0.6,
883
+ JSON.stringify(tags),
884
+ source,
885
+ now,
886
+ now,
887
+ );
888
+ syncFtsForMemory(id);
889
+ return getMemory(id);
890
+ }
891
+
892
+ export function getMemory(id) {
893
+ const db = getDb();
894
+ return deserializeMemory(
895
+ db.prepare("SELECT * FROM memories WHERE id = ?").get(id),
896
+ );
897
+ }
898
+
899
+ export function getMemoriesByIds(ids) {
900
+ if (!ids.length) return [];
901
+ const db = getDb();
902
+ const placeholders = ids.map(() => "?").join(",");
903
+ return db
904
+ .prepare(`SELECT * FROM memories WHERE id IN (${placeholders})`)
905
+ .all(...ids)
906
+ .map(deserializeMemory);
907
+ }
908
+
909
+ export function getMemories({ limit = 100, offset = 0, source } = {}) {
910
+ const db = getDb();
911
+ if (source) {
912
+ return db
913
+ .prepare("SELECT * FROM memories WHERE source = ? ORDER BY created_at DESC LIMIT ? OFFSET ?")
914
+ .all(source, limit, offset)
915
+ .map(deserializeMemory);
916
+ }
917
+ return db
918
+ .prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ? OFFSET ?")
919
+ .all(limit, offset)
920
+ .map(deserializeMemory);
921
+ }
922
+
923
+ const MEMORY_CATALOG_SORTS = Object.freeze({
924
+ title: "m.title",
925
+ type: "m.type",
926
+ source: "m.source",
927
+ confidence: "m.confidence",
928
+ created_at: "m.created_at",
929
+ linked: "linked_count",
930
+ });
931
+
932
+ export function getMemoryCatalog({
933
+ q = "",
934
+ limit = 25,
935
+ offset = 0,
936
+ sort = "created_at",
937
+ order = "desc",
938
+ source,
939
+ type,
940
+ } = {}) {
941
+ const db = getDb();
942
+ const conditions = [];
943
+ const params = {};
944
+ const normalizedQuery = String(q).trim();
945
+
946
+ if (normalizedQuery) {
947
+ conditions.push(`
948
+ (
949
+ m.title LIKE @query
950
+ OR m.summary LIKE @query
951
+ OR m.raw_text LIKE @query
952
+ OR EXISTS (
953
+ SELECT 1
954
+ FROM memory_entities search_me
955
+ JOIN entities search_e ON search_e.id = search_me.entity_id
956
+ WHERE search_me.memory_id = m.id
957
+ AND (
958
+ search_e.canonical_name LIKE @query
959
+ OR EXISTS (
960
+ SELECT 1
961
+ FROM entity_aliases search_ea
962
+ WHERE search_ea.entity_id = search_e.id
963
+ AND search_ea.alias LIKE @query
964
+ )
965
+ )
966
+ )
967
+ )
968
+ `);
969
+ params.query = `%${normalizedQuery}%`;
970
+ }
971
+ if (source) {
972
+ conditions.push("m.source = @source");
973
+ params.source = source;
974
+ }
975
+ if (type) {
976
+ conditions.push("m.type = @type");
977
+ params.type = type;
978
+ }
979
+
980
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
981
+ const sortColumn = MEMORY_CATALOG_SORTS[sort] || MEMORY_CATALOG_SORTS.created_at;
982
+ const sortOrder = order === "asc" ? "ASC" : "DESC";
983
+ const count = db
984
+ .prepare(`SELECT COUNT(*) AS total FROM memories m ${where}`)
985
+ .get(params).total;
986
+ const rows = db
987
+ .prepare(`
988
+ SELECT
989
+ m.*,
990
+ COALESCE(
991
+ json_group_array(
992
+ CASE
993
+ WHEN e.id IS NULL THEN NULL
994
+ ELSE json_object(
995
+ 'id', e.id,
996
+ 'canonical_name', e.canonical_name,
997
+ 'kind', e.kind,
998
+ 'mention', me.mention,
999
+ 'role', me.role,
1000
+ 'confidence', me.confidence
1001
+ )
1002
+ END
1003
+ ) FILTER (WHERE e.id IS NOT NULL),
1004
+ json('[]')
1005
+ ) AS entities_json,
1006
+ (
1007
+ SELECT COUNT(DISTINCT cand.memory_id)
1008
+ FROM memory_entities cur
1009
+ JOIN memory_entities cand ON cand.entity_id = cur.entity_id AND cand.memory_id <> cur.memory_id
1010
+ WHERE cur.memory_id = m.id
1011
+ ) AS linked_count
1012
+ FROM memories m
1013
+ LEFT JOIN memory_entities me ON me.memory_id = m.id
1014
+ LEFT JOIN entities e ON e.id = me.entity_id
1015
+ ${where}
1016
+ GROUP BY m.id
1017
+ ORDER BY ${sortColumn} ${sortOrder}, m.id ASC
1018
+ LIMIT @limit OFFSET @offset
1019
+ `)
1020
+ .all({ ...params, limit, offset })
1021
+ .map(({ entities_json: entitiesJson, linked_count: linkedCount, ...memory }) => ({
1022
+ ...deserializeMemory(memory),
1023
+ entities: JSON.parse(entitiesJson),
1024
+ linked_count: linkedCount || 0,
1025
+ }));
1026
+
1027
+ return { items: rows, total: count, limit, offset };
1028
+ }
1029
+
1030
+ export function searchMemories(query, { limit = 20 } = {}) {
1031
+ const db = getDb();
1032
+ const pattern = `%${query}%`;
1033
+ return db
1034
+ .prepare(
1035
+ `SELECT *
1036
+ FROM memories
1037
+ WHERE raw_text LIKE ? OR summary LIKE ?
1038
+ ORDER BY created_at DESC
1039
+ LIMIT ?`
1040
+ )
1041
+ .all(pattern, pattern, limit)
1042
+ .map(deserializeMemory);
1043
+ }
1044
+
1045
+ // --- FTS5 / BM25 ---
1046
+
1047
+ export function syncMemoriesToFts() {
1048
+ const database = getDb();
1049
+ database.exec("DELETE FROM memories_fts");
1050
+ database.prepare(`
1051
+ INSERT INTO memories_fts (memory_id, title, summary, raw_text, tags)
1052
+ SELECT id, title, summary, raw_text, tags FROM memories
1053
+ `).run();
1054
+ database.exec("INSERT INTO memories_fts(memories_fts) VALUES('optimize')");
1055
+ }
1056
+
1057
+ export function indexMemoryFts(id) {
1058
+ const database = getDb();
1059
+ const memory = database.prepare("SELECT * FROM memories WHERE id = ?").get(id);
1060
+ if (!memory) return;
1061
+ database.prepare("DELETE FROM memories_fts WHERE memory_id = ?").run(id);
1062
+ database.exec("INSERT INTO memories_fts(memories_fts) VALUES('optimize')");
1063
+ database.prepare(`
1064
+ INSERT INTO memories_fts (memory_id, title, summary, raw_text, tags)
1065
+ VALUES (?, ?, ?, ?, ?)
1066
+ `).run(id, memory.title || "", memory.summary || "", memory.raw_text || "", memory.tags || "[]");
1067
+ }
1068
+
1069
+ export function removeMemoryFts(id) {
1070
+ const database = getDb();
1071
+ database.prepare("DELETE FROM memories_fts WHERE memory_id = ?").run(id);
1072
+ database.exec("INSERT INTO memories_fts(memories_fts) VALUES('optimize')");
1073
+ }
1074
+
1075
+ export function searchMemoriesFts(query, { limit = 20 } = {}) {
1076
+ const database = getDb();
1077
+ const ftsQuery = buildFtsQuery(query);
1078
+ if (!ftsQuery) return [];
1079
+ try {
1080
+ const rows = database.prepare(`
1081
+ SELECT memory_id, rank
1082
+ FROM memories_fts
1083
+ WHERE memories_fts MATCH ?
1084
+ ORDER BY rank
1085
+ LIMIT ?
1086
+ `).all(ftsQuery, limit);
1087
+ return rows.map((row) => ({ id: row.memory_id, score: row.rank }));
1088
+ } catch {
1089
+ return [];
1090
+ }
1091
+ }
1092
+
1093
+ function buildFtsQuery(query) {
1094
+ const terms = String(query).trim().split(/\s+/).filter(Boolean);
1095
+ if (!terms.length) return "";
1096
+ return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
1097
+ }
1098
+
1099
+ function syncFtsForMemory(id) {
1100
+ try {
1101
+ indexMemoryFts(id);
1102
+ } catch {
1103
+ // FTS sync is best-effort; don't break the main operation
1104
+ }
1105
+ }
1106
+
1107
+ function removeFtsForMemory(id) {
1108
+ try {
1109
+ removeMemoryFts(id);
1110
+ } catch {
1111
+ // FTS sync is best-effort
1112
+ }
1113
+ }
1114
+
1115
+ export function updateMemorySummary(id, summary) {
1116
+ const db = getDb();
1117
+ const updatedAt = new Date().toISOString();
1118
+ db.prepare(`
1119
+ UPDATE memories SET summary = ?, updated_at = ? WHERE id = ?
1120
+ `).run(summary, updatedAt, id);
1121
+ syncFtsForMemory(id);
1122
+ }
1123
+
1124
+ // --- Memory Comparisons ---
1125
+
1126
+ export function getMemoryComparison({
1127
+ leftMemoryId,
1128
+ rightMemoryId,
1129
+ inputHash,
1130
+ model,
1131
+ schemaVersion,
1132
+ }) {
1133
+ const row = getDb()
1134
+ .prepare(`
1135
+ SELECT *
1136
+ FROM memory_comparisons
1137
+ WHERE left_memory_id = ?
1138
+ AND right_memory_id = ?
1139
+ AND input_hash = ?
1140
+ AND model = ?
1141
+ AND schema_version = ?
1142
+ `)
1143
+ .get(
1144
+ leftMemoryId,
1145
+ rightMemoryId,
1146
+ inputHash,
1147
+ model,
1148
+ schemaVersion,
1149
+ );
1150
+ if (!row) return null;
1151
+ return {
1152
+ ...row,
1153
+ comparison_json: JSON.parse(row.comparison_json),
1154
+ };
1155
+ }
1156
+
1157
+ export function saveMemoryComparison({
1158
+ leftMemoryId,
1159
+ rightMemoryId,
1160
+ inputHash,
1161
+ model,
1162
+ schemaVersion,
1163
+ comparison,
1164
+ generatedAt = new Date().toISOString(),
1165
+ }) {
1166
+ const db = getDb();
1167
+ const save = db.transaction(() => {
1168
+ db.prepare(`
1169
+ DELETE FROM memory_comparisons
1170
+ WHERE left_memory_id = ?
1171
+ AND right_memory_id = ?
1172
+ AND model = ?
1173
+ AND schema_version = ?
1174
+ `).run(leftMemoryId, rightMemoryId, model, schemaVersion);
1175
+ db.prepare(`
1176
+ INSERT INTO memory_comparisons (
1177
+ left_memory_id,
1178
+ right_memory_id,
1179
+ input_hash,
1180
+ model,
1181
+ schema_version,
1182
+ comparison_json,
1183
+ generated_at
1184
+ )
1185
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1186
+ `).run(
1187
+ leftMemoryId,
1188
+ rightMemoryId,
1189
+ inputHash,
1190
+ model,
1191
+ schemaVersion,
1192
+ JSON.stringify(comparison),
1193
+ generatedAt,
1194
+ );
1195
+ });
1196
+ save();
1197
+ return getMemoryComparison({
1198
+ leftMemoryId,
1199
+ rightMemoryId,
1200
+ inputHash,
1201
+ model,
1202
+ schemaVersion,
1203
+ });
1204
+ }
1205
+
1206
+ // --- Extraction CRUD ---
1207
+
1208
+ export function saveExtraction(
1209
+ memoryId,
1210
+ extractionJson,
1211
+ model,
1212
+ schemaVersion = EXTRACTION_SCHEMA_VERSION,
1213
+ ) {
1214
+ const db = getDb();
1215
+ const stmt = db.prepare(`
1216
+ INSERT INTO memory_extractions (
1217
+ memory_id,
1218
+ extraction_json,
1219
+ model,
1220
+ schema_version
1221
+ )
1222
+ VALUES (?, ?, ?, ?)
1223
+ `);
1224
+ const result = stmt.run(
1225
+ memoryId,
1226
+ JSON.stringify(extractionJson),
1227
+ model,
1228
+ schemaVersion,
1229
+ );
1230
+ return result.lastInsertRowid;
1231
+ }
1232
+
1233
+ export function getExtractions(memoryId) {
1234
+ const db = getDb();
1235
+ const rows = db
1236
+ .prepare(
1237
+ `SELECT *
1238
+ FROM memory_extractions
1239
+ WHERE memory_id = ?
1240
+ ORDER BY created_at DESC, id DESC`
1241
+ )
1242
+ .all(memoryId);
1243
+ return rows.map((r) => ({ ...r, extraction_json: JSON.parse(r.extraction_json) }));
1244
+ }
1245
+
1246
+ export function getLatestExtraction(memoryId) {
1247
+ const db = getDb();
1248
+ const row = db
1249
+ .prepare(
1250
+ `SELECT *
1251
+ FROM memory_extractions
1252
+ WHERE memory_id = ?
1253
+ ORDER BY created_at DESC, id DESC
1254
+ LIMIT 1`
1255
+ )
1256
+ .get(memoryId);
1257
+ if (!row) return null;
1258
+ return { ...row, extraction_json: JSON.parse(row.extraction_json) };
1259
+ }
1260
+
1261
+ export function setExtractionAuthoritative(id) {
1262
+ const db = getDb();
1263
+ db.prepare("UPDATE memory_extractions SET authoritative = 1 WHERE id = ?").run(id);
1264
+ }
1265
+
1266
+ // --- Entity CRUD ---
1267
+
1268
+ export function upsertEntity(canonicalName, kind) {
1269
+ const db = getDb();
1270
+ const normalizedName = normalizeEntityName(canonicalName);
1271
+ const matches = findExactEntityMatches(normalizedName, kind);
1272
+ if (matches.length === 1) {
1273
+ addEntityAlias(matches[0].id, normalizedName, true);
1274
+ return matches[0].id;
1275
+ }
1276
+
1277
+ const result = db
1278
+ .prepare("INSERT INTO entities (canonical_name, kind) VALUES (?, ?)")
1279
+ .run(normalizedName, kind);
1280
+ addEntityAlias(result.lastInsertRowid, normalizedName, true);
1281
+ return result.lastInsertRowid;
1282
+ }
1283
+
1284
+ export function getEntity(id) {
1285
+ const db = getDb();
1286
+ return db.prepare("SELECT * FROM entities WHERE id = ?").get(id);
1287
+ }
1288
+
1289
+ export function findEntities(query) {
1290
+ const db = getDb();
1291
+ return db
1292
+ .prepare(
1293
+ `SELECT e.*
1294
+ FROM entities e
1295
+ WHERE e.canonical_name LIKE ?
1296
+ OR EXISTS (
1297
+ SELECT 1
1298
+ FROM entity_aliases ea
1299
+ WHERE ea.entity_id = e.id AND ea.alias LIKE ?
1300
+ )
1301
+ ORDER BY e.canonical_name`
1302
+ )
1303
+ .all(`%${query}%`, `%${query}%`);
1304
+ }
1305
+
1306
+ export function getEntityAliases(entityId) {
1307
+ return getDb()
1308
+ .prepare(`
1309
+ SELECT id, entity_id, alias, normalized_alias, canonical, created_at
1310
+ FROM entity_aliases
1311
+ WHERE entity_id = ?
1312
+ ORDER BY canonical DESC, alias COLLATE NOCASE ASC, id ASC
1313
+ `)
1314
+ .all(entityId);
1315
+ }
1316
+
1317
+ export function getEntityResolutionSuggestions({ status } = {}) {
1318
+ const db = getDb();
1319
+ const where = status ? "WHERE s.status = ?" : "";
1320
+ const params = status ? [status] : [];
1321
+ return db
1322
+ .prepare(`
1323
+ SELECT
1324
+ s.*,
1325
+ s.observed_alias AS alias,
1326
+ s.kind AS source_kind,
1327
+ s.kind AS target_kind,
1328
+ COALESCE(source.canonical_name, s.source_name) AS source_name,
1329
+ COALESCE(target.canonical_name, s.target_name) AS target_name
1330
+ FROM entity_resolution_suggestions s
1331
+ LEFT JOIN entities source ON source.id = s.source_entity_id
1332
+ LEFT JOIN entities target ON target.id = s.target_entity_id
1333
+ ${where}
1334
+ ORDER BY s.created_at DESC, s.id DESC
1335
+ `)
1336
+ .all(...params);
1337
+ }
1338
+
1339
+ export function resolveEntityResolutionSuggestion(id, decision) {
1340
+ if (decision !== "merge" && decision !== "reject") {
1341
+ throw new Error(`Invalid entity resolution decision: ${decision}`);
1342
+ }
1343
+
1344
+ const db = getDb();
1345
+ const resolve = db.transaction(() => {
1346
+ const suggestion = db
1347
+ .prepare("SELECT * FROM entity_resolution_suggestions WHERE id = ?")
1348
+ .get(id);
1349
+ if (!suggestion) return null;
1350
+ const desiredStatus = decision === "merge" ? "merged" : "rejected";
1351
+ if (suggestion.status !== "pending") {
1352
+ if (suggestion.status === desiredStatus) {
1353
+ return getEntityResolutionSuggestions().find((item) => item.id === id);
1354
+ }
1355
+ throw new Error(
1356
+ `Entity resolution suggestion ${id} is already ${suggestion.status}`,
1357
+ );
1358
+ }
1359
+
1360
+ const resolvedAt = new Date().toISOString();
1361
+ if (decision === "reject") {
1362
+ db.prepare(`
1363
+ UPDATE entity_resolution_suggestions
1364
+ SET status = 'rejected', resolved_at = ?
1365
+ WHERE id = ?
1366
+ `).run(resolvedAt, id);
1367
+ return getEntityResolutionSuggestions().find((item) => item.id === id);
1368
+ }
1369
+
1370
+ mergeEntityIntoTarget(
1371
+ suggestion.source_entity_id,
1372
+ suggestion.target_entity_id,
1373
+ id,
1374
+ resolvedAt,
1375
+ );
1376
+ return getEntityResolutionSuggestions().find((item) => item.id === id);
1377
+ });
1378
+
1379
+ return resolve();
1380
+ }
1381
+
1382
+ const ENTITY_CATALOG_SORTS = Object.freeze({
1383
+ canonical_name: "e.canonical_name",
1384
+ kind: "e.kind",
1385
+ memory_count: "memory_count",
1386
+ relationship_count: "relationship_count",
1387
+ created_at: "e.created_at",
1388
+ });
1389
+
1390
+ export function getEntityCatalog({
1391
+ q = "",
1392
+ limit = 25,
1393
+ offset = 0,
1394
+ sort = "canonical_name",
1395
+ order = "asc",
1396
+ kind,
1397
+ } = {}) {
1398
+ const db = getDb();
1399
+ const conditions = [];
1400
+ const params = {};
1401
+ const normalizedQuery = String(q).trim();
1402
+
1403
+ if (normalizedQuery) {
1404
+ conditions.push(`
1405
+ (
1406
+ e.canonical_name LIKE @query
1407
+ OR EXISTS (
1408
+ SELECT 1
1409
+ FROM entity_aliases search_ea
1410
+ WHERE search_ea.entity_id = e.id
1411
+ AND search_ea.alias LIKE @query
1412
+ )
1413
+ )
1414
+ `);
1415
+ params.query = `%${normalizedQuery}%`;
1416
+ }
1417
+ if (kind) {
1418
+ conditions.push("e.kind = @kind");
1419
+ params.kind = kind;
1420
+ }
1421
+
1422
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
1423
+ const sortColumn = ENTITY_CATALOG_SORTS[sort]
1424
+ || ENTITY_CATALOG_SORTS.canonical_name;
1425
+ const sortOrder = order === "desc" ? "DESC" : "ASC";
1426
+ const total = db
1427
+ .prepare(`SELECT COUNT(*) AS total FROM entities e ${where}`)
1428
+ .get(params).total;
1429
+ const items = db
1430
+ .prepare(`
1431
+ SELECT
1432
+ e.*,
1433
+ COUNT(DISTINCT me.memory_id) AS memory_count,
1434
+ COUNT(DISTINCT r.id) AS relationship_count,
1435
+ (
1436
+ SELECT COUNT(*)
1437
+ FROM entity_aliases ea
1438
+ WHERE ea.entity_id = e.id
1439
+ ) AS alias_count,
1440
+ (
1441
+ SELECT COUNT(*)
1442
+ FROM entity_resolution_suggestions ers
1443
+ WHERE ers.status = 'pending'
1444
+ AND (
1445
+ ers.source_entity_id = e.id
1446
+ OR ers.target_entity_id = e.id
1447
+ )
1448
+ ) AS pending_suggestion_count
1449
+ FROM entities e
1450
+ LEFT JOIN memory_entities me ON me.entity_id = e.id
1451
+ LEFT JOIN relationships r
1452
+ ON r.source_entity_id = e.id OR r.target_entity_id = e.id
1453
+ ${where}
1454
+ GROUP BY e.id
1455
+ ORDER BY ${sortColumn} ${sortOrder}, e.id ASC
1456
+ LIMIT @limit OFFSET @offset
1457
+ `)
1458
+ .all({ ...params, limit, offset });
1459
+
1460
+ return { items, total, limit, offset };
1461
+ }
1462
+
1463
+ // --- Memory-Entity Links ---
1464
+
1465
+ export function linkMemoryToEntity(memoryId, entityId, mention, role = null, confidence = 1.0) {
1466
+ const db = getDb();
1467
+ const stmt = db.prepare(`
1468
+ INSERT OR IGNORE INTO memory_entities (memory_id, entity_id, mention, role, confidence)
1469
+ VALUES (?, ?, ?, ?, ?)
1470
+ `);
1471
+ stmt.run(memoryId, entityId, mention, role, confidence);
1472
+ }
1473
+
1474
+ export function getEntitiesForMemory(memoryId) {
1475
+ const db = getDb();
1476
+ return db
1477
+ .prepare(
1478
+ `SELECT e.*, me.mention, me.role, me.confidence
1479
+ FROM memory_entities me
1480
+ JOIN entities e ON e.id = me.entity_id
1481
+ WHERE me.memory_id = ?`
1482
+ )
1483
+ .all(memoryId);
1484
+ }
1485
+
1486
+ export function getMemoriesForEntity(entityId) {
1487
+ const db = getDb();
1488
+ return db
1489
+ .prepare(
1490
+ `SELECT m.*, me.mention, me.role, me.confidence AS entity_confidence
1491
+ FROM memory_entities me
1492
+ JOIN memories m ON m.id = me.memory_id
1493
+ WHERE me.entity_id = ?`
1494
+ )
1495
+ .all(entityId)
1496
+ .map(deserializeMemory);
1497
+ }
1498
+
1499
+ // --- Relationships ---
1500
+
1501
+ export function addRelationship(sourceEntityId, targetEntityId, predicate, memoryId, confidence = 1.0, evidence = null) {
1502
+ const db = getDb();
1503
+ const stmt = db.prepare(`
1504
+ INSERT OR IGNORE INTO relationships (source_entity_id, target_entity_id, predicate, memory_id, confidence, evidence)
1505
+ VALUES (?, ?, ?, ?, ?, ?)
1506
+ `);
1507
+ stmt.run(sourceEntityId, targetEntityId, predicate, memoryId, confidence, evidence);
1508
+ }
1509
+
1510
+ export function getRelationshipsForMemory(memoryId) {
1511
+ const db = getDb();
1512
+ return db
1513
+ .prepare(
1514
+ `SELECT r.*,
1515
+ se.canonical_name AS source_name, se.kind AS source_kind,
1516
+ te.canonical_name AS target_name, te.kind AS target_kind
1517
+ FROM relationships r
1518
+ JOIN entities se ON se.id = r.source_entity_id
1519
+ JOIN entities te ON te.id = r.target_entity_id
1520
+ WHERE r.memory_id = ?`
1521
+ )
1522
+ .all(memoryId);
1523
+ }
1524
+
1525
+ export function getRelationshipsForEntity(entityId) {
1526
+ const db = getDb();
1527
+ return db
1528
+ .prepare(
1529
+ `SELECT r.*,
1530
+ se.canonical_name AS source_name, se.kind AS source_kind,
1531
+ te.canonical_name AS target_name, te.kind AS target_kind
1532
+ FROM relationships r
1533
+ JOIN entities se ON se.id = r.source_entity_id
1534
+ JOIN entities te ON te.id = r.target_entity_id
1535
+ WHERE r.source_entity_id = ? OR r.target_entity_id = ?
1536
+ ORDER BY r.created_at DESC, r.id DESC`
1537
+ )
1538
+ .all(entityId, entityId);
1539
+ }
1540
+
1541
+ export function getStructuralMemoryLinks(memoryId) {
1542
+ const db = getDb();
1543
+ const sharedEntityRows = db
1544
+ .prepare(`
1545
+ SELECT DISTINCT
1546
+ candidate.memory_id,
1547
+ e.id,
1548
+ e.canonical_name,
1549
+ e.kind
1550
+ FROM memory_entities current
1551
+ JOIN memory_entities candidate
1552
+ ON candidate.entity_id = current.entity_id
1553
+ AND candidate.memory_id <> current.memory_id
1554
+ JOIN entities e ON e.id = current.entity_id
1555
+ WHERE current.memory_id = ?
1556
+ ORDER BY candidate.memory_id ASC, e.canonical_name ASC, e.id ASC
1557
+ `)
1558
+ .all(memoryId);
1559
+ const sharedRelationshipRows = db
1560
+ .prepare(`
1561
+ SELECT DISTINCT
1562
+ candidate.memory_id,
1563
+ current.source_entity_id,
1564
+ source.canonical_name AS source_name,
1565
+ source.kind AS source_kind,
1566
+ current.predicate,
1567
+ current.target_entity_id,
1568
+ target.canonical_name AS target_name,
1569
+ target.kind AS target_kind
1570
+ FROM relationships current
1571
+ JOIN relationships candidate
1572
+ ON candidate.source_entity_id = current.source_entity_id
1573
+ AND candidate.target_entity_id = current.target_entity_id
1574
+ AND candidate.predicate = current.predicate
1575
+ AND candidate.memory_id <> current.memory_id
1576
+ JOIN entities source ON source.id = current.source_entity_id
1577
+ JOIN entities target ON target.id = current.target_entity_id
1578
+ WHERE current.memory_id = ?
1579
+ ORDER BY candidate.memory_id ASC, current.predicate ASC
1580
+ `)
1581
+ .all(memoryId);
1582
+ const candidates = new Map();
1583
+ const ensureCandidate = (candidateMemoryId) => {
1584
+ if (!candidates.has(candidateMemoryId)) {
1585
+ candidates.set(candidateMemoryId, {
1586
+ memory_id: candidateMemoryId,
1587
+ shared_entities: [],
1588
+ shared_relationships: [],
1589
+ });
1590
+ }
1591
+ return candidates.get(candidateMemoryId);
1592
+ };
1593
+
1594
+ for (const { memory_id: candidateMemoryId, ...entity } of sharedEntityRows) {
1595
+ ensureCandidate(candidateMemoryId).shared_entities.push(entity);
1596
+ }
1597
+ for (const {
1598
+ memory_id: candidateMemoryId,
1599
+ ...relationship
1600
+ } of sharedRelationshipRows) {
1601
+ ensureCandidate(candidateMemoryId).shared_relationships.push(relationship);
1602
+ }
1603
+
1604
+ return [...candidates.values()];
1605
+ }
1606
+
1607
+ // --- Region Activations ---
1608
+
1609
+ export function saveRegionActivations(
1610
+ memoryId,
1611
+ activations,
1612
+ mappingVersion = REGION_MAPPING_VERSION
1613
+ ) {
1614
+ const db = getDb();
1615
+ const remove = db.prepare(
1616
+ "DELETE FROM region_activations WHERE memory_id = ? AND mapping_version = ?"
1617
+ );
1618
+ const stmt = db.prepare(`
1619
+ INSERT INTO region_activations (
1620
+ memory_id,
1621
+ region,
1622
+ weight,
1623
+ left_weight,
1624
+ right_weight,
1625
+ mapping_version
1626
+ )
1627
+ VALUES (?, ?, ?, ?, ?, ?)
1628
+ `);
1629
+ const insert = db.transaction((items) => {
1630
+ remove.run(memoryId, mappingVersion);
1631
+ for (const { region, weight, hemispheres } of items) {
1632
+ stmt.run(
1633
+ memoryId,
1634
+ region,
1635
+ weight,
1636
+ hemispheres?.left ?? null,
1637
+ hemispheres?.right ?? null,
1638
+ mappingVersion,
1639
+ );
1640
+ }
1641
+ });
1642
+ insert(activations);
1643
+ }
1644
+
1645
+ export function getRegionActivations(
1646
+ memoryId,
1647
+ mappingVersion = REGION_MAPPING_VERSION
1648
+ ) {
1649
+ const db = getDb();
1650
+ return db
1651
+ .prepare(
1652
+ `SELECT region, weight, left_weight, right_weight, mapping_version
1653
+ FROM region_activations
1654
+ WHERE memory_id = ? AND mapping_version = ?
1655
+ ORDER BY weight DESC, region ASC`
1656
+ )
1657
+ .all(memoryId, mappingVersion)
1658
+ .map(({ left_weight: left, right_weight: right, ...activation }) => ({
1659
+ ...activation,
1660
+ ...(Number.isFinite(left) && Number.isFinite(right)
1661
+ ? { hemispheres: { left, right } }
1662
+ : {}),
1663
+ }));
1664
+ }
1665
+
1666
+ export function backfillRegionActivations() {
1667
+ const db = getDb();
1668
+ const rows = db
1669
+ .prepare(
1670
+ `SELECT m.id, e.extraction_json
1671
+ FROM memories m
1672
+ JOIN memory_extractions e ON e.id = (
1673
+ SELECT latest.id
1674
+ FROM memory_extractions latest
1675
+ WHERE latest.memory_id = m.id
1676
+ ORDER BY latest.created_at DESC, latest.id DESC
1677
+ LIMIT 1
1678
+ )
1679
+ WHERE NOT EXISTS (
1680
+ SELECT 1
1681
+ FROM region_activations ra
1682
+ WHERE ra.memory_id = m.id AND ra.mapping_version = ?
1683
+ )`
1684
+ )
1685
+ .all(REGION_MAPPING_VERSION);
1686
+
1687
+ const backfill = db.transaction(() => {
1688
+ for (const row of rows) {
1689
+ const extraction = JSON.parse(row.extraction_json);
1690
+ saveRegionActivations(
1691
+ row.id,
1692
+ mapExtractionToRegions(extraction),
1693
+ REGION_MAPPING_VERSION
1694
+ );
1695
+ }
1696
+ });
1697
+ backfill();
1698
+
1699
+ return rows.length;
1700
+ }
1701
+
1702
+ // --- Full pipeline: extract + store ---
1703
+
1704
+ export function storeMemory(
1705
+ id,
1706
+ rawText,
1707
+ ingestionDate,
1708
+ extraction,
1709
+ model,
1710
+ source = "ui",
1711
+ metadata = {},
1712
+ ) {
1713
+ const db = getDb();
1714
+ const storeAll = db.transaction(() => {
1715
+ createMemory(
1716
+ id,
1717
+ rawText,
1718
+ ingestionDate,
1719
+ extraction.summary,
1720
+ source,
1721
+ metadata,
1722
+ );
1723
+ saveExtraction(id, extraction, model);
1724
+ persistDerivedMemoryGraph(id, extraction);
1725
+
1726
+ return getMemory(id);
1727
+ });
1728
+
1729
+ return storeAll();
1730
+ }
1731
+
1732
+ export function updateMemoryGraph({
1733
+ memoryId,
1734
+ rawText,
1735
+ ingestionDate,
1736
+ extraction,
1737
+ model,
1738
+ metadata = {},
1739
+ schemaVersion = EXTRACTION_SCHEMA_VERSION,
1740
+ }) {
1741
+ const db = getDb();
1742
+ const updateAll = db.transaction(() => {
1743
+ const current = db
1744
+ .prepare("SELECT * FROM memories WHERE id = ?")
1745
+ .get(memoryId);
1746
+ if (!current) {
1747
+ throw new Error(`Memory not found: ${memoryId}`);
1748
+ }
1749
+
1750
+ const revisionNumber = db
1751
+ .prepare(`
1752
+ SELECT COALESCE(MAX(revision_number), 0) + 1 AS revision_number
1753
+ FROM memory_revisions
1754
+ WHERE memory_id = ?
1755
+ `)
1756
+ .get(memoryId).revision_number;
1757
+ const now = new Date().toISOString();
1758
+ const snapshot = getMemoryGraphSnapshot(memoryId);
1759
+ db.prepare(`
1760
+ INSERT INTO memory_revisions (
1761
+ memory_id,
1762
+ revision_number,
1763
+ snapshot_json,
1764
+ created_at
1765
+ )
1766
+ VALUES (?, ?, ?, ?)
1767
+ `).run(memoryId, revisionNumber, JSON.stringify(snapshot), now);
1768
+
1769
+ const tags = mergeTags(current.tags, metadata.tags);
1770
+ db.prepare(`
1771
+ UPDATE memories
1772
+ SET raw_text = ?,
1773
+ ingestion_date = ?,
1774
+ summary = ?,
1775
+ type = ?,
1776
+ title = ?,
1777
+ confidence = ?,
1778
+ tags = ?,
1779
+ version = version + 1,
1780
+ updated_at = ?
1781
+ WHERE id = ?
1782
+ `).run(
1783
+ rawText,
1784
+ ingestionDate,
1785
+ extraction.summary ?? null,
1786
+ metadata.type || "fact",
1787
+ metadata.title || String(rawText).slice(0, 50),
1788
+ metadata.confidence ?? 0.6,
1789
+ JSON.stringify(tags),
1790
+ now,
1791
+ memoryId,
1792
+ );
1793
+
1794
+ db.prepare("DELETE FROM relationships WHERE memory_id = ?").run(memoryId);
1795
+ db.prepare("DELETE FROM memory_entities WHERE memory_id = ?").run(memoryId);
1796
+ db.prepare("DELETE FROM region_activations WHERE memory_id = ?").run(memoryId);
1797
+ db.prepare(`
1798
+ DELETE FROM memory_comparisons
1799
+ WHERE left_memory_id = ? OR right_memory_id = ?
1800
+ `).run(memoryId, memoryId);
1801
+
1802
+ saveExtraction(memoryId, extraction, model, schemaVersion);
1803
+ persistDerivedMemoryGraph(memoryId, extraction);
1804
+ syncFtsForMemory(memoryId);
1805
+
1806
+ return {
1807
+ memory: getMemory(memoryId),
1808
+ revisionNumber,
1809
+ };
1810
+ });
1811
+
1812
+ return updateAll();
1813
+ }
1814
+
1815
+ export function getMemoryRevisions(memoryId) {
1816
+ return getDb()
1817
+ .prepare(`
1818
+ SELECT *
1819
+ FROM memory_revisions
1820
+ WHERE memory_id = ?
1821
+ ORDER BY revision_number DESC, id DESC
1822
+ `)
1823
+ .all(memoryId)
1824
+ .map((revision) => ({
1825
+ ...revision,
1826
+ snapshot_json: JSON.parse(revision.snapshot_json),
1827
+ }));
1828
+ }
1829
+
1830
+ function getMemoryGraphSnapshot(memoryId) {
1831
+ const db = getDb();
1832
+ return {
1833
+ memory: deserializeMemory(
1834
+ db.prepare("SELECT * FROM memories WHERE id = ?").get(memoryId),
1835
+ ),
1836
+ extractions: getExtractions(memoryId),
1837
+ entities: db
1838
+ .prepare(`
1839
+ SELECT e.*
1840
+ FROM entities e
1841
+ WHERE e.id IN (
1842
+ SELECT me.entity_id
1843
+ FROM memory_entities me
1844
+ WHERE me.memory_id = ?
1845
+ UNION
1846
+ SELECT r.source_entity_id
1847
+ FROM relationships r
1848
+ WHERE r.memory_id = ?
1849
+ UNION
1850
+ SELECT r.target_entity_id
1851
+ FROM relationships r
1852
+ WHERE r.memory_id = ?
1853
+ )
1854
+ ORDER BY e.id ASC
1855
+ `)
1856
+ .all(memoryId, memoryId, memoryId),
1857
+ entityLinks: db
1858
+ .prepare(`
1859
+ SELECT *
1860
+ FROM memory_entities
1861
+ WHERE memory_id = ?
1862
+ ORDER BY id ASC
1863
+ `)
1864
+ .all(memoryId),
1865
+ relationships: db
1866
+ .prepare(`
1867
+ SELECT *
1868
+ FROM relationships
1869
+ WHERE memory_id = ?
1870
+ ORDER BY id ASC
1871
+ `)
1872
+ .all(memoryId),
1873
+ regionActivations: db
1874
+ .prepare(`
1875
+ SELECT *
1876
+ FROM region_activations
1877
+ WHERE memory_id = ?
1878
+ ORDER BY mapping_version ASC, id ASC
1879
+ `)
1880
+ .all(memoryId),
1881
+ };
1882
+ }
1883
+
1884
+ function persistDerivedMemoryGraph(memoryId, extraction) {
1885
+ saveRegionActivations(
1886
+ memoryId,
1887
+ mapExtractionToRegions(extraction),
1888
+ REGION_MAPPING_VERSION,
1889
+ );
1890
+
1891
+ const entityIds = new Map();
1892
+ for (const ent of extraction.entities || []) {
1893
+ const name = ent.canonicalName || ent.mention;
1894
+ const entityId = resolveEntityForExtraction({
1895
+ canonicalName: name,
1896
+ mention: ent.mention,
1897
+ kind: ent.kind,
1898
+ });
1899
+ for (const alias of [name, ent.mention, ent.canonicalName]) {
1900
+ const key = normalizeEntityKey(alias);
1901
+ if (key) entityIds.set(key, entityId);
1902
+ }
1903
+ linkMemoryToEntity(
1904
+ memoryId,
1905
+ entityId,
1906
+ ent.mention,
1907
+ null,
1908
+ ent.confidence,
1909
+ );
1910
+ }
1911
+
1912
+ for (const rel of extraction.relationships || []) {
1913
+ const srcName = String(rel.subject || "").trim();
1914
+ const tgtName = String(rel.object || "").trim();
1915
+ const srcId =
1916
+ entityIds.get(normalizeEntityKey(srcName)) ||
1917
+ resolveEntityForExtraction({
1918
+ canonicalName: srcName,
1919
+ mention: srcName,
1920
+ kind: "concept",
1921
+ });
1922
+ const tgtId =
1923
+ entityIds.get(normalizeEntityKey(tgtName)) ||
1924
+ resolveEntityForExtraction({
1925
+ canonicalName: tgtName,
1926
+ mention: tgtName,
1927
+ kind: "concept",
1928
+ });
1929
+ addRelationship(
1930
+ srcId,
1931
+ tgtId,
1932
+ rel.predicate,
1933
+ memoryId,
1934
+ rel.confidence,
1935
+ rel.evidence || (rel.evidenceSpanIndexes || [])
1936
+ .map((index) => extraction.evidenceSpans?.[index]?.text)
1937
+ .filter(Boolean)
1938
+ .join(" … "),
1939
+ );
1940
+ }
1941
+ }
1942
+
1943
+ function mergeTags(existingJson, incomingTags) {
1944
+ let existingTags = [];
1945
+ try {
1946
+ existingTags = JSON.parse(existingJson);
1947
+ } catch {
1948
+ existingTags = [];
1949
+ }
1950
+ return [
1951
+ ...new Set([
1952
+ ...(Array.isArray(existingTags) ? existingTags : []),
1953
+ ...(Array.isArray(incomingTags) ? incomingTags : []),
1954
+ ]),
1955
+ ];
1956
+ }
1957
+
1958
+ function normalizeEntityKey(value) {
1959
+ return normalizeEntityName(value)
1960
+ .toLocaleLowerCase()
1961
+ .replace(/[\p{P}\p{S}]+/gu, " ")
1962
+ .replace(/\s+/g, " ")
1963
+ .trim();
1964
+ }
1965
+
1966
+ function deserializeMemory(memory) {
1967
+ if (!memory) return memory;
1968
+ try {
1969
+ return { ...memory, tags: JSON.parse(memory.tags) };
1970
+ } catch {
1971
+ return { ...memory, tags: [] };
1972
+ }
1973
+ }
1974
+
1975
+ function normalizeEntityName(value) {
1976
+ return String(value || "")
1977
+ .normalize("NFKC")
1978
+ .trim()
1979
+ .replace(/\s+/g, " ");
1980
+ }
1981
+
1982
+ function mergeDuplicateEntities() {
1983
+ const entities = db
1984
+ .prepare("SELECT id, canonical_name, kind FROM entities ORDER BY id")
1985
+ .all();
1986
+ const canonicalIds = new Map();
1987
+ const merge = db.transaction(() => {
1988
+ const copyMemoryLinks = db.prepare(`
1989
+ INSERT OR IGNORE INTO memory_entities (
1990
+ memory_id,
1991
+ entity_id,
1992
+ mention,
1993
+ role,
1994
+ confidence,
1995
+ created_at
1996
+ )
1997
+ SELECT memory_id, ?, mention, role, confidence, created_at
1998
+ FROM memory_entities
1999
+ WHERE entity_id = ?
2000
+ `);
2001
+ const copyRelationships = db.prepare(`
2002
+ INSERT OR IGNORE INTO relationships (
2003
+ source_entity_id,
2004
+ target_entity_id,
2005
+ predicate,
2006
+ memory_id,
2007
+ confidence,
2008
+ evidence,
2009
+ created_at
2010
+ )
2011
+ SELECT
2012
+ CASE WHEN source_entity_id = ? THEN ? ELSE source_entity_id END,
2013
+ CASE WHEN target_entity_id = ? THEN ? ELSE target_entity_id END,
2014
+ predicate,
2015
+ memory_id,
2016
+ confidence,
2017
+ evidence,
2018
+ created_at
2019
+ FROM relationships
2020
+ WHERE source_entity_id = ? OR target_entity_id = ?
2021
+ `);
2022
+ const copyAliases = db.prepare(`
2023
+ INSERT OR IGNORE INTO entity_aliases (
2024
+ entity_id,
2025
+ alias,
2026
+ normalized_alias,
2027
+ canonical,
2028
+ created_at
2029
+ )
2030
+ SELECT ?, alias, normalized_alias, 0, created_at
2031
+ FROM entity_aliases
2032
+ WHERE entity_id = ?
2033
+ `);
2034
+ const deleteMemoryLinks = db.prepare(
2035
+ "DELETE FROM memory_entities WHERE entity_id = ?",
2036
+ );
2037
+ const deleteRelationships = db.prepare(
2038
+ "DELETE FROM relationships WHERE source_entity_id = ? OR target_entity_id = ?",
2039
+ );
2040
+ const closeSuggestions = db.prepare(`
2041
+ UPDATE entity_resolution_suggestions
2042
+ SET status = 'rejected', resolved_at = ?
2043
+ WHERE status = 'pending'
2044
+ AND (
2045
+ source_entity_id = ?
2046
+ OR target_entity_id = ?
2047
+ )
2048
+ `);
2049
+ const deleteEntity = db.prepare("DELETE FROM entities WHERE id = ?");
2050
+
2051
+ for (const entity of entities) {
2052
+ const key = `${entity.kind}:${normalizeEntityKey(entity.canonical_name)}`;
2053
+ const canonicalId = canonicalIds.get(key);
2054
+ if (!canonicalId) {
2055
+ canonicalIds.set(key, entity.id);
2056
+ continue;
2057
+ }
2058
+
2059
+ copyMemoryLinks.run(canonicalId, entity.id);
2060
+ copyAliases.run(canonicalId, entity.id);
2061
+ copyRelationships.run(
2062
+ entity.id,
2063
+ canonicalId,
2064
+ entity.id,
2065
+ canonicalId,
2066
+ entity.id,
2067
+ entity.id,
2068
+ );
2069
+ deleteMemoryLinks.run(entity.id);
2070
+ deleteRelationships.run(entity.id, entity.id);
2071
+ closeSuggestions.run(new Date().toISOString(), entity.id, entity.id);
2072
+ deleteEntity.run(entity.id);
2073
+ }
2074
+ });
2075
+
2076
+ merge();
2077
+ }
2078
+
2079
+ const GENERIC_ENTITY_ALIASES = new Set([
2080
+ "he",
2081
+ "her",
2082
+ "hers",
2083
+ "herself",
2084
+ "him",
2085
+ "himself",
2086
+ "i",
2087
+ "it",
2088
+ "itself",
2089
+ "colleague",
2090
+ "company",
2091
+ "concept",
2092
+ "coworker",
2093
+ "friend",
2094
+ "girl",
2095
+ "guy",
2096
+ "man",
2097
+ "me",
2098
+ "myself",
2099
+ "object",
2100
+ "organization",
2101
+ "person",
2102
+ "place",
2103
+ "someone",
2104
+ "somebody",
2105
+ "something",
2106
+ "she",
2107
+ "them",
2108
+ "themselves",
2109
+ "they",
2110
+ "this person",
2111
+ "we",
2112
+ "who",
2113
+ "whom",
2114
+ "woman",
2115
+ "you",
2116
+ "yourself",
2117
+ ]);
2118
+
2119
+ function isUsefulEntityAlias(value) {
2120
+ const key = normalizeEntityKey(value);
2121
+ return Boolean(key) && !GENERIC_ENTITY_ALIASES.has(key);
2122
+ }
2123
+
2124
+ function addEntityAlias(entityId, alias, canonical = false) {
2125
+ const displayAlias = normalizeEntityName(alias);
2126
+ const normalizedAlias = normalizeEntityKey(displayAlias);
2127
+ if (!normalizedAlias) return;
2128
+ getDb().prepare(`
2129
+ INSERT INTO entity_aliases (
2130
+ entity_id,
2131
+ alias,
2132
+ normalized_alias,
2133
+ canonical
2134
+ )
2135
+ VALUES (?, ?, ?, ?)
2136
+ ON CONFLICT(entity_id, normalized_alias) DO UPDATE SET
2137
+ canonical = MAX(entity_aliases.canonical, excluded.canonical)
2138
+ `).run(entityId, displayAlias, normalizedAlias, canonical ? 1 : 0);
2139
+ }
2140
+
2141
+ function backfillCanonicalEntityAliases() {
2142
+ const entities = db
2143
+ .prepare("SELECT id, canonical_name FROM entities ORDER BY id")
2144
+ .all();
2145
+ const backfill = db.transaction(() => {
2146
+ for (const entity of entities) {
2147
+ addEntityAlias(entity.id, entity.canonical_name, true);
2148
+ }
2149
+ });
2150
+ backfill();
2151
+ }
2152
+
2153
+ function findExactEntityMatches(value, kind) {
2154
+ const key = normalizeEntityKey(value);
2155
+ if (!key) return [];
2156
+ return getDb()
2157
+ .prepare(`
2158
+ SELECT DISTINCT e.id, e.canonical_name, e.kind
2159
+ FROM entities e
2160
+ LEFT JOIN entity_aliases ea ON ea.entity_id = e.id
2161
+ WHERE e.kind = ?
2162
+ AND (
2163
+ ea.normalized_alias = ?
2164
+ OR lower(trim(e.canonical_name)) = ?
2165
+ )
2166
+ ORDER BY e.id ASC
2167
+ `)
2168
+ .all(kind, key, key);
2169
+ }
2170
+
2171
+ function resolveEntityForExtraction({ canonicalName, mention, kind }) {
2172
+ const db = getDb();
2173
+ const canonical = normalizeEntityName(canonicalName || mention);
2174
+ const canonicalMatches = findExactEntityMatches(canonical, kind);
2175
+ if (canonicalMatches.length === 1) {
2176
+ const entityId = canonicalMatches[0].id;
2177
+ addEntityAlias(entityId, canonical, true);
2178
+ recordMentionAliasOrSuggestions(entityId, mention, kind);
2179
+ return entityId;
2180
+ }
2181
+
2182
+ const mentionMatches = isUsefulEntityAlias(mention)
2183
+ ? findExactEntityMatches(mention, kind)
2184
+ : [];
2185
+ if (canonicalMatches.length === 0 && mentionMatches.length === 1) {
2186
+ const entityId = mentionMatches[0].id;
2187
+ addEntityAlias(entityId, canonical, false);
2188
+ addEntityAlias(entityId, mention, false);
2189
+ return entityId;
2190
+ }
2191
+
2192
+ const result = db
2193
+ .prepare("INSERT INTO entities (canonical_name, kind) VALUES (?, ?)")
2194
+ .run(canonical, kind);
2195
+ const entityId = result.lastInsertRowid;
2196
+ addEntityAlias(entityId, canonical, true);
2197
+ if (isUsefulEntityAlias(mention)) {
2198
+ addEntityAlias(entityId, mention, false);
2199
+ }
2200
+
2201
+ const exactCandidates = new Map(
2202
+ [...canonicalMatches, ...mentionMatches].map((candidate) => [
2203
+ candidate.id,
2204
+ candidate,
2205
+ ]),
2206
+ );
2207
+ for (const candidate of findFuzzyEntityCandidates(
2208
+ [canonical, mention],
2209
+ kind,
2210
+ entityId,
2211
+ )) {
2212
+ exactCandidates.set(candidate.id, candidate);
2213
+ }
2214
+ for (const candidate of exactCandidates.values()) {
2215
+ createEntityResolutionSuggestion(
2216
+ entityId,
2217
+ candidate.id,
2218
+ isUsefulEntityAlias(mention) ? mention : canonical,
2219
+ );
2220
+ }
2221
+ return entityId;
2222
+ }
2223
+
2224
+ function recordMentionAliasOrSuggestions(entityId, mention, kind) {
2225
+ if (!isUsefulEntityAlias(mention)) return;
2226
+ const conflictingMatches = findExactEntityMatches(mention, kind)
2227
+ .filter((candidate) => candidate.id !== entityId);
2228
+ if (conflictingMatches.length === 0) {
2229
+ addEntityAlias(entityId, mention, false);
2230
+ return;
2231
+ }
2232
+ for (const candidate of conflictingMatches) {
2233
+ createEntityResolutionSuggestion(entityId, candidate.id, mention);
2234
+ }
2235
+ }
2236
+
2237
+ function findFuzzyEntityCandidates(values, kind, excludedEntityId) {
2238
+ const keys = [...new Set(values.map(normalizeEntityKey).filter(Boolean))];
2239
+ if (keys.length === 0) return [];
2240
+ const rows = getDb()
2241
+ .prepare(`
2242
+ SELECT DISTINCT
2243
+ e.id,
2244
+ e.canonical_name,
2245
+ e.kind,
2246
+ ea.normalized_alias
2247
+ FROM entities e
2248
+ JOIN entity_aliases ea ON ea.entity_id = e.id
2249
+ WHERE e.kind = ? AND e.id <> ?
2250
+ ORDER BY e.id ASC
2251
+ `)
2252
+ .all(kind, excludedEntityId);
2253
+ const matches = new Map();
2254
+ for (const row of rows) {
2255
+ if (keys.some((key) => areFuzzyEntityKeys(key, row.normalized_alias))) {
2256
+ matches.set(row.id, {
2257
+ id: row.id,
2258
+ canonical_name: row.canonical_name,
2259
+ kind: row.kind,
2260
+ });
2261
+ }
2262
+ }
2263
+ return [...matches.values()];
2264
+ }
2265
+
2266
+ function areFuzzyEntityKeys(left, right) {
2267
+ if (!left || !right || left === right) return false;
2268
+ const leftTokens = left.split(" ");
2269
+ const rightTokens = right.split(" ");
2270
+ const shorter = leftTokens.length <= rightTokens.length
2271
+ ? leftTokens
2272
+ : rightTokens;
2273
+ const longer = new Set(
2274
+ leftTokens.length <= rightTokens.length ? rightTokens : leftTokens,
2275
+ );
2276
+ return shorter.length > 0 && shorter.every((token) => longer.has(token));
2277
+ }
2278
+
2279
+ function createEntityResolutionSuggestion(
2280
+ sourceEntityId,
2281
+ targetEntityId,
2282
+ observedAlias,
2283
+ ) {
2284
+ if (sourceEntityId === targetEntityId) return;
2285
+ const db = getDb();
2286
+ const source = db
2287
+ .prepare("SELECT canonical_name, kind FROM entities WHERE id = ?")
2288
+ .get(sourceEntityId);
2289
+ const target = db
2290
+ .prepare("SELECT canonical_name, kind FROM entities WHERE id = ?")
2291
+ .get(targetEntityId);
2292
+ if (!source || !target || source.kind !== target.kind) return;
2293
+ db.prepare(`
2294
+ INSERT OR IGNORE INTO entity_resolution_suggestions (
2295
+ source_entity_id,
2296
+ target_entity_id,
2297
+ source_name,
2298
+ target_name,
2299
+ kind,
2300
+ observed_alias,
2301
+ normalized_alias
2302
+ )
2303
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2304
+ `).run(
2305
+ sourceEntityId,
2306
+ targetEntityId,
2307
+ source.canonical_name,
2308
+ target.canonical_name,
2309
+ source.kind,
2310
+ normalizeEntityName(observedAlias),
2311
+ normalizeEntityKey(observedAlias),
2312
+ );
2313
+ }
2314
+
2315
+ function mergeEntityIntoTarget(
2316
+ sourceEntityId,
2317
+ targetEntityId,
2318
+ suggestionId,
2319
+ resolvedAt,
2320
+ ) {
2321
+ const db = getDb();
2322
+ const source = db
2323
+ .prepare("SELECT * FROM entities WHERE id = ?")
2324
+ .get(sourceEntityId);
2325
+ const target = db
2326
+ .prepare("SELECT * FROM entities WHERE id = ?")
2327
+ .get(targetEntityId);
2328
+ if (!source || !target) {
2329
+ throw new Error("Entity resolution merge endpoint no longer exists");
2330
+ }
2331
+ if (source.kind !== target.kind) {
2332
+ throw new Error("Cannot merge entities of different kinds");
2333
+ }
2334
+
2335
+ db.prepare(`
2336
+ INSERT OR IGNORE INTO entity_aliases (
2337
+ entity_id,
2338
+ alias,
2339
+ normalized_alias,
2340
+ canonical,
2341
+ created_at
2342
+ )
2343
+ SELECT ?, alias, normalized_alias, 0, created_at
2344
+ FROM entity_aliases
2345
+ WHERE entity_id = ?
2346
+ `).run(targetEntityId, sourceEntityId);
2347
+ db.prepare(`
2348
+ INSERT OR IGNORE INTO memory_entities (
2349
+ memory_id,
2350
+ entity_id,
2351
+ mention,
2352
+ role,
2353
+ confidence,
2354
+ created_at
2355
+ )
2356
+ SELECT memory_id, ?, mention, role, confidence, created_at
2357
+ FROM memory_entities
2358
+ WHERE entity_id = ?
2359
+ `).run(targetEntityId, sourceEntityId);
2360
+ db.prepare(`
2361
+ INSERT OR IGNORE INTO relationships (
2362
+ source_entity_id,
2363
+ target_entity_id,
2364
+ predicate,
2365
+ memory_id,
2366
+ confidence,
2367
+ evidence,
2368
+ created_at
2369
+ )
2370
+ SELECT
2371
+ CASE WHEN source_entity_id = ? THEN ? ELSE source_entity_id END,
2372
+ CASE WHEN target_entity_id = ? THEN ? ELSE target_entity_id END,
2373
+ predicate,
2374
+ memory_id,
2375
+ confidence,
2376
+ evidence,
2377
+ created_at
2378
+ FROM relationships
2379
+ WHERE source_entity_id = ? OR target_entity_id = ?
2380
+ `).run(
2381
+ sourceEntityId,
2382
+ targetEntityId,
2383
+ sourceEntityId,
2384
+ targetEntityId,
2385
+ sourceEntityId,
2386
+ sourceEntityId,
2387
+ );
2388
+
2389
+ db.prepare("DELETE FROM memory_entities WHERE entity_id = ?")
2390
+ .run(sourceEntityId);
2391
+ db.prepare(`
2392
+ DELETE FROM relationships
2393
+ WHERE source_entity_id = ? OR target_entity_id = ?
2394
+ `).run(sourceEntityId, sourceEntityId);
2395
+ db.prepare(`
2396
+ UPDATE entity_resolution_suggestions
2397
+ SET status = 'rejected', resolved_at = ?
2398
+ WHERE status = 'pending'
2399
+ AND id <> ?
2400
+ AND (
2401
+ source_entity_id = ?
2402
+ OR target_entity_id = ?
2403
+ )
2404
+ `).run(resolvedAt, suggestionId, sourceEntityId, sourceEntityId);
2405
+ db.prepare("DELETE FROM entities WHERE id = ?").run(sourceEntityId);
2406
+ db.prepare(`
2407
+ UPDATE entity_resolution_suggestions
2408
+ SET status = 'merged', resolved_at = ?
2409
+ WHERE id = ?
2410
+ `).run(resolvedAt, suggestionId);
2411
+ }
2412
+
2413
+ // --- Delete ---
2414
+
2415
+ export function deleteAllMemories() {
2416
+ const db = getDb();
2417
+ db.exec("DELETE FROM memories");
2418
+ db.exec("DELETE FROM memories_fts");
2419
+ db.exec("INSERT INTO memories_fts(memories_fts) VALUES('optimize')");
2420
+ }
2421
+
2422
+ export function deleteAllEntities() {
2423
+ const db = getDb();
2424
+ const cleanup = db.transaction(() => {
2425
+ db.exec("DELETE FROM memory_entities");
2426
+ db.exec("DELETE FROM relationships");
2427
+ db.exec("DELETE FROM entity_resolution_suggestions");
2428
+ db.exec("DELETE FROM entity_aliases");
2429
+ db.exec("DELETE FROM entities");
2430
+ });
2431
+ cleanup();
2432
+ }
2433
+
2434
+ export function deleteMemory(id) {
2435
+ const db = getDb();
2436
+ db.prepare("DELETE FROM memories WHERE id = ?").run(id);
2437
+ removeFtsForMemory(id);
2438
+ }
2439
+
2440
+ // --- Graph Data ---
2441
+
2442
+ export function getGraphData() {
2443
+ const db = getDb();
2444
+
2445
+ const memoryRows = db
2446
+ .prepare(
2447
+ `SELECT m.id, m.raw_text, m.summary, m.source, m.created_at,
2448
+ e.extraction_json
2449
+ FROM memories m
2450
+ LEFT JOIN memory_extractions e ON e.id = (
2451
+ SELECT latest.id
2452
+ FROM memory_extractions latest
2453
+ WHERE latest.memory_id = m.id
2454
+ ORDER BY latest.created_at DESC, latest.id DESC
2455
+ LIMIT 1
2456
+ )
2457
+ ORDER BY m.created_at DESC`
2458
+ )
2459
+ .all();
2460
+
2461
+ const memoryNodes = memoryRows.map((row) => {
2462
+ const extraction = row.extraction_json ? JSON.parse(row.extraction_json) : null;
2463
+ return {
2464
+ id: row.id,
2465
+ type: "memory",
2466
+ label: row.summary || row.raw_text || row.id,
2467
+ text: row.raw_text,
2468
+ source: row.source,
2469
+ salience: extraction?.salience ?? 0.5,
2470
+ types: extraction?.types ?? [],
2471
+ createdAt: row.created_at,
2472
+ };
2473
+ });
2474
+
2475
+ const entityRows = db
2476
+ .prepare(
2477
+ `SELECT e.id, e.canonical_name, e.kind,
2478
+ COUNT(DISTINCT me.memory_id) AS memory_count
2479
+ FROM entities e
2480
+ LEFT JOIN memory_entities me ON me.entity_id = e.id
2481
+ GROUP BY e.id
2482
+ ORDER BY memory_count DESC, e.canonical_name ASC`
2483
+ )
2484
+ .all();
2485
+
2486
+ const entityNodes = entityRows.map((row) => ({
2487
+ id: `entity_${row.id}`,
2488
+ entityId: row.id,
2489
+ type: "entity",
2490
+ label: row.canonical_name,
2491
+ kind: row.kind,
2492
+ memoryCount: row.memory_count,
2493
+ }));
2494
+
2495
+ const memoryEntityEdges = db
2496
+ .prepare(
2497
+ `SELECT me.memory_id, me.entity_id, me.mention, me.confidence
2498
+ FROM memory_entities me`
2499
+ )
2500
+ .all()
2501
+ .map((row) => ({
2502
+ source: row.memory_id,
2503
+ target: `entity_${row.entity_id}`,
2504
+ type: "memory-entity",
2505
+ label: row.mention,
2506
+ confidence: row.confidence,
2507
+ }));
2508
+
2509
+ const relationshipEdges = db
2510
+ .prepare(
2511
+ `SELECT r.id, r.source_entity_id, r.target_entity_id,
2512
+ r.predicate, r.confidence, r.memory_id, r.evidence,
2513
+ se.canonical_name AS source_name, se.kind AS source_kind,
2514
+ te.canonical_name AS target_name, te.kind AS target_kind
2515
+ FROM relationships r
2516
+ JOIN entities se ON se.id = r.source_entity_id
2517
+ JOIN entities te ON te.id = r.target_entity_id`
2518
+ )
2519
+ .all()
2520
+ .map((row) => ({
2521
+ source: `entity_${row.source_entity_id}`,
2522
+ target: `entity_${row.target_entity_id}`,
2523
+ type: "relationship",
2524
+ label: row.predicate,
2525
+ confidence: row.confidence,
2526
+ memoryId: row.memory_id,
2527
+ evidence: row.evidence,
2528
+ sourceName: row.source_name,
2529
+ targetName: row.target_name,
2530
+ }));
2531
+
2532
+ return {
2533
+ nodes: [...memoryNodes, ...entityNodes],
2534
+ edges: [...memoryEntityEdges, ...relationshipEdges],
2535
+ };
2536
+ }
2537
+
2538
+ export function auditMemoryIntegrity() {
2539
+ const database = getDb();
2540
+ const findings = [];
2541
+ const memories = database.prepare(`
2542
+ SELECT m.*, e.id AS extraction_id, e.extraction_json, e.schema_version
2543
+ FROM memories m
2544
+ LEFT JOIN memory_extractions e ON e.id = (
2545
+ SELECT latest.id FROM memory_extractions latest
2546
+ WHERE latest.memory_id = m.id ORDER BY latest.created_at DESC, latest.id DESC LIMIT 1
2547
+ )
2548
+ ORDER BY m.id
2549
+ `).all();
2550
+
2551
+ const shapesByVersion = new Map();
2552
+ for (const memory of memories) {
2553
+ const extraction = parseJson(memory.extraction_json, null);
2554
+ if (!extraction) {
2555
+ findings.push({ code: "missing_extraction", memoryId: memory.id });
2556
+ continue;
2557
+ }
2558
+ const shape = extraction.evidenceSpans && extraction.durability
2559
+ ? "semantic_v3_atom"
2560
+ : extraction.memories ? "semantic_collection" : "legacy_atom";
2561
+ const shapes = shapesByVersion.get(memory.schema_version) || new Set();
2562
+ shapes.add(shape);
2563
+ shapesByVersion.set(memory.schema_version, shapes);
2564
+ if (extraction.text && extraction.text !== memory.raw_text) {
2565
+ findings.push({
2566
+ code: "extraction_text_mismatch",
2567
+ memoryId: memory.id,
2568
+ extractionId: memory.extraction_id,
2569
+ });
2570
+ }
2571
+ if (likelyMultiFact(memory.raw_text, extraction)) {
2572
+ findings.push({ code: "likely_multi_fact_atom", memoryId: memory.id });
2573
+ }
2574
+ if (memory.summary && hasNegationMismatch(memory.raw_text, memory.summary)) {
2575
+ findings.push({ code: "possible_summary_contradiction", memoryId: memory.id });
2576
+ }
2577
+ }
2578
+
2579
+ for (const [schemaVersion, shapes] of shapesByVersion) {
2580
+ if (shapes.size > 1) {
2581
+ findings.push({
2582
+ code: "incompatible_schema_shapes",
2583
+ schemaVersion,
2584
+ shapes: [...shapes].sort(),
2585
+ });
2586
+ }
2587
+ }
2588
+
2589
+ const unsupportedRelationships = database.prepare(`
2590
+ SELECT r.id, r.memory_id, r.evidence, m.raw_text,
2591
+ GROUP_CONCAT(s.text, char(0)) AS source_texts
2592
+ FROM relationships r
2593
+ JOIN memories m ON m.id = r.memory_id
2594
+ LEFT JOIN source_memory_links l ON l.memory_id = m.id
2595
+ LEFT JOIN memory_sources s ON s.id = l.source_id
2596
+ GROUP BY r.id
2597
+ `).all().filter((row) => row.evidence
2598
+ && !row.raw_text.includes(row.evidence)
2599
+ && !String(row.source_texts || "").split("\u0000").some(
2600
+ (sourceText) => sourceText.includes(row.evidence),
2601
+ ));
2602
+ for (const relationship of unsupportedRelationships) {
2603
+ findings.push({
2604
+ code: "relationship_evidence_missing",
2605
+ memoryId: relationship.memory_id,
2606
+ relationshipId: relationship.id,
2607
+ });
2608
+ }
2609
+
2610
+ for (const row of database.prepare(`
2611
+ SELECT s.id FROM memory_sources s
2612
+ LEFT JOIN source_memory_links l ON l.source_id = s.id
2613
+ WHERE s.extraction_status = 'completed'
2614
+ GROUP BY s.id HAVING COUNT(l.id) = 0
2615
+ `).all()) {
2616
+ findings.push({ code: "orphaned_source", sourceId: row.id });
2617
+ }
2618
+ for (const row of database.prepare(`
2619
+ SELECT a.id, a.memory_id FROM cognitive_annotations a
2620
+ LEFT JOIN memories m ON m.id = a.memory_id WHERE m.id IS NULL
2621
+ `).all()) {
2622
+ findings.push({
2623
+ code: "orphaned_annotation",
2624
+ annotationId: row.id,
2625
+ memoryId: row.memory_id,
2626
+ });
2627
+ }
2628
+ for (const row of database.prepare(`
2629
+ SELECT m.id, m.version,
2630
+ MAX(CASE WHEN v.status = 'completed' THEN v.memory_version END) AS indexed_version
2631
+ FROM memories m LEFT JOIN vector_index_jobs v ON v.memory_id = m.id
2632
+ GROUP BY m.id
2633
+ HAVING indexed_version IS NULL OR indexed_version < m.version
2634
+ `).all()) {
2635
+ findings.push({
2636
+ code: "missing_or_stale_vector_index",
2637
+ memoryId: row.id,
2638
+ memoryVersion: row.version,
2639
+ indexedVersion: row.indexed_version,
2640
+ });
2641
+ }
2642
+
2643
+ const counts = findings.reduce((result, finding) => {
2644
+ result[finding.code] = (result[finding.code] || 0) + 1;
2645
+ return result;
2646
+ }, {});
2647
+ return {
2648
+ generatedAt: new Date().toISOString(),
2649
+ memoryCount: memories.length,
2650
+ findingCount: findings.length,
2651
+ counts,
2652
+ findings,
2653
+ };
2654
+ }
2655
+
2656
+ function likelyMultiFact(text, extraction) {
2657
+ if ((String(text).match(/[.!?]+(?:\s+|$)/g) || []).length > 1) return true;
2658
+ const subjects = new Set((extraction.relationships || [])
2659
+ .map((relationship) => String(relationship.subject).toLocaleLowerCase()));
2660
+ return subjects.size > 1 || (/\band\b/i.test(text)
2661
+ && (extraction.relationships || []).length > 1);
2662
+ }
2663
+
2664
+ function hasNegationMismatch(text, summary) {
2665
+ const negation = (value) => /\b(?:not|never|no longer|doesn't|don't|isn't|wasn't)\b/i.test(value);
2666
+ return negation(text) !== negation(summary);
2667
+ }
2668
+
2669
+ export function closeDb() {
2670
+ if (db) {
2671
+ db.close();
2672
+ db = null;
2673
+ }
2674
+ }