@equationalapplications/core-llm-wiki 4.6.0 → 4.7.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.
package/dist/index.mjs CHANGED
@@ -62,6 +62,19 @@ async function setupDatabase(db, prefix) {
62
62
  key TEXT PRIMARY KEY,
63
63
  value TEXT NOT NULL
64
64
  );
65
+
66
+ CREATE TABLE IF NOT EXISTS ${prefix}outbox (
67
+ id TEXT PRIMARY KEY,
68
+ entity_id TEXT NOT NULL,
69
+ table_name TEXT NOT NULL,
70
+ record_id TEXT NOT NULL,
71
+ operation TEXT NOT NULL,
72
+ payload TEXT NOT NULL,
73
+ created_at INTEGER NOT NULL
74
+ );
75
+
76
+ CREATE INDEX IF NOT EXISTS ${prefix}outbox_entity_id_created_at
77
+ ON ${prefix}outbox (entity_id, created_at);
65
78
  `);
66
79
  }
67
80
 
@@ -77,8 +90,8 @@ var MIGRATIONS = [
77
90
  version: 2,
78
91
  description: "Remove FTS5; add embedding column for semantic retrieval",
79
92
  run: async (db, prefix) => {
80
- await db.withTransactionAsync(async () => {
81
- await db.execAsync(`
93
+ await db.withTransactionAsync(async (tx) => {
94
+ await tx.execAsync(`
82
95
  DROP TRIGGER IF EXISTS ${prefix}entries_ai;
83
96
  DROP TRIGGER IF EXISTS ${prefix}entries_ad;
84
97
  DROP TRIGGER IF EXISTS ${prefix}entries_au;
@@ -106,6 +119,25 @@ var MIGRATIONS = [
106
119
  );
107
120
  }
108
121
  }
122
+ },
123
+ {
124
+ version: 4,
125
+ description: "Create outbox table for change data capture",
126
+ run: async (db, prefix) => {
127
+ await db.execAsync(`
128
+ CREATE TABLE IF NOT EXISTS ${prefix}outbox (
129
+ id TEXT PRIMARY KEY,
130
+ entity_id TEXT NOT NULL,
131
+ table_name TEXT NOT NULL,
132
+ record_id TEXT NOT NULL,
133
+ operation TEXT NOT NULL,
134
+ payload TEXT NOT NULL,
135
+ created_at INTEGER NOT NULL
136
+ );
137
+ CREATE INDEX IF NOT EXISTS ${prefix}outbox_entity_id_created_at
138
+ ON ${prefix}outbox (entity_id, created_at);
139
+ `);
140
+ }
109
141
  }
110
142
  ];
111
143
  for (let i = 1; i < MIGRATIONS.length; i++) {
@@ -139,6 +171,1249 @@ var PrunePartialFailureError = class extends Error {
139
171
  }
140
172
  };
141
173
 
174
+ // src/repositories/BaseRepository.ts
175
+ var BaseRepository = class {
176
+ constructor(db, prefix) {
177
+ this.db = db;
178
+ this.prefix = prefix;
179
+ }
180
+ /**
181
+ * Return the DB executor for a given transaction handle.
182
+ * If tx is provided, use it; otherwise fall back to this.db.
183
+ */
184
+ getExecutor(tx) {
185
+ return tx ?? this.db;
186
+ }
187
+ };
188
+
189
+ // src/repositories/EntryRepository.ts
190
+ function mapRowToFact(row) {
191
+ const tags = (() => {
192
+ if (Array.isArray(row.tags)) return row.tags;
193
+ try {
194
+ const p = JSON.parse(row.tags);
195
+ if (Array.isArray(p)) return p;
196
+ } catch {
197
+ }
198
+ return [];
199
+ })();
200
+ return {
201
+ id: row.id,
202
+ entity_id: row.entity_id,
203
+ title: row.title,
204
+ body: row.body,
205
+ tags,
206
+ confidence: row.confidence,
207
+ source_type: row.source_type,
208
+ source_hash: row.source_hash ?? null,
209
+ source_ref: row.source_ref ?? null,
210
+ created_at: Number(row.created_at),
211
+ updated_at: Number(row.updated_at),
212
+ last_accessed_at: row.last_accessed_at === null || row.last_accessed_at === void 0 ? null : Number(row.last_accessed_at),
213
+ deleted_at: row.deleted_at != null ? Number(row.deleted_at) : null,
214
+ access_count: Number(row.access_count ?? 0)
215
+ };
216
+ }
217
+ var EntryRepository = class extends BaseRepository {
218
+ constructor(db, prefix, outbox) {
219
+ super(db, prefix);
220
+ this.outbox = outbox;
221
+ this.chunkSize = 500;
222
+ }
223
+ /**
224
+ * Fetch facts by IDs, optionally scoped to entity IDs.
225
+ * Returns facts in the order of the input IDs (first match wins).
226
+ */
227
+ async findByIds(ids, scopedEntityIds, tx) {
228
+ const executor = this.getExecutor(tx);
229
+ const rows = [];
230
+ const entityClause = scopedEntityIds && scopedEntityIds.length > 0 ? ` AND entity_id IN (${scopedEntityIds.map(() => "?").join(",")})` : "";
231
+ const entityParams = scopedEntityIds ?? [];
232
+ for (let i = 0; i < ids.length; i += this.chunkSize) {
233
+ const chunk = ids.slice(i, i + this.chunkSize);
234
+ const placeholders = chunk.map(() => "?").join(",");
235
+ const chunkRows = await executor.getAllAsync(
236
+ `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders})${entityClause} AND deleted_at IS NULL`,
237
+ [...chunk, ...entityParams]
238
+ );
239
+ rows.push(...chunkRows);
240
+ }
241
+ const byId = new Map(rows.map((r) => [r.id, r]));
242
+ return ids.map((id) => byId.get(id)).filter((r) => r !== void 0).map(mapRowToFact);
243
+ }
244
+ /**
245
+ * Upsert a WikiFact. Nullable fields set to null when fact value is null.
246
+ * Returns { changes, lastInsertRowId }.
247
+ * `tx` is REQUIRED to ensure atomic outbox staging.
248
+ */
249
+ async upsert(fact, tx) {
250
+ const executor = this.getExecutor(tx);
251
+ const now = Date.now();
252
+ const tagsJson = JSON.stringify(fact.tags);
253
+ const embeddingBlob = this.normalizeEmbeddingBlob(fact.embedding_blob);
254
+ const existingRow = await executor.getFirstAsync(
255
+ `SELECT id FROM ${this.prefix}entries WHERE id = ?`,
256
+ [fact.id]
257
+ );
258
+ const operation = fact.deleted_at ? "DELETE" : existingRow ? "UPDATE" : "INSERT";
259
+ const result = await executor.runAsync(
260
+ `INSERT INTO ${this.prefix}entries (
261
+ id, entity_id, title, body, tags, confidence, source_type,
262
+ source_hash, source_ref, created_at, updated_at, last_accessed_at, access_count,
263
+ deleted_at, embedding_blob, embedding
264
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
265
+ ON CONFLICT(id) DO UPDATE SET
266
+ entity_id = excluded.entity_id,
267
+ title = excluded.title,
268
+ body = excluded.body,
269
+ tags = excluded.tags,
270
+ confidence = excluded.confidence,
271
+ source_type = excluded.source_type,
272
+ source_hash = excluded.source_hash,
273
+ source_ref = excluded.source_ref,
274
+ updated_at = excluded.updated_at,
275
+ last_accessed_at = excluded.last_accessed_at,
276
+ access_count = excluded.access_count,
277
+ deleted_at = excluded.deleted_at,
278
+ embedding_blob = CASE WHEN excluded.embedding_blob IS NULL THEN embedding_blob ELSE excluded.embedding_blob END,
279
+ embedding = NULL`,
280
+ [
281
+ fact.id,
282
+ fact.entity_id,
283
+ fact.title,
284
+ fact.body,
285
+ tagsJson,
286
+ fact.confidence,
287
+ fact.source_type,
288
+ fact.source_hash,
289
+ fact.source_ref,
290
+ fact.created_at,
291
+ now,
292
+ fact.last_accessed_at === null ? null : fact.last_accessed_at,
293
+ fact.access_count,
294
+ fact.deleted_at ?? null,
295
+ embeddingBlob ?? null,
296
+ null
297
+ ]
298
+ );
299
+ await this.outbox.push({
300
+ entityId: fact.entity_id,
301
+ tableName: "entries",
302
+ recordId: fact.id,
303
+ operation,
304
+ payload: fact
305
+ }, tx);
306
+ return result;
307
+ }
308
+ /**
309
+ * Normalize an embedding blob value to Uint8Array or null.
310
+ */
311
+ normalizeEmbeddingBlob(blob) {
312
+ if (blob instanceof Uint8Array) return blob;
313
+ if (blob !== null && blob !== void 0 && typeof blob === "object") {
314
+ const obj = blob;
315
+ if (obj["type"] === "Buffer" && Array.isArray(obj["data"])) {
316
+ return new Uint8Array(obj["data"]);
317
+ }
318
+ const entries = Object.keys(obj);
319
+ if (entries.length > 0 && entries.every((k) => /^\d+$/.test(k))) {
320
+ const len = entries.length;
321
+ const arr = new Uint8Array(len);
322
+ for (let i = 0; i < len; i++) arr[i] = obj[String(i)] ?? 0;
323
+ return arr;
324
+ }
325
+ }
326
+ return null;
327
+ }
328
+ /**
329
+ * Fetch existing rows by IDs and return id/entity_id/updated_at for import collision resolution.
330
+ */
331
+ async findExistingMetadataByIds(ids, tx) {
332
+ const executor = this.getExecutor(tx);
333
+ const rows = [];
334
+ for (let i = 0; i < ids.length; i += this.chunkSize) {
335
+ const chunk = ids.slice(i, i + this.chunkSize);
336
+ const placeholders = chunk.map(() => "?").join(",");
337
+ const chunkRows = await executor.getAllAsync(
338
+ `SELECT id, entity_id, updated_at FROM ${this.prefix}entries WHERE id IN (${placeholders})`,
339
+ chunk
340
+ );
341
+ rows.push(...chunkRows.map((row) => ({ id: row.id, entity_id: row.entity_id, updated_at: Number(row.updated_at) })));
342
+ }
343
+ return rows;
344
+ }
345
+ async findIdById(id, entityId, tx) {
346
+ const executor = this.getExecutor(tx);
347
+ const row = await executor.getFirstAsync(
348
+ `SELECT id FROM ${this.prefix}entries WHERE id = ? AND entity_id = ?`,
349
+ [id, entityId]
350
+ );
351
+ return row?.id ?? null;
352
+ }
353
+ async findIdsBySource(entityId, sourceRef, sourceHash, tx, includeDeleted = false) {
354
+ const executor = this.getExecutor(tx);
355
+ let sql = `SELECT id FROM ${this.prefix}entries WHERE entity_id = ?`;
356
+ const args = [entityId];
357
+ if (sourceRef !== null) {
358
+ sql += ` AND source_ref = ?`;
359
+ args.push(sourceRef);
360
+ }
361
+ if (sourceHash !== null) {
362
+ sql += ` AND source_hash = ?`;
363
+ args.push(sourceHash);
364
+ }
365
+ if (!includeDeleted) {
366
+ sql += ` AND deleted_at IS NULL`;
367
+ }
368
+ const rows = await executor.getAllAsync(sql, args);
369
+ return rows.map((row) => row.id);
370
+ }
371
+ async upsertForImport(fact, tx) {
372
+ const executor = this.getExecutor(tx);
373
+ const tagsJson = JSON.stringify(fact.tags);
374
+ const embeddingBlob = this.normalizeEmbeddingBlob(fact.embedding_blob);
375
+ const result = await executor.runAsync(
376
+ `INSERT INTO ${this.prefix}entries (
377
+ id, entity_id, title, body, tags, confidence, source_type,
378
+ source_hash, source_ref, created_at, updated_at, last_accessed_at, access_count,
379
+ deleted_at, embedding_blob, embedding
380
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
381
+ ON CONFLICT(id) DO UPDATE SET
382
+ entity_id = excluded.entity_id,
383
+ title = excluded.title,
384
+ body = excluded.body,
385
+ tags = excluded.tags,
386
+ confidence = excluded.confidence,
387
+ source_type = excluded.source_type,
388
+ source_hash = excluded.source_hash,
389
+ source_ref = excluded.source_ref,
390
+ created_at = excluded.created_at,
391
+ updated_at = excluded.updated_at,
392
+ last_accessed_at = excluded.last_accessed_at,
393
+ access_count = excluded.access_count,
394
+ deleted_at = excluded.deleted_at,
395
+ embedding_blob = excluded.embedding_blob,
396
+ embedding = NULL`,
397
+ [
398
+ fact.id,
399
+ fact.entity_id,
400
+ fact.title,
401
+ fact.body,
402
+ tagsJson,
403
+ fact.confidence,
404
+ fact.source_type,
405
+ fact.source_hash,
406
+ fact.source_ref,
407
+ fact.created_at,
408
+ fact.updated_at,
409
+ fact.last_accessed_at === null ? null : fact.last_accessed_at,
410
+ fact.access_count,
411
+ fact.deleted_at ?? null,
412
+ embeddingBlob ?? null,
413
+ null
414
+ ]
415
+ );
416
+ return result;
417
+ }
418
+ /**
419
+ * Soft-delete a single entry by ID scoped to entityId. Sets deleted_at + updated_at.
420
+ * `tx` is REQUIRED to ensure atomic outbox staging.
421
+ */
422
+ async softDelete(entryId, entityId, tx) {
423
+ const executor = this.getExecutor(tx);
424
+ const now = Date.now();
425
+ const result = await executor.runAsync(
426
+ `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE id = ? AND entity_id = ? AND deleted_at IS NULL`,
427
+ [now, now, entryId, entityId]
428
+ );
429
+ await this.outbox.push({
430
+ entityId,
431
+ tableName: "entries",
432
+ recordId: entryId,
433
+ operation: "DELETE",
434
+ payload: { id: entryId, entity_id: entityId, deleted_at: now }
435
+ }, tx);
436
+ return result;
437
+ }
438
+ /**
439
+ * Soft-delete entries by source_ref and/or source_hash within a transaction.
440
+ * Stages a DELETE outbox entry for each row in the same transaction.
441
+ * `tx` is REQUIRED.
442
+ * Returns the number of rows deleted.
443
+ */
444
+ async softDeleteBySource(entityId, tx, sourceRef, sourceHash) {
445
+ const executor = this.getExecutor(tx);
446
+ const now = Date.now();
447
+ let q = `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`;
448
+ const args = [now, now, entityId];
449
+ if (sourceRef) {
450
+ q += ` AND source_ref = ?`;
451
+ args.push(sourceRef);
452
+ }
453
+ if (sourceHash) {
454
+ q += ` AND source_hash = ?`;
455
+ args.push(sourceHash);
456
+ }
457
+ let selectQ = `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`;
458
+ const selectArgs = [entityId];
459
+ if (sourceRef) {
460
+ selectQ += ` AND source_ref = ?`;
461
+ selectArgs.push(sourceRef);
462
+ }
463
+ if (sourceHash) {
464
+ selectQ += ` AND source_hash = ?`;
465
+ selectArgs.push(sourceHash);
466
+ }
467
+ const idsToDelete = await executor.getAllAsync(selectQ, selectArgs);
468
+ const result = await executor.runAsync(q, args);
469
+ for (const row of idsToDelete) {
470
+ await this.outbox.push({
471
+ entityId,
472
+ tableName: "entries",
473
+ recordId: row.id,
474
+ operation: "DELETE",
475
+ payload: { id: row.id, entity_id: entityId, deleted_at: now }
476
+ }, tx);
477
+ }
478
+ return result.changes;
479
+ }
480
+ /**
481
+ * Fetch IDs + entity_ids of soft-deleted rows older than cutoff for a given entity.
482
+ * Used by runPrune().
483
+ */
484
+ async getPrunableMetadata(entityId, cutoff, tx) {
485
+ const executor = this.getExecutor(tx);
486
+ return executor.getAllAsync(
487
+ `SELECT id, entity_id FROM ${this.prefix}entries
488
+ WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at <= ?`,
489
+ [entityId, cutoff]
490
+ );
491
+ }
492
+ /**
493
+ * Fetch all non-deleted entries for an entity, ordered by updated_at DESC.
494
+ * Used by _getFullBundle().
495
+ */
496
+ async findAllByEntityId(entityId, tx) {
497
+ const executor = this.getExecutor(tx);
498
+ const rows = await executor.getAllAsync(
499
+ `SELECT * FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC`,
500
+ [entityId]
501
+ );
502
+ return rows.map(mapRowToFact);
503
+ }
504
+ /**
505
+ * Fetch recent non-deleted entries for an entity (limited), ordered by updated_at DESC.
506
+ * Used by _doRunLibrarian().
507
+ */
508
+ async findRecentByEntityId(entityId, limit, tx) {
509
+ const executor = this.getExecutor(tx);
510
+ const rows = await executor.getAllAsync(
511
+ `SELECT * FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT ?`,
512
+ [entityId, limit]
513
+ );
514
+ return rows.map(mapRowToFact);
515
+ }
516
+ /**
517
+ * Count non-deleted entries for the given entities whose embedding_blob dimension
518
+ * doesn't match queryVecLength. Used by read() to detect model-switch mismatches.
519
+ */
520
+ async countDimensionMismatched(entityIds, queryVecLength, tx) {
521
+ if (entityIds.length === 0) return 0;
522
+ const executor = this.getExecutor(tx);
523
+ const placeholders = entityIds.map(() => "?").join(",");
524
+ const row = await executor.getFirstAsync(
525
+ `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
526
+ WHERE entity_id IN (${placeholders}) AND deleted_at IS NULL
527
+ AND embedding_blob IS NOT NULL
528
+ AND (CAST(length(embedding_blob) AS INTEGER) % 4 = 0)
529
+ AND (CAST(length(embedding_blob) AS INTEGER) / 4) != ?`,
530
+ [...entityIds, queryVecLength]
531
+ );
532
+ return row?.cnt ?? 0;
533
+ }
534
+ /**
535
+ * Count non-deleted entries for entityId that are stale relative to targetDim
536
+ * (either no blob or wrong dimension). Used by runReembed() per-entity skip logic.
537
+ */
538
+ async countStaleForEntity(entityId, targetDim, tx) {
539
+ const executor = this.getExecutor(tx);
540
+ const row = await executor.getFirstAsync(
541
+ `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
542
+ WHERE entity_id = ? AND deleted_at IS NULL
543
+ AND (
544
+ embedding_blob IS NULL
545
+ OR (CAST(length(embedding_blob) AS INTEGER) / 4) != ?
546
+ )`,
547
+ [entityId, targetDim]
548
+ );
549
+ return row?.cnt ?? 0;
550
+ }
551
+ /**
552
+ * Count non-deleted entries with stale or unconverted embeddings relative to `dim`.
553
+ * Used by _reconcileEmbeddingDimension() to decide when to promote the pending
554
+ * embedding_dimension value.
555
+ */
556
+ async countStaleEmbeddings(dim, tx) {
557
+ const executor = this.getExecutor(tx);
558
+ const row = await executor.getFirstAsync(
559
+ `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
560
+ WHERE deleted_at IS NULL
561
+ AND (
562
+ (embedding_blob IS NOT NULL AND (CAST(length(embedding_blob) AS INTEGER) / 4) != ?)
563
+ OR (embedding_blob IS NULL AND embedding IS NOT NULL)
564
+ )`,
565
+ [dim]
566
+ );
567
+ return row?.cnt ?? 0;
568
+ }
569
+ /**
570
+ * Bulk delete pruned entries (already soft-deleted) by IDs.
571
+ * Used by runPrune(). Returns total number of deleted rows.
572
+ * `tx` is REQUIRED so outbox deletion events are staged atomically.
573
+ */
574
+ async bulkDeletePruned(entityId, cutoff, ids, tx) {
575
+ const executor = this.getExecutor(tx);
576
+ let totalChanges = 0;
577
+ const chunkSize = 500;
578
+ for (let i = 0; i < ids.length; i += chunkSize) {
579
+ const chunk = ids.slice(i, i + chunkSize);
580
+ const placeholders = chunk.map(() => "?").join(",");
581
+ const result = await executor.runAsync(
582
+ `DELETE FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at <= ? AND id IN (${placeholders})`,
583
+ [entityId, cutoff, ...chunk]
584
+ );
585
+ totalChanges += result.changes;
586
+ if (result.changes > 0) {
587
+ for (const id of chunk) {
588
+ await this.outbox.push({
589
+ entityId,
590
+ tableName: "entries",
591
+ recordId: id,
592
+ operation: "DELETE",
593
+ payload: { id, entity_id: entityId, deleted_at: cutoff }
594
+ }, tx);
595
+ }
596
+ }
597
+ }
598
+ return totalChanges;
599
+ }
600
+ /**
601
+ * Mark orphaned entries (never accessed, old) as deleted.
602
+ * Used by _doRunHeal().
603
+ */
604
+ async markOrphaned(entityId, orphanThreshold, tx) {
605
+ const executor = this.getExecutor(tx);
606
+ const now = Date.now();
607
+ const orphanedRows = await executor.getAllAsync(
608
+ `SELECT id FROM ${this.prefix}entries
609
+ WHERE entity_id = ? AND access_count = 0 AND created_at <= ? AND source_type != 'immutable_document' AND deleted_at IS NULL`,
610
+ [entityId, orphanThreshold]
611
+ );
612
+ if (orphanedRows.length === 0) return 0;
613
+ const result = await executor.runAsync(
614
+ `UPDATE ${this.prefix}entries
615
+ SET deleted_at = ?, updated_at = ?
616
+ WHERE entity_id = ? AND access_count = 0 AND created_at <= ? AND source_type != 'immutable_document' AND deleted_at IS NULL`,
617
+ [now, now, entityId, orphanThreshold]
618
+ );
619
+ for (const row of orphanedRows) {
620
+ await this.outbox.push({
621
+ entityId,
622
+ tableName: "entries",
623
+ recordId: row.id,
624
+ operation: "DELETE",
625
+ payload: { id: row.id, entity_id: entityId, deleted_at: now }
626
+ }, tx);
627
+ }
628
+ return result.changes;
629
+ }
630
+ /**
631
+ * Downgrade stale inferred entries to 'tentative'.
632
+ * Used by _doRunHeal().
633
+ */
634
+ async downgradeStaleInferred(entityId, staleThreshold, tx) {
635
+ const executor = this.getExecutor(tx);
636
+ const now = Date.now();
637
+ const eligibleRows = await executor.getAllAsync(
638
+ `SELECT id FROM ${this.prefix}entries
639
+ WHERE entity_id = ? AND confidence = 'inferred'
640
+ AND (last_accessed_at <= ? OR (last_accessed_at IS NULL AND created_at <= ?))
641
+ AND source_type != 'immutable_document' AND deleted_at IS NULL`,
642
+ [entityId, staleThreshold, staleThreshold]
643
+ );
644
+ if (eligibleRows.length === 0) return 0;
645
+ const result = await executor.runAsync(
646
+ `UPDATE ${this.prefix}entries
647
+ SET confidence = 'tentative', updated_at = ?
648
+ WHERE entity_id = ? AND confidence = 'inferred' AND (last_accessed_at <= ? OR (last_accessed_at IS NULL AND created_at <= ?)) AND source_type != 'immutable_document' AND deleted_at IS NULL`,
649
+ [now, entityId, staleThreshold, staleThreshold]
650
+ );
651
+ for (const row of eligibleRows) {
652
+ await this.outbox.push({
653
+ entityId,
654
+ tableName: "entries",
655
+ recordId: row.id,
656
+ operation: "UPDATE",
657
+ payload: { id: row.id, entity_id: entityId, confidence: "tentative", updated_at: now }
658
+ }, tx);
659
+ }
660
+ return result.changes;
661
+ }
662
+ /**
663
+ * Downgrade specific entries to 'tentative' by IDs.
664
+ * Used by _doRunHeal().
665
+ */
666
+ async downgradeByIds(ids, entityId, tx) {
667
+ if (ids.length === 0) return;
668
+ const executor = this.getExecutor(tx);
669
+ const now = Date.now();
670
+ const placeholders = ids.map(() => "?").join(",");
671
+ await executor.runAsync(
672
+ `UPDATE ${this.prefix}entries SET confidence = 'tentative', updated_at = ? WHERE id IN (${placeholders}) AND entity_id = ?`,
673
+ [now, ...ids, entityId]
674
+ );
675
+ for (const id of ids) {
676
+ await this.outbox.push({
677
+ entityId,
678
+ tableName: "entries",
679
+ recordId: id,
680
+ operation: "UPDATE",
681
+ payload: { id, entity_id: entityId, confidence: "tentative", updated_at: now }
682
+ }, tx);
683
+ }
684
+ }
685
+ /**
686
+ * Soft-delete specific entries by IDs.
687
+ * Used by _doRunHeal().
688
+ */
689
+ async softDeleteByIds(ids, entityId, tx) {
690
+ if (ids.length === 0) return;
691
+ const executor = this.getExecutor(tx);
692
+ const now = Date.now();
693
+ const placeholders = ids.map(() => "?").join(",");
694
+ await executor.runAsync(
695
+ `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE id IN (${placeholders}) AND entity_id = ?`,
696
+ [now, now, ...ids, entityId]
697
+ );
698
+ for (const id of ids) {
699
+ await this.outbox.push({
700
+ entityId,
701
+ tableName: "entries",
702
+ recordId: id,
703
+ operation: "DELETE",
704
+ payload: { id, entity_id: entityId, deleted_at: now }
705
+ }, tx);
706
+ }
707
+ }
708
+ /**
709
+ * Bulk soft-delete all entries for an entity.
710
+ * Stages DELETE outbox entries for each row in the same transaction.
711
+ * `tx` is REQUIRED.
712
+ */
713
+ async bulkSoftDeleteByEntityId(entityId, tx) {
714
+ const executor = this.getExecutor(tx);
715
+ const now = Date.now();
716
+ const idsToDelete = await executor.getAllAsync(
717
+ `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
718
+ [entityId]
719
+ );
720
+ const result = await executor.runAsync(
721
+ `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`,
722
+ [now, now, entityId]
723
+ );
724
+ for (const row of idsToDelete) {
725
+ await this.outbox.push({
726
+ entityId,
727
+ tableName: "entries",
728
+ recordId: row.id,
729
+ operation: "DELETE",
730
+ payload: { id: row.id, entity_id: entityId, deleted_at: now }
731
+ }, tx);
732
+ }
733
+ return result.changes;
734
+ }
735
+ async findMiniSearchRows(entityId, tx) {
736
+ const executor = this.getExecutor(tx);
737
+ if (entityId !== void 0) {
738
+ return executor.getAllAsync(
739
+ `SELECT id, entity_id, title, body, tags FROM ${this.prefix}entries WHERE deleted_at IS NULL AND entity_id = ?`,
740
+ [entityId]
741
+ );
742
+ }
743
+ return executor.getAllAsync(
744
+ `SELECT id, entity_id, title, body, tags FROM ${this.prefix}entries WHERE deleted_at IS NULL`
745
+ );
746
+ }
747
+ async updateEmbeddingBlob(id, blob, tx) {
748
+ const executor = this.getExecutor(tx);
749
+ await executor.runAsync(
750
+ `UPDATE ${this.prefix}entries SET embedding_blob = ?, embedding = NULL WHERE id = ?`,
751
+ [blob, id]
752
+ );
753
+ }
754
+ async hasLegacySourceTypes(tx) {
755
+ const executor = this.getExecutor(tx);
756
+ const row = await executor.getFirstAsync(
757
+ `SELECT 1 AS one FROM ${this.prefix}entries WHERE source_type IN ('user_document', 'agent_inferred') LIMIT 1`,
758
+ []
759
+ );
760
+ return row != null;
761
+ }
762
+ async countLegacySourceTypes(tx) {
763
+ const executor = this.getExecutor(tx);
764
+ const row = await executor.getFirstAsync(
765
+ `SELECT COUNT(*) as count FROM ${this.prefix}entries WHERE source_type IN ('user_document', 'agent_inferred')`,
766
+ []
767
+ );
768
+ return row?.count ?? 0;
769
+ }
770
+ async findAllForReembed(entityId, tx) {
771
+ const executor = this.getExecutor(tx);
772
+ if (entityId !== void 0) {
773
+ return executor.getAllAsync(
774
+ `SELECT * FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
775
+ [entityId]
776
+ );
777
+ }
778
+ return executor.getAllAsync(
779
+ `SELECT * FROM ${this.prefix}entries WHERE deleted_at IS NULL`
780
+ );
781
+ }
782
+ async findRowsForSourceRefMigration(tx) {
783
+ const executor = this.getExecutor(tx);
784
+ return executor.getAllAsync(
785
+ `SELECT rowid, source_ref FROM ${this.prefix}entries
786
+ WHERE source_ref IS NOT NULL
787
+ AND (
788
+ TRIM(source_ref) != source_ref
789
+ OR INSTR(source_ref, '/') > 0
790
+ OR INSTR(source_ref, '\\') > 0
791
+ OR INSTR(source_ref, CHAR(0)) > 0
792
+ OR source_ref GLOB '*[^-A-Za-z0-9._ ]*'
793
+ )`
794
+ );
795
+ }
796
+ async updateSourceRefByRowid(rowid, sourceRef, tx) {
797
+ const executor = this.getExecutor(tx);
798
+ await executor.runAsync(
799
+ `UPDATE ${this.prefix}entries SET source_ref = ? WHERE rowid = ?`,
800
+ [sourceRef, rowid]
801
+ );
802
+ }
803
+ async findLatestSourceHash(entityId, sourceRef, tx) {
804
+ const executor = this.getExecutor(tx);
805
+ const row = await executor.getFirstAsync(
806
+ `SELECT source_hash FROM ${this.prefix}entries
807
+ WHERE entity_id = ? AND source_ref = ? AND deleted_at IS NULL
808
+ ORDER BY updated_at DESC
809
+ LIMIT 1`,
810
+ [entityId, sourceRef]
811
+ );
812
+ return row?.source_hash ?? null;
813
+ }
814
+ async findMetadataByIds(ids, tx) {
815
+ if (ids.length === 0) return [];
816
+ const executor = this.getExecutor(tx);
817
+ const rows = [];
818
+ for (let i = 0; i < ids.length; i += this.chunkSize) {
819
+ const chunk = ids.slice(i, i + this.chunkSize);
820
+ const placeholders = chunk.map(() => "?").join(",");
821
+ const chunkRows = await executor.getAllAsync(
822
+ `SELECT id, entity_id, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
823
+ chunk
824
+ );
825
+ rows.push(...chunkRows);
826
+ }
827
+ return rows;
828
+ }
829
+ async findWithEmbeddingsByIds(ids, tx) {
830
+ if (ids.length === 0) return [];
831
+ const executor = this.getExecutor(tx);
832
+ const rows = [];
833
+ for (let i = 0; i < ids.length; i += this.chunkSize) {
834
+ const chunk = ids.slice(i, i + this.chunkSize);
835
+ const placeholders = chunk.map(() => "?").join(",");
836
+ const chunkRows = await executor.getAllAsync(
837
+ `SELECT id, entity_id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
838
+ chunk
839
+ );
840
+ rows.push(...chunkRows);
841
+ }
842
+ return rows;
843
+ }
844
+ async findMetadataByEntityIds(entityIds, tx) {
845
+ if (entityIds.length === 0) return [];
846
+ const executor = this.getExecutor(tx);
847
+ const placeholders = entityIds.map(() => "?").join(",");
848
+ return executor.getAllAsync(
849
+ `SELECT id, entity_id, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id IN (${placeholders}) AND deleted_at IS NULL`,
850
+ [...entityIds]
851
+ );
852
+ }
853
+ async findWithEmbeddingsByEntityIds(entityIds, tx) {
854
+ if (entityIds.length === 0) return [];
855
+ const executor = this.getExecutor(tx);
856
+ const placeholders = entityIds.map(() => "?").join(",");
857
+ return executor.getAllAsync(
858
+ `SELECT id, entity_id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id IN (${placeholders}) AND deleted_at IS NULL`,
859
+ [...entityIds]
860
+ );
861
+ }
862
+ async findEmbeddingsByIds(ids, tx) {
863
+ if (ids.length === 0) return [];
864
+ const executor = this.getExecutor(tx);
865
+ const rows = [];
866
+ for (let i = 0; i < ids.length; i += this.chunkSize) {
867
+ const chunk = ids.slice(i, i + this.chunkSize);
868
+ const placeholders = chunk.map(() => "?").join(",");
869
+ const chunkRows = await executor.getAllAsync(
870
+ `SELECT id, embedding_blob, embedding FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
871
+ chunk
872
+ );
873
+ rows.push(...chunkRows);
874
+ }
875
+ return rows;
876
+ }
877
+ async trackAccess(ids, now, tx) {
878
+ if (ids.length === 0) return;
879
+ const executor = this.getExecutor(tx);
880
+ for (let i = 0; i < ids.length; i += this.chunkSize) {
881
+ const chunk = ids.slice(i, i + this.chunkSize);
882
+ const placeholders = chunk.map(() => "?").join(",");
883
+ await executor.runAsync(
884
+ `UPDATE ${this.prefix}entries SET access_count = access_count + 1, last_accessed_at = ? WHERE id IN (${placeholders})`,
885
+ [now, ...chunk]
886
+ );
887
+ }
888
+ }
889
+ getLegacyMigrationSQL() {
890
+ return [
891
+ `-- Migrate legacy source_type values (targets your WikiMemory prefix: ${this.prefix})`,
892
+ `UPDATE ${this.prefix}entries SET source_type = 'immutable_document' WHERE source_type = 'user_document';`,
893
+ `UPDATE ${this.prefix}entries SET source_type = 'librarian_inferred' WHERE source_type = 'agent_inferred';`
894
+ ].join("\n");
895
+ }
896
+ async findRecentByEntityIds(entityIds, limit, tx) {
897
+ if (entityIds.length === 0) return [];
898
+ const executor = this.getExecutor(tx);
899
+ const placeholders = entityIds.map(() => "?").join(",");
900
+ const rows = await executor.getAllAsync(
901
+ `SELECT * FROM ${this.prefix}entries WHERE entity_id IN (${placeholders}) AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT ?`,
902
+ [...entityIds, limit]
903
+ );
904
+ return rows.map(mapRowToFact);
905
+ }
906
+ };
907
+
908
+ // src/utils/ids.ts
909
+ function generateId(prefix = "") {
910
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
911
+ return prefix + crypto.randomUUID().replace(/-/g, "").substring(0, 24);
912
+ }
913
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
914
+ const bytes = new Uint8Array(16);
915
+ crypto.getRandomValues(bytes);
916
+ return prefix + Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("").substring(0, 24);
917
+ }
918
+ return prefix + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
919
+ }
920
+
921
+ // src/repositories/OutboxRepository.ts
922
+ var OutboxRepository = class extends BaseRepository {
923
+ /**
924
+ * Insert a new outbox event within the provided transaction.
925
+ * `tx` is required — callers must always pass the active transaction
926
+ * so the write is atomic with the main table mutation.
927
+ */
928
+ async push(params, tx) {
929
+ const executor = this.getExecutor(tx);
930
+ const id = generateId("out_");
931
+ const now = Date.now();
932
+ await executor.runAsync(
933
+ `INSERT INTO ${this.prefix}outbox (id, entity_id, table_name, record_id, operation, payload, created_at)
934
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
935
+ [id, params.entityId, params.tableName, params.recordId, params.operation, JSON.stringify(params.payload), now]
936
+ );
937
+ }
938
+ /**
939
+ * Fetch pending outbox rows ordered by created_at ASC.
940
+ * Reads directly from `this.db` (not a transaction).
941
+ */
942
+ async fetchPending(limit = 50) {
943
+ return this.db.getAllAsync(
944
+ `SELECT * FROM ${this.prefix}outbox ORDER BY created_at ASC LIMIT ?`,
945
+ [limit]
946
+ );
947
+ }
948
+ /**
949
+ * Delete acknowledged outbox rows by their IDs.
950
+ * No-op when `ids` is empty.
951
+ * Deletes directly from `this.db` (not a transaction).
952
+ */
953
+ async acknowledge(ids) {
954
+ if (ids.length === 0) return;
955
+ const placeholders = ids.map(() => "?").join(", ");
956
+ await this.db.runAsync(
957
+ `DELETE FROM ${this.prefix}outbox WHERE id IN (${placeholders})`,
958
+ ids
959
+ );
960
+ }
961
+ };
962
+
963
+ // src/repositories/TaskRepository.ts
964
+ function mapRowToTask(row) {
965
+ return {
966
+ id: row.id,
967
+ entity_id: row.entity_id,
968
+ description: row.description,
969
+ status: row.status,
970
+ priority: Number(row.priority),
971
+ created_at: Number(row.created_at),
972
+ updated_at: Number(row.updated_at),
973
+ resolved_at: row.resolved_at != null ? Number(row.resolved_at) : null,
974
+ deleted_at: row.deleted_at != null ? Number(row.deleted_at) : null
975
+ };
976
+ }
977
+ var TaskRepository = class extends BaseRepository {
978
+ constructor(db, prefix, outbox) {
979
+ super(db, prefix);
980
+ this.outbox = outbox;
981
+ }
982
+ /**
983
+ * Fetch a single task by ID. Returns null if not found or soft-deleted.
984
+ */
985
+ async findById(id) {
986
+ const row = await this.db.getFirstAsync(
987
+ `SELECT * FROM ${this.prefix}tasks WHERE id = ? AND deleted_at IS NULL`,
988
+ [id]
989
+ );
990
+ return row ? mapRowToTask(row) : null;
991
+ }
992
+ /**
993
+ * Fetch all pending/in_progress tasks for the given entity IDs.
994
+ * Returns empty array when entityIds is empty.
995
+ */
996
+ async findAllPending(entityIds, limit) {
997
+ if (entityIds.length === 0) return [];
998
+ const placeholders = entityIds.map(() => "?").join(", ");
999
+ const sql = `SELECT * FROM ${this.prefix}tasks WHERE entity_id IN (${placeholders}) AND status IN ('pending', 'in_progress') AND deleted_at IS NULL ORDER BY priority DESC, created_at ASC` + (limit != null ? ` LIMIT ?` : "");
1000
+ const params = limit != null ? [...entityIds, limit] : [...entityIds];
1001
+ const rows = await this.db.getAllAsync(sql, params);
1002
+ return rows.map(mapRowToTask);
1003
+ }
1004
+ async findExistingMetadataByIds(ids, tx) {
1005
+ const executor = this.getExecutor(tx);
1006
+ const rows = [];
1007
+ const chunkSize = 500;
1008
+ for (let i = 0; i < ids.length; i += chunkSize) {
1009
+ const chunk = ids.slice(i, i + chunkSize);
1010
+ const placeholders = chunk.map(() => "?").join(",");
1011
+ const chunkRows = await executor.getAllAsync(
1012
+ `SELECT id, entity_id, updated_at FROM ${this.prefix}tasks WHERE id IN (${placeholders})`,
1013
+ chunk
1014
+ );
1015
+ rows.push(...chunkRows.map((row) => ({ id: row.id, entity_id: row.entity_id, updated_at: Number(row.updated_at) })));
1016
+ }
1017
+ return rows;
1018
+ }
1019
+ /**
1020
+ * Upsert a WikiTask within the provided transaction.
1021
+ * Uses ON CONFLICT(id) DO UPDATE (not INSERT OR REPLACE).
1022
+ * Stages an outbox entry in the same transaction.
1023
+ * `tx` is REQUIRED.
1024
+ */
1025
+ async upsert(task, tx, updatedAt) {
1026
+ const executor = this.getExecutor(tx);
1027
+ const now = Number.isFinite(updatedAt) ? updatedAt : Date.now();
1028
+ const existingRow = await executor.getFirstAsync(
1029
+ `SELECT id FROM ${this.prefix}tasks WHERE id = ?`,
1030
+ [task.id]
1031
+ );
1032
+ const operation = task.deleted_at != null ? "DELETE" : existingRow ? "UPDATE" : "INSERT";
1033
+ await executor.runAsync(
1034
+ `INSERT INTO ${this.prefix}tasks (
1035
+ id, entity_id, description, status, priority,
1036
+ created_at, updated_at, resolved_at, deleted_at
1037
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1038
+ ON CONFLICT(id) DO UPDATE SET
1039
+ entity_id = excluded.entity_id,
1040
+ description = excluded.description,
1041
+ status = excluded.status,
1042
+ priority = excluded.priority,
1043
+ updated_at = excluded.updated_at,
1044
+ resolved_at = excluded.resolved_at,
1045
+ deleted_at = excluded.deleted_at`,
1046
+ [
1047
+ task.id,
1048
+ task.entity_id,
1049
+ task.description,
1050
+ task.status,
1051
+ task.priority,
1052
+ task.created_at,
1053
+ now,
1054
+ // updated_at set by repo or import override
1055
+ task.resolved_at ?? null,
1056
+ task.deleted_at ?? null
1057
+ ]
1058
+ );
1059
+ await this.outbox.push(
1060
+ {
1061
+ entityId: task.entity_id,
1062
+ tableName: "tasks",
1063
+ recordId: task.id,
1064
+ operation,
1065
+ payload: task
1066
+ },
1067
+ tx
1068
+ );
1069
+ }
1070
+ async upsertForImport(task, tx, updatedAt) {
1071
+ const executor = this.getExecutor(tx);
1072
+ const now = Number.isFinite(updatedAt) ? updatedAt : Date.now();
1073
+ await executor.runAsync(
1074
+ `INSERT INTO ${this.prefix}tasks (
1075
+ id, entity_id, description, status, priority,
1076
+ created_at, updated_at, resolved_at, deleted_at
1077
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1078
+ ON CONFLICT(id) DO UPDATE SET
1079
+ entity_id = excluded.entity_id,
1080
+ description = excluded.description,
1081
+ status = excluded.status,
1082
+ priority = excluded.priority,
1083
+ updated_at = excluded.updated_at,
1084
+ resolved_at = excluded.resolved_at,
1085
+ deleted_at = excluded.deleted_at`,
1086
+ [
1087
+ task.id,
1088
+ task.entity_id,
1089
+ task.description,
1090
+ task.status,
1091
+ task.priority,
1092
+ task.created_at,
1093
+ now,
1094
+ task.resolved_at ?? null,
1095
+ task.deleted_at ?? null
1096
+ ]
1097
+ );
1098
+ }
1099
+ /**
1100
+ * Soft-delete a task by ID. Sets deleted_at and updated_at.
1101
+ * Stages a DELETE outbox entry in the same transaction.
1102
+ * `tx` is REQUIRED.
1103
+ */
1104
+ async softDelete(id, entityId, tx) {
1105
+ const executor = this.getExecutor(tx);
1106
+ const now = Date.now();
1107
+ await executor.runAsync(
1108
+ `UPDATE ${this.prefix}tasks SET deleted_at = ?, updated_at = ? WHERE id = ? AND entity_id = ? AND deleted_at IS NULL`,
1109
+ [now, now, id, entityId]
1110
+ );
1111
+ await this.outbox.push(
1112
+ {
1113
+ entityId,
1114
+ tableName: "tasks",
1115
+ recordId: id,
1116
+ operation: "DELETE",
1117
+ payload: { id, entity_id: entityId, deleted_at: now }
1118
+ },
1119
+ tx
1120
+ );
1121
+ }
1122
+ /**
1123
+ * Fetch all non-deleted tasks for an entity, ordered by priority DESC, created_at ASC.
1124
+ * Used by _getFullBundle().
1125
+ */
1126
+ async findAllByEntityId(entityId, tx) {
1127
+ const executor = this.getExecutor(tx);
1128
+ const rows = await executor.getAllAsync(
1129
+ `SELECT * FROM ${this.prefix}tasks WHERE entity_id = ? AND deleted_at IS NULL ORDER BY priority DESC, created_at ASC`,
1130
+ [entityId]
1131
+ );
1132
+ return rows.map(mapRowToTask);
1133
+ }
1134
+ /**
1135
+ * Bulk delete pruned tasks (already soft-deleted) by cutoff date.
1136
+ * Used by runPrune(). Returns number of deleted rows.
1137
+ */
1138
+ async bulkDeletePruned(entityId, cutoff, tx) {
1139
+ const executor = this.getExecutor(tx);
1140
+ const rowsToDelete = await executor.getAllAsync(
1141
+ `SELECT id, deleted_at FROM ${this.prefix}tasks WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at <= ?`,
1142
+ [entityId, cutoff]
1143
+ );
1144
+ if (rowsToDelete.length === 0) return 0;
1145
+ const result = await executor.runAsync(
1146
+ `DELETE FROM ${this.prefix}tasks WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at <= ?`,
1147
+ [entityId, cutoff]
1148
+ );
1149
+ for (const row of rowsToDelete) {
1150
+ await this.outbox.push(
1151
+ {
1152
+ entityId,
1153
+ tableName: "tasks",
1154
+ recordId: row.id,
1155
+ operation: "DELETE",
1156
+ payload: { id: row.id, entity_id: entityId, deleted_at: row.deleted_at }
1157
+ },
1158
+ tx
1159
+ );
1160
+ }
1161
+ return result.changes;
1162
+ }
1163
+ /**
1164
+ * Soft-delete a task by ID within a transaction.
1165
+ * Stages a DELETE outbox entry in the same transaction.
1166
+ * `tx` is REQUIRED.
1167
+ */
1168
+ async softDeleteById(id, entityId, tx) {
1169
+ const executor = this.getExecutor(tx);
1170
+ const now = Date.now();
1171
+ const result = await executor.runAsync(
1172
+ `UPDATE ${this.prefix}tasks SET deleted_at = ?, updated_at = ? WHERE id = ? AND entity_id = ? AND deleted_at IS NULL`,
1173
+ [now, now, id, entityId]
1174
+ );
1175
+ if (result.changes > 0) {
1176
+ await this.outbox.push(
1177
+ {
1178
+ entityId,
1179
+ tableName: "tasks",
1180
+ recordId: id,
1181
+ operation: "DELETE",
1182
+ payload: { id, entity_id: entityId, deleted_at: now }
1183
+ },
1184
+ tx
1185
+ );
1186
+ }
1187
+ return result;
1188
+ }
1189
+ /**
1190
+ * Bulk soft-delete all tasks for an entity.
1191
+ * Stages DELETE outbox entries for each row in the same transaction.
1192
+ * `tx` is REQUIRED.
1193
+ */
1194
+ async bulkSoftDeleteByEntityId(entityId, tx) {
1195
+ const executor = this.getExecutor(tx);
1196
+ const now = Date.now();
1197
+ const idsToDelete = await executor.getAllAsync(
1198
+ `SELECT id FROM ${this.prefix}tasks WHERE entity_id = ? AND deleted_at IS NULL`,
1199
+ [entityId]
1200
+ );
1201
+ const result = await executor.runAsync(
1202
+ `UPDATE ${this.prefix}tasks SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`,
1203
+ [now, now, entityId]
1204
+ );
1205
+ for (const row of idsToDelete) {
1206
+ await this.outbox.push({
1207
+ entityId,
1208
+ tableName: "tasks",
1209
+ recordId: row.id,
1210
+ operation: "DELETE",
1211
+ payload: { id: row.id, entity_id: entityId, deleted_at: now }
1212
+ }, tx);
1213
+ }
1214
+ return result.changes;
1215
+ }
1216
+ };
1217
+
1218
+ // src/repositories/EventRepository.ts
1219
+ var EventRepository = class extends BaseRepository {
1220
+ /**
1221
+ * Insert a new event row.
1222
+ * Pass `tx` to participate in a caller-owned transaction; omit to run against the default db.
1223
+ */
1224
+ async add(event, tx) {
1225
+ const executor = this.getExecutor(tx);
1226
+ await executor.runAsync(
1227
+ `INSERT INTO ${this.prefix}events (id, entity_id, event_type, summary, related_entry_id, created_at)
1228
+ VALUES (?, ?, ?, ?, ?, ?)`,
1229
+ [
1230
+ event.id,
1231
+ event.entity_id,
1232
+ event.event_type,
1233
+ event.summary,
1234
+ event.related_entry_id ?? null,
1235
+ event.created_at
1236
+ ]
1237
+ );
1238
+ }
1239
+ async addIgnoreDuplicate(event, tx) {
1240
+ const executor = this.getExecutor(tx);
1241
+ await executor.runAsync(
1242
+ `INSERT OR IGNORE INTO ${this.prefix}events (id, entity_id, event_type, summary, related_entry_id, created_at)
1243
+ VALUES (?, ?, ?, ?, ?, ?)`,
1244
+ [
1245
+ event.id,
1246
+ event.entity_id,
1247
+ event.event_type,
1248
+ event.summary,
1249
+ event.related_entry_id ?? null,
1250
+ event.created_at
1251
+ ]
1252
+ );
1253
+ }
1254
+ /**
1255
+ * Return the most recent events for an entity, newest first.
1256
+ * Defaults to a limit of 50.
1257
+ */
1258
+ async getRecent(entityId, limit = 50) {
1259
+ return this.db.getAllAsync(
1260
+ `SELECT * FROM ${this.prefix}events WHERE entity_id = ? ORDER BY created_at DESC LIMIT ?`,
1261
+ [entityId, limit]
1262
+ );
1263
+ }
1264
+ /**
1265
+ * Return the most recent events for the given entity IDs, newest first.
1266
+ * Defaults to a limit of 50.
1267
+ */
1268
+ async getRecentForEntities(entityIds, limit = 50) {
1269
+ if (entityIds.length === 0) return [];
1270
+ const placeholders = entityIds.map(() => "?").join(", ");
1271
+ return this.db.getAllAsync(
1272
+ `SELECT * FROM ${this.prefix}events WHERE entity_id IN (${placeholders}) ORDER BY created_at DESC LIMIT ?`,
1273
+ [...entityIds, limit]
1274
+ );
1275
+ }
1276
+ /**
1277
+ * Delete events for an entity that were created at or before the given cutoff timestamp.
1278
+ * Returns the number of deleted rows.
1279
+ */
1280
+ async prune(entityId, cutoff) {
1281
+ return this.db.runAsync(
1282
+ `DELETE FROM ${this.prefix}events WHERE entity_id = ? AND created_at <= ?`,
1283
+ [entityId, cutoff]
1284
+ );
1285
+ }
1286
+ /**
1287
+ * Return the total number of events stored for an entity.
1288
+ * `tx` is optional — pass an active transaction handle for atomic reads.
1289
+ */
1290
+ async count(entityId, tx) {
1291
+ const executor = tx ?? this.db;
1292
+ const row = await executor.getFirstAsync(
1293
+ `SELECT COUNT(*) as count FROM ${this.prefix}events WHERE entity_id = ?`,
1294
+ [entityId]
1295
+ );
1296
+ return row?.count ?? 0;
1297
+ }
1298
+ /**
1299
+ * Return all events for an entity in chronological (ASC) order.
1300
+ * When limit is provided, fetches newest-first then reverses to preserve chronological order.
1301
+ */
1302
+ async getByEntityId(entityId, limit) {
1303
+ if (limit != null) {
1304
+ const rows = await this.db.getAllAsync(
1305
+ `SELECT * FROM ${this.prefix}events WHERE entity_id = ? ORDER BY created_at DESC LIMIT ?`,
1306
+ [entityId, limit]
1307
+ );
1308
+ return rows.slice().reverse();
1309
+ }
1310
+ return this.db.getAllAsync(
1311
+ `SELECT * FROM ${this.prefix}events WHERE entity_id = ? ORDER BY created_at ASC`,
1312
+ [entityId]
1313
+ );
1314
+ }
1315
+ };
1316
+
1317
+ // src/repositories/MetadataRepository.ts
1318
+ var MetadataRepository = class extends BaseRepository {
1319
+ // CHECKPOINTS TABLE METHODS
1320
+ async getCheckpoint(entityId, tx) {
1321
+ const executor = this.getExecutor(tx);
1322
+ const row = await executor.getFirstAsync(
1323
+ `SELECT memory_checkpoint, heal_checkpoint FROM ${this.prefix}checkpoints WHERE entity_id = ?`,
1324
+ [entityId]
1325
+ );
1326
+ if (!row) return {};
1327
+ return {
1328
+ memory: row.memory_checkpoint ?? void 0,
1329
+ heal: row.heal_checkpoint ?? void 0
1330
+ };
1331
+ }
1332
+ async updateCheckpoint(entityId, updates, tx) {
1333
+ const fields = [];
1334
+ const values = [];
1335
+ if (updates.memory !== void 0) {
1336
+ fields.push("memory_checkpoint = ?");
1337
+ values.push(updates.memory);
1338
+ }
1339
+ if (updates.heal !== void 0) {
1340
+ fields.push("heal_checkpoint = ?");
1341
+ values.push(updates.heal);
1342
+ }
1343
+ if (fields.length === 0) return;
1344
+ const executor = this.getExecutor(tx);
1345
+ await executor.runAsync(
1346
+ `INSERT INTO ${this.prefix}checkpoints (entity_id, memory_checkpoint, heal_checkpoint)
1347
+ VALUES (?, ?, ?)
1348
+ ON CONFLICT(entity_id) DO UPDATE SET ${fields.join(", ")}`,
1349
+ [entityId, updates.memory ?? 0, updates.heal ?? 0, ...values]
1350
+ );
1351
+ }
1352
+ async deleteCheckpoint(entityId, tx) {
1353
+ const executor = this.getExecutor(tx);
1354
+ await executor.runAsync(
1355
+ `DELETE FROM ${this.prefix}checkpoints WHERE entity_id = ?`,
1356
+ [entityId]
1357
+ );
1358
+ }
1359
+ // META TABLE METHODS
1360
+ async getMeta(key, tx) {
1361
+ const executor = this.getExecutor(tx);
1362
+ const row = await executor.getFirstAsync(
1363
+ `SELECT value FROM ${this.prefix}meta WHERE key = ?`,
1364
+ [key]
1365
+ );
1366
+ return row ? row.value : null;
1367
+ }
1368
+ async setMeta(key, value, tx) {
1369
+ const executor = this.getExecutor(tx);
1370
+ await executor.runAsync(
1371
+ `INSERT INTO ${this.prefix}meta (key, value) VALUES (?, ?)
1372
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
1373
+ [key, value]
1374
+ );
1375
+ }
1376
+ async clearDimensionMismatch(tx) {
1377
+ const executor = this.getExecutor(tx);
1378
+ await executor.runAsync(
1379
+ `DELETE FROM ${this.prefix}meta WHERE key = 'embedding_dimension_mismatch'`
1380
+ );
1381
+ }
1382
+ async tableExists(tableName, tx) {
1383
+ const executor = this.getExecutor(tx);
1384
+ const row = await executor.getFirstAsync(
1385
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
1386
+ [tableName]
1387
+ );
1388
+ return row != null;
1389
+ }
1390
+ async getTableDdl(tableName, tx) {
1391
+ const executor = this.getExecutor(tx);
1392
+ const row = await executor.getFirstAsync(
1393
+ `SELECT sql FROM sqlite_master WHERE type='table' AND name=?`,
1394
+ [tableName]
1395
+ );
1396
+ return row?.sql ?? null;
1397
+ }
1398
+ async vacuum() {
1399
+ await this.db.execAsync(`PRAGMA wal_checkpoint(TRUNCATE)`);
1400
+ await this.db.execAsync(`VACUUM`);
1401
+ }
1402
+ async getDistinctEntityIds(tx) {
1403
+ const executor = this.getExecutor(tx);
1404
+ const rows = await executor.getAllAsync(
1405
+ `SELECT DISTINCT entity_id FROM (
1406
+ SELECT entity_id FROM ${this.prefix}entries WHERE deleted_at IS NULL
1407
+ UNION
1408
+ SELECT entity_id FROM ${this.prefix}tasks WHERE deleted_at IS NULL
1409
+ UNION
1410
+ SELECT entity_id FROM ${this.prefix}events
1411
+ ) ORDER BY entity_id`
1412
+ );
1413
+ return rows.map((r) => r.entity_id);
1414
+ }
1415
+ };
1416
+
142
1417
  // src/prompts.ts
143
1418
  var LIBRARIAN_SYSTEM_PROMPT = `You are a knowledge extraction agent. Your job is to analyze recent episodic events and extract stable facts and actionable tasks about the user or entity.
144
1419
  Return ONLY a valid JSON object matching this schema:
@@ -290,9 +1565,6 @@ function parseJsonResponse(text) {
290
1565
  if (end === -1) throw new SyntaxError("No JSON object/array found in LLM response");
291
1566
  return JSON.parse(text.slice(start, end + 1));
292
1567
  }
293
- function generateId(prefix = "") {
294
- return prefix + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
295
- }
296
1568
  function safeSlice(value, start, end) {
297
1569
  const length = value.length;
298
1570
  let safeStart = start < 0 ? Math.max(length + start, 0) : Math.min(start, length);
@@ -461,6 +1733,11 @@ var _WikiMemory = class _WikiMemory {
461
1733
  this.db = db;
462
1734
  this.options = options;
463
1735
  this.prefix = options.config?.tablePrefix || "llm_wiki_";
1736
+ this.outboxRepo = new OutboxRepository(db, this.prefix);
1737
+ this.entryRepo = new EntryRepository(db, this.prefix, this.outboxRepo);
1738
+ this.taskRepo = new TaskRepository(db, this.prefix, this.outboxRepo);
1739
+ this.eventRepo = new EventRepository(db, this.prefix);
1740
+ this.metadataRepo = new MetadataRepository(db, this.prefix);
464
1741
  }
465
1742
  normalizeMiniSearchRow(row) {
466
1743
  return {
@@ -480,10 +1757,7 @@ var _WikiMemory = class _WikiMemory {
480
1757
  }
481
1758
  async rebuildMiniSearchIndex(entityId) {
482
1759
  if (entityId) {
483
- const rows2 = await this.db.getAllAsync(
484
- `SELECT id, entity_id, title, body, tags FROM ${this.prefix}entries WHERE deleted_at IS NULL AND entity_id = ?`,
485
- [entityId]
486
- );
1760
+ const rows2 = await this.entryRepo.findMiniSearchRows(entityId);
487
1761
  const previousIds = this.miniSearchEntryIdsByEntity.get(entityId);
488
1762
  if (previousIds) {
489
1763
  for (const id of previousIds) {
@@ -497,7 +1771,7 @@ var _WikiMemory = class _WikiMemory {
497
1771
  this.miniSearchEntryIdsByEntity.set(entityId, new Set(documents2.map((document) => document.id)));
498
1772
  return;
499
1773
  }
500
- const rows = await this.db.getAllAsync(`SELECT id, entity_id, title, body, tags FROM ${this.prefix}entries WHERE deleted_at IS NULL`);
1774
+ const rows = await this.entryRepo.findMiniSearchRows();
501
1775
  this.miniSearch.removeAll();
502
1776
  this.miniSearchEntryIdsByEntity.clear();
503
1777
  const documents = rows.map((row) => this.normalizeMiniSearchRow(row));
@@ -511,25 +1785,17 @@ var _WikiMemory = class _WikiMemory {
511
1785
  }
512
1786
  }
513
1787
  async storeEmbeddingDimension(dim) {
514
- const existing = await this.db.getFirstAsync(
515
- `SELECT value FROM ${this.prefix}meta WHERE key = 'embedding_dimension'`
516
- );
1788
+ const existing = await this.metadataRepo.getMeta("embedding_dimension");
517
1789
  if (existing) {
518
- const storedDim = parseInt(existing.value, 10);
1790
+ const storedDim = parseInt(existing, 10);
519
1791
  if (storedDim !== dim) {
520
1792
  console.warn(
521
1793
  `[WikiMemory] Embedding dimension mismatch: stored ${storedDim}, got ${dim}. Call runReembed() to rebuild embeddings with the new model.`
522
1794
  );
523
- await this.db.runAsync(
524
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('embedding_dimension_mismatch', ?)`,
525
- [String(dim)]
526
- );
1795
+ await this.metadataRepo.setMeta("embedding_dimension_mismatch", String(dim), this.db);
527
1796
  }
528
1797
  } else {
529
- await this.db.runAsync(
530
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('embedding_dimension', ?)`,
531
- [String(dim)]
532
- );
1798
+ await this.metadataRepo.setMeta("embedding_dimension", String(dim), this.db);
533
1799
  }
534
1800
  }
535
1801
  /**
@@ -539,28 +1805,13 @@ var _WikiMemory = class _WikiMemory {
539
1805
  * stuck on the MiniSearch fallback.
540
1806
  */
541
1807
  async _reconcileEmbeddingDimension() {
542
- const mismatch = await this.db.getFirstAsync(
543
- `SELECT value FROM ${this.prefix}meta WHERE key = 'embedding_dimension_mismatch'`
544
- );
545
- if (!mismatch) return;
546
- const newDim = parseInt(mismatch.value, 10);
547
- const residual = await this.db.getFirstAsync(
548
- `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
549
- WHERE deleted_at IS NULL
550
- AND (
551
- (embedding_blob IS NOT NULL AND (CAST(length(embedding_blob) AS INTEGER) / 4) != ?)
552
- OR (embedding_blob IS NULL AND embedding IS NOT NULL)
553
- )`,
554
- [newDim]
555
- );
556
- if (!residual || residual.cnt === 0) {
557
- await this.db.runAsync(
558
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('embedding_dimension', ?)`,
559
- [mismatch.value]
560
- );
561
- await this.db.runAsync(
562
- `DELETE FROM ${this.prefix}meta WHERE key = 'embedding_dimension_mismatch'`
563
- );
1808
+ const mismatchValue = await this.metadataRepo.getMeta("embedding_dimension_mismatch");
1809
+ if (!mismatchValue) return;
1810
+ const newDim = parseInt(mismatchValue, 10);
1811
+ const residualCount = await this.entryRepo.countStaleEmbeddings(newDim);
1812
+ if (residualCount === 0) {
1813
+ await this.metadataRepo.setMeta("embedding_dimension", mismatchValue, this.db);
1814
+ await this.metadataRepo.clearDimensionMismatch(this.db);
564
1815
  }
565
1816
  }
566
1817
  async embedFact(fact) {
@@ -598,10 +1849,7 @@ var _WikiMemory = class _WikiMemory {
598
1849
  }
599
1850
  await this.storeEmbeddingDimension(float32Vector.length);
600
1851
  const blob = new Uint8Array(float32Vector.buffer);
601
- await this.db.runAsync(
602
- `UPDATE ${this.prefix}entries SET embedding_blob = ?, embedding = NULL WHERE id = ?`,
603
- [blob, fact.id]
604
- );
1852
+ await this.entryRepo.updateEmbeddingBlob(fact.id, blob);
605
1853
  try {
606
1854
  await this._notifyEmbeddingPersisted(fact.entity_id, fact.id, float32Vector);
607
1855
  } catch (hookErr) {
@@ -634,28 +1882,12 @@ var _WikiMemory = class _WikiMemory {
634
1882
  );
635
1883
  }
636
1884
  async assertNoLegacySourceTypes() {
637
- const legacyProbe = await this.db.getFirstAsync(
638
- `SELECT 1 AS one FROM ${this.prefix}entries
639
- WHERE source_type IN ('user_document', 'agent_inferred')
640
- LIMIT 1`,
641
- []
642
- );
643
- if (!legacyProbe) return;
644
- const legacyCount = await this.db.getFirstAsync(
645
- `SELECT COUNT(*) as count FROM ${this.prefix}entries
646
- WHERE source_type IN ('user_document', 'agent_inferred')`,
647
- []
648
- );
649
- const count = legacyCount?.count ?? 0;
650
- const migrationSQL = `
651
- -- Migrate legacy source_type values (targets your WikiMemory prefix: ${this.prefix})
652
- UPDATE ${this.prefix}entries SET source_type = 'immutable_document' WHERE source_type = 'user_document';
653
- UPDATE ${this.prefix}entries SET source_type = 'librarian_inferred' WHERE source_type = 'agent_inferred';
654
- `.trim();
1885
+ if (!await this.entryRepo.hasLegacySourceTypes()) return;
1886
+ const count = await this.entryRepo.countLegacySourceTypes();
655
1887
  throw new Error(
656
1888
  `Database contains ${count} entries with legacy source_type values ('user_document' or 'agent_inferred'). These enum values were renamed in this release. Running without migration would allow legacy 'user_document' facts to bypass immutability guards, causing data corruption.
657
1889
 
658
- ${migrationSQL}
1890
+ ${this.entryRepo.getLegacyMigrationSQL()}
659
1891
 
660
1892
  After running the migration SQL, restart your application.`
661
1893
  );
@@ -712,77 +1944,45 @@ After running the migration SQL, restart your application.`
712
1944
  }
713
1945
  }
714
1946
  async setup() {
715
- const entriesExistedBeforeSetup = await this.db.getFirstAsync(
716
- `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
717
- [`${this.prefix}entries`]
718
- );
1947
+ const entriesExistedBeforeSetup = await this.metadataRepo.tableExists(`${this.prefix}entries`);
719
1948
  await setupDatabase(this.db, this.prefix);
720
1949
  let currentVersion;
721
1950
  if (!entriesExistedBeforeSetup) {
722
- await this.db.runAsync(
723
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('schema_version', ?)`,
724
- [String(CURRENT_SCHEMA_VERSION)]
725
- );
1951
+ await this.metadataRepo.setMeta("schema_version", String(CURRENT_SCHEMA_VERSION), this.db);
726
1952
  currentVersion = CURRENT_SCHEMA_VERSION;
727
1953
  } else {
728
- const metaRow = await this.db.getFirstAsync(
729
- `SELECT value FROM ${this.prefix}meta WHERE key = 'schema_version'`
730
- );
731
- if (metaRow) {
732
- currentVersion = parseInt(metaRow.value, 10);
1954
+ const schemaVersionValue = await this.metadataRepo.getMeta("schema_version");
1955
+ if (schemaVersionValue) {
1956
+ currentVersion = parseInt(schemaVersionValue, 10);
733
1957
  if (!Number.isFinite(currentVersion)) currentVersion = 0;
734
1958
  } else {
735
- const ftsMeta = await this.db.getFirstAsync(
736
- `SELECT sql FROM sqlite_master WHERE type='table' AND name=?`,
737
- [`${this.prefix}entries_fts`]
738
- );
739
- const hasPorter = /tokenize\s*=\s*['"]porter\s+unicode61['"]/i.test(ftsMeta?.sql ?? "");
1959
+ const ftsDdl = await this.metadataRepo.getTableDdl(`${this.prefix}entries_fts`);
1960
+ const hasPorter = /tokenize\s*=\s*['"]porter\s+unicode61['"]/i.test(ftsDdl ?? "");
740
1961
  currentVersion = hasPorter ? 1 : 0;
741
1962
  }
742
1963
  }
743
1964
  for (const migration of MIGRATIONS) {
744
1965
  if (migration.version > currentVersion) {
745
1966
  await migration.run(this.db, this.prefix);
746
- await this.db.runAsync(
747
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('schema_version', ?)`,
748
- [String(migration.version)]
749
- );
1967
+ await this.metadataRepo.setMeta("schema_version", String(migration.version), this.db);
750
1968
  currentVersion = migration.version;
751
1969
  }
752
1970
  }
753
1971
  if (entriesExistedBeforeSetup) {
754
- const metaCheck = await this.db.getFirstAsync(
755
- `SELECT value FROM ${this.prefix}meta WHERE key = 'schema_version'`
756
- );
757
- if (!metaCheck) {
758
- await this.db.runAsync(
759
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('schema_version', ?)`,
760
- [String(currentVersion)]
761
- );
1972
+ const schemaVersionCheck = await this.metadataRepo.getMeta("schema_version");
1973
+ if (!schemaVersionCheck) {
1974
+ await this.metadataRepo.setMeta("schema_version", String(currentVersion), this.db);
762
1975
  }
763
1976
  }
764
1977
  if (entriesExistedBeforeSetup) {
765
1978
  await this.assertNoLegacySourceTypes();
766
1979
  }
767
- const rows = await this.db.getAllAsync(`
768
- SELECT rowid, source_ref FROM ${this.prefix}entries
769
- WHERE source_ref IS NOT NULL
770
- AND (
771
- TRIM(source_ref) != source_ref
772
- OR INSTR(source_ref, '/') > 0
773
- OR INSTR(source_ref, '\\') > 0
774
- OR INSTR(source_ref, CHAR(0)) > 0
775
- OR source_ref GLOB '*[^-A-Za-z0-9._ ]*'
776
- )
777
- `);
778
- await this.db.withTransactionAsync(async () => {
1980
+ const rows = await this.entryRepo.findRowsForSourceRefMigration();
1981
+ await this.db.withTransactionAsync(async (tx) => {
779
1982
  for (const row of rows) {
780
1983
  const normalized = normalizeSourceRef(row.source_ref);
781
1984
  if (normalized !== row.source_ref) {
782
- await this.db.runAsync(
783
- `UPDATE ${this.prefix}entries SET source_ref = ? WHERE rowid = ?`,
784
- [normalized, row.rowid]
785
- );
1985
+ await this.entryRepo.updateSourceRefByRowid(row.rowid, normalized, tx);
786
1986
  }
787
1987
  }
788
1988
  });
@@ -797,15 +1997,9 @@ After running the migration SQL, restart your application.`
797
1997
  if (!normalizedHash) {
798
1998
  throw new Error(`Invalid sourceHash: must be a 64-character hex string (normalized to lowercase)`);
799
1999
  }
800
- const row = await this.db.getFirstAsync(
801
- `SELECT source_hash FROM ${this.prefix}entries
802
- WHERE entity_id = ? AND source_ref = ? AND deleted_at IS NULL
803
- ORDER BY updated_at DESC
804
- LIMIT 1`,
805
- [entityId, normalizedRef]
806
- );
807
- if (!row) return true;
808
- const normalizedStoredHash = row.source_hash ? normalizeSourceHash(row.source_hash) : null;
2000
+ const storedHash = await this.entryRepo.findLatestSourceHash(entityId, normalizedRef);
2001
+ if (storedHash === null) return true;
2002
+ const normalizedStoredHash = normalizeSourceHash(storedHash);
809
2003
  return normalizedStoredHash !== normalizedHash;
810
2004
  }
811
2005
  _pruneKey(entityId) {
@@ -916,11 +2110,7 @@ After running the migration SQL, restart your application.`
916
2110
  let deletedEvents = 0;
917
2111
  if (retainSoftDeletedFor !== null) {
918
2112
  const cutoff = now - retainSoftDeletedFor * 864e5;
919
- const entriesToDelete = await this.db.getAllAsync(
920
- `SELECT id, entity_id FROM ${this.prefix}entries
921
- WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at <= ?`,
922
- [entityId, cutoff]
923
- );
2113
+ const entriesToDelete = await this.entryRepo.getPrunableMetadata(entityId, cutoff);
924
2114
  const succeeded = [];
925
2115
  let failure = null;
926
2116
  for (const row of entriesToDelete) {
@@ -932,24 +2122,13 @@ After running the migration SQL, restart your application.`
932
2122
  break;
933
2123
  }
934
2124
  }
935
- if (succeeded.length > 0) {
936
- const chunkSize = 500;
937
- for (let i = 0; i < succeeded.length; i += chunkSize) {
938
- const chunk = succeeded.slice(i, i + chunkSize);
939
- const placeholders = chunk.map(() => "?").join(",");
940
- const entryResult = await this.db.runAsync(
941
- `DELETE FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at <= ? AND id IN (${placeholders})`,
942
- [entityId, cutoff, ...chunk.map((r) => r.id)]
943
- );
944
- deletedEntries += entryResult.changes;
2125
+ const succeededIds = succeeded.map((r) => r.id);
2126
+ await this.db.withTransactionAsync(async (tx) => {
2127
+ if (succeededIds.length > 0) {
2128
+ deletedEntries = await this.entryRepo.bulkDeletePruned(entityId, cutoff, succeededIds, tx);
945
2129
  }
946
- }
947
- const taskResult = await this.db.runAsync(
948
- `DELETE FROM ${this.prefix}tasks
949
- WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at <= ?`,
950
- [entityId, cutoff]
951
- );
952
- deletedTasks = taskResult.changes;
2130
+ deletedTasks = await this.taskRepo.bulkDeletePruned(entityId, cutoff, tx);
2131
+ });
953
2132
  if (failure) {
954
2133
  await this.rebuildMiniSearchIndex(entityId);
955
2134
  this.vectorCache.delete(entityId);
@@ -982,16 +2161,11 @@ After running the migration SQL, restart your application.`
982
2161
  }
983
2162
  if (retainEventsFor !== null) {
984
2163
  const cutoff = now - retainEventsFor * 864e5;
985
- const eventResult = await this.db.runAsync(
986
- `DELETE FROM ${this.prefix}events
987
- WHERE entity_id = ? AND created_at <= ?`,
988
- [entityId, cutoff]
989
- );
2164
+ const eventResult = await this.eventRepo.prune(entityId, cutoff);
990
2165
  deletedEvents = eventResult.changes;
991
2166
  }
992
2167
  if (vacuum) {
993
- await this.db.execAsync(`PRAGMA wal_checkpoint(TRUNCATE)`);
994
- await this.db.execAsync(`VACUUM`);
2168
+ await this.metadataRepo.vacuum();
995
2169
  }
996
2170
  await this.rebuildMiniSearchIndex(entityId);
997
2171
  this.vectorCache.delete(entityId);
@@ -1047,27 +2221,17 @@ After running the migration SQL, restart your application.`
1047
2221
  "embed() returned an empty or non-finite vector. Falling back to keyword search."
1048
2222
  );
1049
2223
  }
1050
- const storedDimRow = await this.db.getFirstAsync(
1051
- `SELECT value FROM ${this.prefix}meta WHERE key = 'embedding_dimension'`
1052
- );
1053
- if (storedDimRow) {
1054
- const storedDim = parseInt(storedDimRow.value, 10);
2224
+ const storedDimValue = await this.metadataRepo.getMeta("embedding_dimension");
2225
+ if (storedDimValue) {
2226
+ const storedDim = parseInt(storedDimValue, 10);
1055
2227
  if (storedDim !== queryVec.length) {
1056
2228
  throw new Error(
1057
2229
  `Embedding dimension mismatch: stored ${storedDim}, query has ${queryVec.length}. Call runReembed() to rebuild embeddings with the new model.`
1058
2230
  );
1059
2231
  }
1060
2232
  }
1061
- const mismatchScope = this._entityInClause(scoredEntityIds);
1062
- const mismatchedCount = await this.db.getFirstAsync(
1063
- `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
1064
- WHERE ${mismatchScope.clause} AND deleted_at IS NULL
1065
- AND embedding_blob IS NOT NULL
1066
- AND (CAST(length(embedding_blob) AS INTEGER) % 4 = 0)
1067
- AND (CAST(length(embedding_blob) AS INTEGER) / 4) != ?`,
1068
- [...mismatchScope.params, queryVec.length]
1069
- );
1070
- if (mismatchedCount && mismatchedCount.cnt > 0) {
2233
+ const mismatchedCount = await this.entryRepo.countDimensionMismatched(scoredEntityIds, queryVec.length);
2234
+ if (mismatchedCount > 0) {
1071
2235
  throw new Error(
1072
2236
  `Some facts have embeddings that do not match the current model dimension. Call runReembed() to rebuild all embeddings consistently.`
1073
2237
  );
@@ -1091,31 +2255,10 @@ After running the migration SQL, restart your application.`
1091
2255
  candidateRows = null;
1092
2256
  } else {
1093
2257
  const topKIds = topKResults.map((r) => r.id);
1094
- const inClauseChunkSize = 500;
1095
2258
  if (useRanker) {
1096
- const rows = [];
1097
- for (let i = 0; i < topKIds.length; i += inClauseChunkSize) {
1098
- const idChunk = topKIds.slice(i, i + inClauseChunkSize);
1099
- const placeholders = idChunk.map(() => "?").join(",");
1100
- const chunkRows = await this.db.getAllAsync(
1101
- `SELECT id, entity_id, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1102
- idChunk
1103
- );
1104
- rows.push(...chunkRows);
1105
- }
1106
- candidateRows = rows;
2259
+ candidateRows = await this.entryRepo.findMetadataByIds(topKIds);
1107
2260
  } else {
1108
- const rows = [];
1109
- for (let i = 0; i < topKIds.length; i += inClauseChunkSize) {
1110
- const idChunk = topKIds.slice(i, i + inClauseChunkSize);
1111
- const placeholders = idChunk.map(() => "?").join(",");
1112
- const chunkRows = await this.db.getAllAsync(
1113
- `SELECT id, entity_id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1114
- idChunk
1115
- );
1116
- rows.push(...chunkRows);
1117
- }
1118
- candidateRows = rows;
2261
+ candidateRows = await this.entryRepo.findWithEmbeddingsByIds(topKIds);
1119
2262
  }
1120
2263
  if (weight !== void 0 && weight < 1) {
1121
2264
  const maxMsScore = Math.max(1, topKResults[0]?.score ?? 1);
@@ -1125,17 +2268,9 @@ After running the migration SQL, restart your application.`
1125
2268
  }
1126
2269
  } else {
1127
2270
  if (useRanker) {
1128
- const entityScope = this._entityInClause(scoredEntityIds);
1129
- candidateRows = await this.db.getAllAsync(
1130
- `SELECT id, entity_id, updated_at, access_count FROM ${this.prefix}entries WHERE ${entityScope.clause} AND deleted_at IS NULL`,
1131
- entityScope.params
1132
- );
2271
+ candidateRows = await this.entryRepo.findMetadataByEntityIds(scoredEntityIds);
1133
2272
  } else {
1134
- const entityScope = this._entityInClause(scoredEntityIds);
1135
- candidateRows = await this.db.getAllAsync(
1136
- `SELECT id, entity_id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE ${entityScope.clause} AND deleted_at IS NULL`,
1137
- entityScope.params
1138
- );
2273
+ candidateRows = await this.entryRepo.findWithEmbeddingsByEntityIds(scoredEntityIds);
1139
2274
  }
1140
2275
  if (weight !== void 0 && weight < 1) {
1141
2276
  const entityIdSet = new Set(scoredEntityIds);
@@ -1298,19 +2433,8 @@ After running the migration SQL, restart your application.`
1298
2433
  let fallbackRows = candidateRows;
1299
2434
  if (fallbackRows && fallbackRows.length > 0 && !("embedding_blob" in fallbackRows[0])) {
1300
2435
  const rowIds = fallbackRows.map((r) => r.id);
1301
- const embeddingsMap = /* @__PURE__ */ new Map();
1302
- const chunkSize = 500;
1303
- for (let i = 0; i < rowIds.length; i += chunkSize) {
1304
- const idChunk = rowIds.slice(i, i + chunkSize);
1305
- const placeholders = idChunk.map(() => "?").join(",");
1306
- const embeddingRows = await this.db.getAllAsync(
1307
- `SELECT id, embedding_blob, embedding FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1308
- idChunk
1309
- );
1310
- for (const row of embeddingRows) {
1311
- embeddingsMap.set(row.id, { embedding_blob: row.embedding_blob, embedding: row.embedding });
1312
- }
1313
- }
2436
+ const embeddingRows = await this.entryRepo.findEmbeddingsByIds(rowIds);
2437
+ const embeddingsMap = new Map(embeddingRows.map((row) => [row.id, row]));
1314
2438
  fallbackRows = fallbackRows.map((r) => ({
1315
2439
  ...r,
1316
2440
  embedding_blob: embeddingsMap.get(r.id)?.embedding_blob ?? null,
@@ -1451,57 +2575,15 @@ After running the migration SQL, restart your application.`
1451
2575
  if (facts.length > 0) {
1452
2576
  const ids = facts.map((f) => f.id);
1453
2577
  const now = Date.now();
1454
- const accessChunkSize = 500;
1455
- for (let i = 0; i < ids.length; i += accessChunkSize) {
1456
- const idChunk = ids.slice(i, i + accessChunkSize);
1457
- const placeholders = idChunk.map(() => "?").join(",");
1458
- await this.db.runAsync(
1459
- `UPDATE ${this.prefix}entries
1460
- SET access_count = access_count + 1, last_accessed_at = ?
1461
- WHERE id IN (${placeholders})`,
1462
- [now, ...idChunk]
1463
- );
1464
- }
2578
+ await this.entryRepo.trackAccess(ids, now);
1465
2579
  }
1466
2580
  } else {
1467
- const entityScope = this._entityInClause(entityIds);
1468
- const rawFacts = await this.db.getAllAsync(
1469
- `SELECT * FROM ${this.prefix}entries
1470
- WHERE ${entityScope.clause} AND deleted_at IS NULL
1471
- ORDER BY updated_at DESC
1472
- LIMIT ?`,
1473
- [...entityScope.params, maxResults]
1474
- );
1475
- facts = rawFacts.map((f) => {
1476
- const { embedding: _embedding, embedding_blob: _blob, ...rest } = f;
1477
- return {
1478
- ...rest,
1479
- tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags
1480
- };
1481
- });
2581
+ facts = await this.entryRepo.findRecentByEntityIds(entityIds, maxResults);
1482
2582
  }
2583
+ const eventsLimit = Math.min(10 * entityIds.length, 100);
1483
2584
  const [tasks, events] = await Promise.all([
1484
- (async () => {
1485
- const entityScope = this._entityInClause(entityIds);
1486
- const tasksLimit = entityIds.length === 1 ? void 0 : Math.min(20 * entityIds.length, 200);
1487
- return this.db.getAllAsync(
1488
- `SELECT * FROM ${this.prefix}tasks
1489
- WHERE ${entityScope.clause} AND status IN ('pending', 'in_progress') AND deleted_at IS NULL
1490
- ORDER BY priority DESC, created_at ASC${tasksLimit !== void 0 ? "\n LIMIT ?" : ""}`,
1491
- tasksLimit !== void 0 ? [...entityScope.params, tasksLimit] : entityScope.params
1492
- );
1493
- })(),
1494
- (async () => {
1495
- const entityScope = this._entityInClause(entityIds);
1496
- const eventsLimit = Math.min(10 * entityIds.length, 100);
1497
- return this.db.getAllAsync(
1498
- `SELECT * FROM ${this.prefix}events
1499
- WHERE ${entityScope.clause}
1500
- ORDER BY created_at DESC
1501
- LIMIT ?`,
1502
- [...entityScope.params, eventsLimit]
1503
- );
1504
- })()
2585
+ this.taskRepo.findAllPending(entityIds, entityIds.length === 1 ? void 0 : Math.min(20 * entityIds.length, 200)),
2586
+ entityIds.length === 1 ? this.eventRepo.getRecent(entityIds[0], eventsLimit) : this.eventRepo.getRecentForEntities(entityIds, eventsLimit)
1505
2587
  ]);
1506
2588
  let factScores;
1507
2589
  if (exposeMetadata && trimmedQuery && scoreByFactId) {
@@ -1544,40 +2626,12 @@ After running the migration SQL, restart your application.`
1544
2626
  if (updatedAtDiff !== 0) return updatedAtDiff;
1545
2627
  return a.id.localeCompare(b.id);
1546
2628
  }
1547
- /**
1548
- * Build SQL IN clause with placeholders for multiple entity IDs.
1549
- */
1550
- _entityInClause(entityIds) {
1551
- if (entityIds.length === 0) return { clause: "1=0", params: [] };
1552
- const placeholders = entityIds.map(() => "?").join(",");
1553
- return { clause: `entity_id IN (${placeholders})`, params: [...entityIds] };
1554
- }
1555
2629
  /**
1556
2630
  * Hydrate full facts by ID. Pass scopedEntityIds to restrict to requested namespaces in SQL
1557
2631
  * (defense-in-depth against a rogue VectorRanker returning cross-entity IDs).
1558
2632
  */
1559
- async _hydrateFactsByIds(ids, scopedEntityIds) {
1560
- const fullRows = [];
1561
- const chunkSize = 500;
1562
- const entityClause = scopedEntityIds && scopedEntityIds.length > 0 ? ` AND entity_id IN (${scopedEntityIds.map(() => "?").join(",")})` : "";
1563
- const entityParams = scopedEntityIds && scopedEntityIds.length > 0 ? [...scopedEntityIds] : [];
1564
- for (let i = 0; i < ids.length; i += chunkSize) {
1565
- const idChunk = ids.slice(i, i + chunkSize);
1566
- const placeholders = idChunk.map(() => "?").join(",");
1567
- const chunkRows = await this.db.getAllAsync(
1568
- `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders})${entityClause} AND deleted_at IS NULL`,
1569
- [...idChunk, ...entityParams]
1570
- );
1571
- fullRows.push(...chunkRows);
1572
- }
1573
- const byId = new Map(fullRows.map((row) => [row.id, row]));
1574
- return ids.map((id) => byId.get(id)).filter((fact) => fact !== void 0).map((fact) => {
1575
- const { embedding: _embedding, embedding_blob: _blob, ...rest } = fact;
1576
- return {
1577
- ...rest,
1578
- tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags
1579
- };
1580
- });
2633
+ async _hydrateFactsByIds(ids, scopedEntityIds, tx) {
2634
+ return this.entryRepo.findByIds(ids, scopedEntityIds, tx);
1581
2635
  }
1582
2636
  /**
1583
2637
  * Strip potentially sensitive data from ranker errors before exposing to host callbacks.
@@ -1709,53 +2763,60 @@ After running the migration SQL, restart your application.`
1709
2763
  if (!["observation", "decision", "action", "outcome"].includes(eventType)) {
1710
2764
  eventType = "observation";
1711
2765
  }
1712
- await this.db.runAsync(`
1713
- INSERT INTO ${this.prefix}events (id, entity_id, event_type, summary, related_entry_id, created_at)
1714
- VALUES (?, ?, ?, ?, ?, ?)
1715
- `, [id, entityId, eventType, event.summary, event.related_entry_id || null, now]);
1716
- const threshold = this.options.config?.autoLibrarianThreshold || 20;
1717
- const [row, cp] = await Promise.all([
1718
- this.db.getFirstAsync(`SELECT COUNT(*) as count FROM ${this.prefix}events WHERE entity_id = ?`, [entityId]),
1719
- this.db.getFirstAsync(`SELECT * FROM ${this.prefix}checkpoints WHERE entity_id = ?`, [entityId])
1720
- ]);
1721
- const count = row?.count || 0;
1722
- let memoryCheckpoint = cp?.memory_checkpoint || 0;
1723
- if (memoryCheckpoint > count) memoryCheckpoint = 0;
1724
- if (count - memoryCheckpoint >= threshold) {
1725
- const jobKey = this._librarianKey(entityId);
1726
- if (!this.activeMaintenanceJobs.has(jobKey) && !this.activeMaintenanceJobs.has(this._pruneKey(entityId)) && !this._isReembedActive(entityId) && !this._isImportActiveFor(entityId) && !this._isForgetActiveFor(entityId)) {
1727
- this.activeMaintenanceJobs.add(jobKey);
1728
- this._notifyStatusSubscribers(entityId);
1729
- this.runLibrarianThenMaybeHeal(entityId, count).catch(console.error).finally(() => {
1730
- this.activeMaintenanceJobs.delete(jobKey);
1731
- this._notifyStatusSubscribers(entityId);
1732
- });
2766
+ const newEvent = {
2767
+ id,
2768
+ entity_id: entityId,
2769
+ event_type: eventType,
2770
+ summary: event.summary,
2771
+ related_entry_id: event.related_entry_id || null,
2772
+ created_at: now
2773
+ };
2774
+ let shouldRunLibrarian = false;
2775
+ let librarianCount = 0;
2776
+ let librarianJobKey = null;
2777
+ await this.db.withTransactionAsync(async (tx) => {
2778
+ await this.eventRepo.add(newEvent, tx);
2779
+ const threshold = this.options.config?.autoLibrarianThreshold || 20;
2780
+ const [count, cp] = await Promise.all([
2781
+ this.eventRepo.count(entityId, tx),
2782
+ this.metadataRepo.getCheckpoint(entityId, tx)
2783
+ ]);
2784
+ let memoryCheckpoint = cp.memory ?? 0;
2785
+ if (memoryCheckpoint > count) memoryCheckpoint = 0;
2786
+ if (count - memoryCheckpoint >= threshold) {
2787
+ const jobKey = this._librarianKey(entityId);
2788
+ if (!this.activeMaintenanceJobs.has(jobKey) && !this.activeMaintenanceJobs.has(this._pruneKey(entityId)) && !this._isReembedActive(entityId) && !this._isImportActiveFor(entityId) && !this._isForgetActiveFor(entityId)) {
2789
+ shouldRunLibrarian = true;
2790
+ librarianCount = count;
2791
+ librarianJobKey = jobKey;
2792
+ await this.metadataRepo.updateCheckpoint(entityId, { memory: count }, tx);
2793
+ }
1733
2794
  }
2795
+ });
2796
+ if (shouldRunLibrarian && librarianJobKey !== null) {
2797
+ this.activeMaintenanceJobs.add(librarianJobKey);
2798
+ this._notifyStatusSubscribers(entityId);
2799
+ this.runLibrarianThenMaybeHeal(entityId, librarianCount).catch(console.error).finally(() => {
2800
+ this.activeMaintenanceJobs.delete(librarianJobKey);
2801
+ this._notifyStatusSubscribers(entityId);
2802
+ });
1734
2803
  }
1735
2804
  }
1736
2805
  async runLibrarianThenMaybeHeal(entityId, currentEventCount) {
1737
2806
  await this._doRunLibrarian(entityId);
1738
- await this.db.runAsync(`
1739
- INSERT INTO ${this.prefix}checkpoints (entity_id, memory_checkpoint)
1740
- VALUES (?, ?)
1741
- ON CONFLICT(entity_id) DO UPDATE SET memory_checkpoint = ?
1742
- `, [entityId, currentEventCount, currentEventCount]);
1743
2807
  const autoHealThreshold = this.options.config?.autoHealThreshold || 100;
1744
- const cp = await this.db.getFirstAsync(`SELECT * FROM ${this.prefix}checkpoints WHERE entity_id = ?`, [entityId]);
1745
- let healCheckpoint = cp?.heal_checkpoint || 0;
2808
+ const cp = await this.metadataRepo.getCheckpoint(entityId, this.db);
2809
+ let healCheckpoint = cp.heal ?? 0;
1746
2810
  if (healCheckpoint > currentEventCount) healCheckpoint = 0;
1747
- if (currentEventCount - healCheckpoint >= autoHealThreshold) {
2811
+ const shouldRunHeal = currentEventCount - healCheckpoint >= autoHealThreshold;
2812
+ if (shouldRunHeal) {
1748
2813
  const healKey = this._healKey(entityId);
1749
2814
  if (!this.activeMaintenanceJobs.has(healKey)) {
1750
2815
  this.activeMaintenanceJobs.add(healKey);
1751
2816
  this._notifyStatusSubscribers(entityId);
1752
2817
  try {
1753
2818
  await this._doRunHeal(entityId);
1754
- await this.db.runAsync(`
1755
- INSERT INTO ${this.prefix}checkpoints (entity_id, heal_checkpoint)
1756
- VALUES (?, ?)
1757
- ON CONFLICT(entity_id) DO UPDATE SET heal_checkpoint = ?
1758
- `, [entityId, currentEventCount, currentEventCount]);
2819
+ await this.metadataRepo.updateCheckpoint(entityId, { heal: currentEventCount }, this.db);
1759
2820
  } finally {
1760
2821
  this.activeMaintenanceJobs.delete(healKey);
1761
2822
  this._notifyStatusSubscribers(entityId);
@@ -1764,18 +2825,8 @@ After running the migration SQL, restart your application.`
1764
2825
  }
1765
2826
  }
1766
2827
  async _doRunLibrarian(entityId) {
1767
- const events = await this.db.getAllAsync(`
1768
- SELECT * FROM ${this.prefix}events
1769
- WHERE entity_id = ?
1770
- ORDER BY created_at DESC
1771
- LIMIT 50
1772
- `, [entityId]);
1773
- const currentFactsRows = await this.db.getAllAsync(`
1774
- SELECT * FROM ${this.prefix}entries
1775
- WHERE entity_id = ? AND deleted_at IS NULL
1776
- ORDER BY updated_at DESC
1777
- LIMIT 100
1778
- `, [entityId]);
2828
+ const events = await this.eventRepo.getRecent(entityId, 50);
2829
+ const currentFactsRows = await this.entryRepo.findRecentByEntityId(entityId, 100);
1779
2830
  const currentFacts = currentFactsRows.map((f) => {
1780
2831
  const { embedding: _embedding, embedding_blob: _blob, ...rest } = f;
1781
2832
  return {
@@ -1799,12 +2850,13 @@ ${JSON.stringify(currentFacts, null, 2)}`;
1799
2850
  const validTasks = tasks.map(validateTask).filter((t) => t !== null);
1800
2851
  const now = Date.now();
1801
2852
  const insertedFacts = [];
1802
- await this.db.withTransactionAsync(async () => {
2853
+ await this.db.withTransactionAsync(async (tx) => {
2854
+ const factsForDedupe = await this.entryRepo.findRecentByEntityId(entityId, 100, tx);
1803
2855
  for (const fact of validFacts) {
1804
2856
  const newTokens = titleTokens(fact.title);
1805
2857
  let skip = false;
1806
2858
  if (newTokens.size >= MIN_TOKENS_TO_QUALIFY) {
1807
- for (const existing of currentFactsRows) {
2859
+ for (const existing of factsForDedupe) {
1808
2860
  if (existing.source_type !== "librarian_inferred") continue;
1809
2861
  const existingTokens = titleTokens(existing.title);
1810
2862
  if (existingTokens.size >= MIN_TOKENS_TO_QUALIFY) {
@@ -1817,18 +2869,29 @@ ${JSON.stringify(currentFacts, null, 2)}`;
1817
2869
  }
1818
2870
  if (skip) continue;
1819
2871
  const id = generateId("fact_");
1820
- await this.db.runAsync(`
1821
- INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, created_at, updated_at)
1822
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1823
- `, [id, entityId, fact.title, fact.body, JSON.stringify(fact.tags), fact.confidence, "librarian_inferred", now, now]);
2872
+ const factObj = {
2873
+ id,
2874
+ entity_id: entityId,
2875
+ title: fact.title,
2876
+ body: fact.body,
2877
+ tags: fact.tags,
2878
+ confidence: fact.confidence,
2879
+ source_type: "librarian_inferred",
2880
+ source_hash: null,
2881
+ source_ref: null,
2882
+ created_at: now,
2883
+ updated_at: now,
2884
+ last_accessed_at: null,
2885
+ access_count: 0,
2886
+ deleted_at: null
2887
+ };
2888
+ await this.entryRepo.upsert(factObj, tx);
1824
2889
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1825
2890
  }
1826
2891
  for (const task of validTasks) {
1827
2892
  const id = generateId("task_");
1828
- await this.db.runAsync(`
1829
- INSERT INTO ${this.prefix}tasks (id, entity_id, description, status, priority, created_at, updated_at)
1830
- VALUES (?, ?, ?, ?, ?, ?, ?)
1831
- `, [id, entityId, task.description, "pending", task.priority, now, now]);
2893
+ const taskObj = { id, entity_id: entityId, description: task.description, status: "pending", priority: task.priority, created_at: now, updated_at: now, resolved_at: null, deleted_at: null };
2894
+ await this.taskRepo.upsert(taskObj, tx);
1832
2895
  }
1833
2896
  });
1834
2897
  await this.rebuildMiniSearchIndex(entityId);
@@ -1849,27 +2912,19 @@ ${JSON.stringify(currentFacts, null, 2)}`;
1849
2912
  if (staleInferredAfterDays !== null && (typeof staleInferredAfterDays !== "number" || !Number.isFinite(staleInferredAfterDays) || staleInferredAfterDays < 0)) {
1850
2913
  throw new Error("Invalid staleInferredAfterDays: must be a finite number >= 0 or null");
1851
2914
  }
1852
- await this.db.withTransactionAsync(async () => {
2915
+ await this.db.withTransactionAsync(async (tx) => {
1853
2916
  if (orphanAfterDays !== null) {
1854
2917
  const orphanThreshold = now - orphanAfterDays * MS_PER_DAY;
1855
- await this.db.runAsync(`
1856
- UPDATE ${this.prefix}entries
1857
- SET deleted_at = ?, updated_at = ?
1858
- WHERE entity_id = ? AND access_count = 0 AND created_at <= ? AND source_type != 'immutable_document' AND deleted_at IS NULL
1859
- `, [now, now, entityId, orphanThreshold]);
2918
+ await this.entryRepo.markOrphaned(entityId, orphanThreshold, tx);
1860
2919
  }
1861
2920
  if (staleInferredAfterDays !== null) {
1862
2921
  const staleThreshold = now - staleInferredAfterDays * MS_PER_DAY;
1863
- await this.db.runAsync(`
1864
- UPDATE ${this.prefix}entries
1865
- SET confidence = 'tentative', updated_at = ?
1866
- WHERE entity_id = ? AND confidence = 'inferred' AND (last_accessed_at <= ? OR (last_accessed_at IS NULL AND created_at <= ?)) AND source_type != 'immutable_document' AND deleted_at IS NULL
1867
- `, [now, entityId, staleThreshold, staleThreshold]);
2922
+ await this.entryRepo.downgradeStaleInferred(entityId, staleThreshold, tx);
1868
2923
  }
1869
2924
  });
1870
- const allFactsRows = await this.db.getAllAsync(`SELECT * FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`, [entityId]);
1871
- const allTasks = await this.db.getAllAsync(`SELECT * FROM ${this.prefix}tasks WHERE entity_id = ? AND status IN ('pending', 'in_progress') AND deleted_at IS NULL`, [entityId]);
1872
- const recentEvents = await this.db.getAllAsync(`SELECT * FROM ${this.prefix}events WHERE entity_id = ? ORDER BY created_at DESC LIMIT 20`, [entityId]);
2925
+ const allFactsRows = await this.entryRepo.findAllByEntityId(entityId);
2926
+ const allTasks = await this.taskRepo.findAllPending([entityId]);
2927
+ const recentEvents = await this.eventRepo.getRecent(entityId, 20);
1873
2928
  const healCandidates = allFactsRows.filter((f) => f.source_type !== "immutable_document");
1874
2929
  const documentAnchors = allFactsRows.filter((f) => f.source_type === "immutable_document").map(({ id, title, source_ref }) => ({ id, title, source_ref }));
1875
2930
  const userPrompt = `Heal Candidates:
@@ -1902,19 +2957,28 @@ The following document anchors are provided for contradiction detection only. Do
1902
2957
  const validNewFacts = newFacts.map(validateFact).filter((f) => f !== null);
1903
2958
  const insertedFacts = [];
1904
2959
  const uniqueDeletedFactIds = Array.from(new Set(safeDeleted));
1905
- await this.db.withTransactionAsync(async () => {
1906
- for (const id of safeDowngraded) {
1907
- await this.db.runAsync(`UPDATE ${this.prefix}entries SET confidence = 'tentative', updated_at = ? WHERE id = ? AND entity_id = ?`, [now, id, entityId]);
1908
- }
1909
- for (const id of safeDeleted) {
1910
- await this.db.runAsync(`UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE id = ? AND entity_id = ?`, [now, now, id, entityId]);
1911
- }
2960
+ await this.db.withTransactionAsync(async (tx) => {
2961
+ await this.entryRepo.downgradeByIds(safeDowngraded, entityId, tx);
2962
+ await this.entryRepo.softDeleteByIds(safeDeleted, entityId, tx);
1912
2963
  for (const fact of validNewFacts) {
1913
2964
  const id = generateId("fact_");
1914
- await this.db.runAsync(`
1915
- INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, created_at, updated_at)
1916
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1917
- `, [id, entityId, fact.title, fact.body, JSON.stringify(fact.tags), fact.confidence, "librarian_inferred", now, now]);
2965
+ const factObj = {
2966
+ id,
2967
+ entity_id: entityId,
2968
+ title: fact.title,
2969
+ body: fact.body,
2970
+ tags: fact.tags,
2971
+ confidence: fact.confidence,
2972
+ source_type: "librarian_inferred",
2973
+ source_hash: null,
2974
+ source_ref: null,
2975
+ created_at: now,
2976
+ updated_at: now,
2977
+ last_accessed_at: null,
2978
+ access_count: 0,
2979
+ deleted_at: null
2980
+ };
2981
+ await this.entryRepo.upsert(factObj, tx);
1918
2982
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1919
2983
  }
1920
2984
  });
@@ -2038,12 +3102,7 @@ The following document anchors are provided for contradiction detection only. Do
2038
3102
  }
2039
3103
  this.activeMaintenanceJobs.add(reembedKey);
2040
3104
  try {
2041
- const where = entityId ? `entity_id = ? AND deleted_at IS NULL` : `deleted_at IS NULL`;
2042
- const params = entityId ? [entityId] : [];
2043
- const rows = await this.db.getAllAsync(
2044
- `SELECT * FROM ${this.prefix}entries WHERE ${where}`,
2045
- params
2046
- );
3105
+ const rows = await this.entryRepo.findAllForReembed(entityId);
2047
3106
  if (entityId) {
2048
3107
  this.vectorCache.delete(entityId);
2049
3108
  } else {
@@ -2052,22 +3111,12 @@ The following document anchors are provided for contradiction detection only. Do
2052
3111
  const skipExisting = opts?.skipExisting ?? false;
2053
3112
  let effectiveSkip = skipExisting;
2054
3113
  if (skipExisting) {
2055
- const mismatchRow = await this.db.getFirstAsync(
2056
- `SELECT value FROM ${this.prefix}meta WHERE key = 'embedding_dimension_mismatch'`
2057
- );
2058
- if (mismatchRow) {
3114
+ const mismatchValue = await this.metadataRepo.getMeta("embedding_dimension_mismatch");
3115
+ if (mismatchValue) {
2059
3116
  if (entityId) {
2060
- const mismatchDim = parseInt(mismatchRow.value, 10);
2061
- const staleForEntity = await this.db.getFirstAsync(
2062
- `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
2063
- WHERE entity_id = ? AND deleted_at IS NULL
2064
- AND (
2065
- embedding_blob IS NULL
2066
- OR (CAST(length(embedding_blob) AS INTEGER) / 4) != ?
2067
- )`,
2068
- [entityId, mismatchDim]
2069
- );
2070
- if (staleForEntity && staleForEntity.cnt > 0) effectiveSkip = false;
3117
+ const mismatchDim = parseInt(mismatchValue, 10);
3118
+ const staleCount = await this.entryRepo.countStaleForEntity(entityId, mismatchDim);
3119
+ if (staleCount > 0) effectiveSkip = false;
2071
3120
  } else {
2072
3121
  effectiveSkip = false;
2073
3122
  }
@@ -2159,19 +3208,10 @@ The following document anchors are provided for contradiction detection only. Do
2159
3208
  this.vectorCache.clear();
2160
3209
  }
2161
3210
  async _getFullBundle(entityId, opts) {
2162
- const maxEvents = opts?.maxEvents;
2163
- const eventsQuery = maxEvents != null ? `SELECT * FROM ${this.prefix}events WHERE entity_id = ? ORDER BY created_at DESC LIMIT ?` : `SELECT * FROM ${this.prefix}events WHERE entity_id = ? ORDER BY created_at ASC`;
2164
- const eventsParams = maxEvents != null ? [entityId, maxEvents] : [entityId];
2165
- const [factsRaw, tasks, eventsRaw] = await Promise.all([
2166
- this.db.getAllAsync(
2167
- `SELECT * FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC`,
2168
- [entityId]
2169
- ),
2170
- this.db.getAllAsync(
2171
- `SELECT * FROM ${this.prefix}tasks WHERE entity_id = ? AND deleted_at IS NULL ORDER BY priority DESC, created_at ASC`,
2172
- [entityId]
2173
- ),
2174
- this.db.getAllAsync(eventsQuery, eventsParams)
3211
+ const [factsRaw, tasks, events] = await Promise.all([
3212
+ this.entryRepo.findAllByEntityId(entityId),
3213
+ this.taskRepo.findAllByEntityId(entityId),
3214
+ this.eventRepo.getByEntityId(entityId, opts?.maxEvents)
2175
3215
  ]);
2176
3216
  const facts = factsRaw.map((f) => {
2177
3217
  const { embedding: _embedding, embedding_blob, ...rest } = f;
@@ -2186,7 +3226,6 @@ The following document anchors are provided for contradiction detection only. Do
2186
3226
  tags: typeof factBase.tags === "string" ? JSON.parse(factBase.tags) : factBase.tags
2187
3227
  };
2188
3228
  });
2189
- const events = maxEvents != null ? eventsRaw.slice().reverse() : eventsRaw;
2190
3229
  return { facts, tasks, events };
2191
3230
  }
2192
3231
  async exportDump(entityIds) {
@@ -2194,16 +3233,7 @@ The following document anchors are provided for contradiction detection only. Do
2194
3233
  if (entityIds && entityIds.length > 0) {
2195
3234
  ids = Array.from(new Set(entityIds));
2196
3235
  } else {
2197
- const rows = await this.db.getAllAsync(`
2198
- SELECT DISTINCT entity_id FROM (
2199
- SELECT entity_id FROM ${this.prefix}entries WHERE deleted_at IS NULL
2200
- UNION
2201
- SELECT entity_id FROM ${this.prefix}tasks WHERE deleted_at IS NULL
2202
- UNION
2203
- SELECT entity_id FROM ${this.prefix}events
2204
- ) ORDER BY entity_id
2205
- `);
2206
- ids = rows.map((r) => r.entity_id);
3236
+ ids = await this.metadataRepo.getDistinctEntityIds();
2207
3237
  }
2208
3238
  const entities = {};
2209
3239
  const BATCH = 3;
@@ -2269,48 +3299,26 @@ The following document anchors are provided for contradiction detection only. Do
2269
3299
  const factsWithPreservedBlob = /* @__PURE__ */ new Map();
2270
3300
  const preservedBlobDims = /* @__PURE__ */ new Set();
2271
3301
  const softDeletedFactIds = [];
2272
- await this.db.withTransactionAsync(async () => {
3302
+ await this.db.withTransactionAsync(async (tx) => {
2273
3303
  if (!merge) {
2274
- const toDelete = await this.db.getAllAsync(
2275
- `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
2276
- [entityId]
2277
- );
2278
- softDeletedFactIds.push(...toDelete.map((r) => r.id));
2279
- const now = Date.now();
2280
- await this.db.runAsync(
2281
- `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`,
2282
- [now, now, entityId]
2283
- );
2284
- await this.db.runAsync(
2285
- `UPDATE ${this.prefix}tasks SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`,
2286
- [now, now, entityId]
2287
- );
2288
- await this.db.runAsync(
2289
- `DELETE FROM ${this.prefix}checkpoints WHERE entity_id = ?`,
2290
- [entityId]
2291
- );
3304
+ const deletedLiveFactIds = await this.entryRepo.findIdsBySource(entityId, null, null, tx, false);
3305
+ softDeletedFactIds.push(...deletedLiveFactIds);
3306
+ await this.entryRepo.bulkSoftDeleteByEntityId(entityId, tx);
3307
+ await this.taskRepo.bulkSoftDeleteByEntityId(entityId, tx);
3308
+ await this.metadataRepo.deleteCheckpoint(entityId, tx);
2292
3309
  }
2293
3310
  const factIds = bundle.facts.map((fact) => fact.id);
2294
3311
  const existingFactsById = /* @__PURE__ */ new Map();
2295
- const factLookupChunkSize = 500;
2296
- for (let i = 0; i < factIds.length; i += factLookupChunkSize) {
2297
- const factIdChunk = factIds.slice(i, i + factLookupChunkSize);
2298
- if (factIdChunk.length === 0) continue;
2299
- const placeholders = factIdChunk.map(() => "?").join(", ");
2300
- const existingFacts = await this.db.getAllAsync(
2301
- `SELECT id, entity_id, updated_at FROM ${this.prefix}entries WHERE id IN (${placeholders})`,
2302
- factIdChunk
2303
- );
2304
- for (const existingFact of existingFacts) {
2305
- existingFactsById.set(existingFact.id, existingFact);
2306
- }
3312
+ const existingFacts = await this.entryRepo.findExistingMetadataByIds(factIds, tx);
3313
+ for (const existingFact of existingFacts) {
3314
+ existingFactsById.set(existingFact.id, existingFact);
2307
3315
  }
2308
3316
  for (const fact of bundle.facts) {
2309
3317
  const sourceType = this._normalizeImportedSourceType(String(fact.source_type), {
2310
3318
  entityId,
2311
3319
  factId: fact.id
2312
3320
  });
2313
- const tagsJson = JSON.stringify(Array.isArray(fact.tags) ? fact.tags : []);
3321
+ JSON.stringify(Array.isArray(fact.tags) ? fact.tags : []);
2314
3322
  const safeUpdatedAt = Number.isFinite(fact.updated_at) ? fact.updated_at : 0;
2315
3323
  const existing = existingFactsById.get(fact.id);
2316
3324
  const rawBlobRaw = fact.embedding_blob;
@@ -2355,55 +3363,38 @@ The following document anchors are provided for contradiction detection only. Do
2355
3363
  if (merge) {
2356
3364
  if (safeUpdatedAt <= existing.updated_at) continue;
2357
3365
  }
2358
- if (blobData != null) {
2359
- await this.db.runAsync(
2360
- `UPDATE ${this.prefix}entries SET entity_id = ?, title = ?, body = ?, tags = ?, confidence = ?, source_type = ?, source_hash = ?, source_ref = ?, created_at = ?, updated_at = ?, last_accessed_at = ?, access_count = ?, deleted_at = ?, embedding_blob = ?, embedding = NULL WHERE id = ?`,
2361
- [entityId, fact.title, fact.body, tagsJson, fact.confidence, sourceType, fact.source_hash, fact.source_ref, fact.created_at, safeUpdatedAt, fact.last_accessed_at, fact.access_count, fact.deleted_at, blobData, fact.id]
2362
- );
2363
- factsWithPreservedBlob.set(fact.id, blobData);
2364
- if (!fact.deleted_at) preservedBlobDims.add(blobData.byteLength / 4);
2365
- } else {
2366
- await this.db.runAsync(
2367
- `UPDATE ${this.prefix}entries SET entity_id = ?, title = ?, body = ?, tags = ?, confidence = ?, source_type = ?, source_hash = ?, source_ref = ?, created_at = ?, updated_at = ?, last_accessed_at = ?, access_count = ?, deleted_at = ?, embedding_blob = NULL, embedding = NULL WHERE id = ?`,
2368
- [entityId, fact.title, fact.body, tagsJson, fact.confidence, sourceType, fact.source_hash, fact.source_ref, fact.created_at, safeUpdatedAt, fact.last_accessed_at, fact.access_count, fact.deleted_at, fact.id]
2369
- );
2370
- }
2371
- existingFactsById.set(fact.id, { id: fact.id, entity_id: entityId, updated_at: safeUpdatedAt });
2372
- upsertedFactIds.add(fact.id);
2373
- if (fact.deleted_at) upsertedDeletedFactIds.add(fact.id);
2374
- } else {
2375
- if (blobData != null) {
2376
- await this.db.runAsync(
2377
- `INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, source_hash, source_ref, created_at, updated_at, last_accessed_at, access_count, deleted_at, embedding_blob) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2378
- [fact.id, entityId, fact.title, fact.body, tagsJson, fact.confidence, sourceType, fact.source_hash, fact.source_ref, fact.created_at, safeUpdatedAt, fact.last_accessed_at, fact.access_count, fact.deleted_at, blobData]
2379
- );
2380
- factsWithPreservedBlob.set(fact.id, blobData);
2381
- if (!fact.deleted_at) preservedBlobDims.add(blobData.byteLength / 4);
2382
- } else {
2383
- await this.db.runAsync(
2384
- `INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, source_hash, source_ref, created_at, updated_at, last_accessed_at, access_count, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2385
- [fact.id, entityId, fact.title, fact.body, tagsJson, fact.confidence, sourceType, fact.source_hash, fact.source_ref, fact.created_at, safeUpdatedAt, fact.last_accessed_at, fact.access_count, fact.deleted_at]
2386
- );
2387
- }
2388
- existingFactsById.set(fact.id, { id: fact.id, entity_id: entityId, updated_at: safeUpdatedAt });
2389
- upsertedFactIds.add(fact.id);
2390
- if (fact.deleted_at) upsertedDeletedFactIds.add(fact.id);
2391
3366
  }
3367
+ const factObj = {
3368
+ id: fact.id,
3369
+ entity_id: entityId,
3370
+ title: fact.title,
3371
+ body: fact.body,
3372
+ tags: Array.isArray(fact.tags) ? fact.tags : [],
3373
+ confidence: fact.confidence,
3374
+ source_type: sourceType,
3375
+ source_hash: fact.source_hash,
3376
+ source_ref: fact.source_ref,
3377
+ created_at: fact.created_at,
3378
+ updated_at: safeUpdatedAt,
3379
+ last_accessed_at: fact.last_accessed_at,
3380
+ access_count: fact.access_count,
3381
+ deleted_at: fact.deleted_at,
3382
+ embedding_blob: blobData ?? void 0
3383
+ };
3384
+ await this.entryRepo.upsertForImport(factObj, tx);
3385
+ if (blobData != null) {
3386
+ factsWithPreservedBlob.set(fact.id, blobData);
3387
+ if (!fact.deleted_at) preservedBlobDims.add(blobData.byteLength / 4);
3388
+ }
3389
+ existingFactsById.set(fact.id, { id: fact.id, entity_id: entityId, updated_at: safeUpdatedAt });
3390
+ upsertedFactIds.add(fact.id);
3391
+ if (fact.deleted_at) upsertedDeletedFactIds.add(fact.id);
2392
3392
  }
2393
3393
  const taskIds = bundle.tasks.map((task) => task.id);
2394
3394
  const existingTasksById = /* @__PURE__ */ new Map();
2395
- const taskLookupChunkSize = 500;
2396
- for (let i = 0; i < taskIds.length; i += taskLookupChunkSize) {
2397
- const taskIdChunk = taskIds.slice(i, i + taskLookupChunkSize);
2398
- if (taskIdChunk.length === 0) continue;
2399
- const placeholders = taskIdChunk.map(() => "?").join(", ");
2400
- const existingTasks = await this.db.getAllAsync(
2401
- `SELECT id, entity_id, updated_at FROM ${this.prefix}tasks WHERE id IN (${placeholders})`,
2402
- taskIdChunk
2403
- );
2404
- for (const existingTask of existingTasks) {
2405
- existingTasksById.set(existingTask.id, existingTask);
2406
- }
3395
+ const existingTasks = await this.taskRepo.findExistingMetadataByIds(taskIds, tx);
3396
+ for (const existingTask of existingTasks) {
3397
+ existingTasksById.set(existingTask.id, existingTask);
2407
3398
  }
2408
3399
  for (const task of bundle.tasks) {
2409
3400
  const safeUpdatedAt = Number.isFinite(task.updated_at) ? task.updated_at : 0;
@@ -2416,25 +3407,29 @@ The following document anchors are provided for contradiction detection only. Do
2416
3407
  if (merge) {
2417
3408
  if (safeUpdatedAt <= existing.updated_at) continue;
2418
3409
  }
2419
- await this.db.runAsync(
2420
- `UPDATE ${this.prefix}tasks SET entity_id = ?, description = ?, status = ?, priority = ?, created_at = ?, updated_at = ?, resolved_at = ?, deleted_at = ? WHERE id = ?`,
2421
- [entityId, task.description, task.status, task.priority, task.created_at, safeUpdatedAt, task.resolved_at, task.deleted_at, task.id]
2422
- );
2423
- existingTasksById.set(task.id, { id: task.id, entity_id: entityId, updated_at: safeUpdatedAt });
2424
- } else {
2425
- await this.db.runAsync(
2426
- `INSERT INTO ${this.prefix}tasks (id, entity_id, description, status, priority, created_at, updated_at, resolved_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2427
- [task.id, entityId, task.description, task.status, task.priority, task.created_at, safeUpdatedAt, task.resolved_at, task.deleted_at]
2428
- );
2429
- existingTasksById.set(task.id, { id: task.id, entity_id: entityId, updated_at: safeUpdatedAt });
2430
3410
  }
3411
+ await this.taskRepo.upsertForImport({
3412
+ id: task.id,
3413
+ entity_id: entityId,
3414
+ description: task.description,
3415
+ status: task.status,
3416
+ priority: task.priority,
3417
+ created_at: task.created_at,
3418
+ updated_at: safeUpdatedAt,
3419
+ resolved_at: task.resolved_at,
3420
+ deleted_at: task.deleted_at
3421
+ }, tx, safeUpdatedAt);
3422
+ existingTasksById.set(task.id, { id: task.id, entity_id: entityId, updated_at: safeUpdatedAt });
2431
3423
  }
2432
3424
  for (const event of bundle.events) {
2433
- await this.db.runAsync(
2434
- `INSERT OR IGNORE INTO ${this.prefix}events (id, entity_id, event_type, summary, related_entry_id, created_at)
2435
- VALUES (?, ?, ?, ?, ?, ?)`,
2436
- [event.id, entityId, event.event_type, event.summary, event.related_entry_id ?? null, event.created_at]
2437
- );
3425
+ await this.eventRepo.addIgnoreDuplicate({
3426
+ id: event.id,
3427
+ entity_id: entityId,
3428
+ event_type: event.event_type,
3429
+ summary: event.summary,
3430
+ related_entry_id: event.related_entry_id ?? null,
3431
+ created_at: event.created_at
3432
+ }, tx);
2438
3433
  }
2439
3434
  });
2440
3435
  this.vectorCache.delete(entityId);
@@ -2472,43 +3467,27 @@ The following document anchors are provided for contradiction detection only. Do
2472
3467
  }
2473
3468
  }
2474
3469
  try {
2475
- const canonicalRow = await this.db.getFirstAsync(
2476
- `SELECT value FROM ${this.prefix}meta WHERE key = 'embedding_dimension'`
2477
- );
2478
- const canonicalDim = canonicalRow ? parseInt(canonicalRow.value, 10) : null;
3470
+ const canonicalDimValue = await this.metadataRepo.getMeta("embedding_dimension");
3471
+ const canonicalDim = canonicalDimValue ? parseInt(canonicalDimValue, 10) : null;
2479
3472
  if (preservedBlobDims.size === 1) {
2480
3473
  const preservedDim = [...preservedBlobDims][0];
2481
3474
  if (canonicalDim === null || canonicalDim === preservedDim) {
2482
3475
  await this.storeEmbeddingDimension(preservedDim);
2483
- const staleMismatch = await this.db.getFirstAsync(
2484
- `SELECT value FROM ${this.prefix}meta WHERE key = 'embedding_dimension_mismatch'`
2485
- );
2486
- if (staleMismatch && parseInt(staleMismatch.value, 10) !== preservedDim) {
2487
- await this.db.runAsync(
2488
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('embedding_dimension_mismatch', ?)`,
2489
- [String(preservedDim)]
2490
- );
3476
+ const staleMismatchValue = await this.metadataRepo.getMeta("embedding_dimension_mismatch");
3477
+ if (staleMismatchValue && parseInt(staleMismatchValue, 10) !== preservedDim) {
3478
+ await this.metadataRepo.setMeta("embedding_dimension_mismatch", String(preservedDim), this.db);
2491
3479
  }
2492
3480
  await this._reconcileEmbeddingDimension();
2493
3481
  } else {
2494
- await this.db.runAsync(
2495
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('embedding_dimension_mismatch', ?)`,
2496
- [String(canonicalDim)]
2497
- );
3482
+ await this.metadataRepo.setMeta("embedding_dimension_mismatch", String(canonicalDim), this.db);
2498
3483
  }
2499
3484
  } else if (preservedBlobDims.size > 1) {
2500
3485
  if (canonicalDim === null) {
2501
3486
  const sortedPreservedBlobDims = [...preservedBlobDims].sort((a, b) => a - b);
2502
3487
  await this.storeEmbeddingDimension(sortedPreservedBlobDims[0]);
2503
- await this.db.runAsync(
2504
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('embedding_dimension_mismatch', ?)`,
2505
- [String(sortedPreservedBlobDims[0])]
2506
- );
3488
+ await this.metadataRepo.setMeta("embedding_dimension_mismatch", String(sortedPreservedBlobDims[0]), this.db);
2507
3489
  } else {
2508
- await this.db.runAsync(
2509
- `INSERT OR REPLACE INTO ${this.prefix}meta (key, value) VALUES ('embedding_dimension_mismatch', ?)`,
2510
- [String(canonicalDim)]
2511
- );
3490
+ await this.metadataRepo.setMeta("embedding_dimension_mismatch", String(canonicalDim), this.db);
2512
3491
  }
2513
3492
  }
2514
3493
  } finally {
@@ -2542,79 +3521,44 @@ The following document anchors are provided for contradiction detection only. Do
2542
3521
  let deletedEntries = 0;
2543
3522
  let deletedTasks = 0;
2544
3523
  const deletedEntryIds = [];
2545
- if (params.clearAll) {
2546
- const newDeletions = await this.db.getAllAsync(
2547
- `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
2548
- [entityId]
2549
- );
2550
- const alreadySoftDeleted = await this.db.getAllAsync(
2551
- `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NOT NULL`,
2552
- [entityId]
2553
- );
2554
- deletedEntryIds.push(...newDeletions.map((e) => e.id), ...alreadySoftDeleted.map((e) => e.id));
2555
- const [entriesRes, tasksRes] = await Promise.all([
2556
- this.db.runAsync(`UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`, [now, now, entityId]),
2557
- this.db.runAsync(`UPDATE ${this.prefix}tasks SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`, [now, now, entityId])
2558
- ]);
2559
- await this.db.runAsync(`UPDATE ${this.prefix}checkpoints SET memory_checkpoint = 0, heal_checkpoint = 0 WHERE entity_id = ?`, [entityId]);
2560
- deletedEntries = entriesRes.changes;
2561
- deletedTasks = tasksRes.changes;
2562
- } else {
2563
- const hasIdSelectors = params.entryId !== void 0 || params.taskId !== void 0;
2564
- const hasSourceSelectors = params.sourceRef !== void 0 || params.sourceHash !== void 0;
2565
- if (hasIdSelectors && hasSourceSelectors) {
2566
- throw new Error("forget() params are mutually exclusive: use entryId/taskId together, or sourceRef/sourceHash together, but not both in the same call");
2567
- }
2568
- const sourceRef = params.sourceRef !== void 0 ? normalizeSourceRef(params.sourceRef) : null;
2569
- if (params.sourceRef !== void 0 && !sourceRef) throw new Error("Invalid sourceRef");
2570
- const sourceHash = params.sourceHash !== void 0 ? normalizeSourceHash(params.sourceHash) : null;
2571
- if (params.sourceHash !== void 0 && !sourceHash) throw new Error("Invalid sourceHash (must be 64-char hex string)");
2572
- if (params.entryId) {
2573
- const entry = await this.db.getFirstAsync(
2574
- `SELECT id FROM ${this.prefix}entries WHERE id = ? AND entity_id = ?`,
2575
- [params.entryId, entityId]
2576
- );
2577
- if (entry) deletedEntryIds.push(entry.id);
2578
- }
2579
- if (sourceRef || sourceHash) {
2580
- let q = `SELECT id FROM ${this.prefix}entries WHERE entity_id = ?`;
2581
- const args = [entityId];
2582
- if (sourceRef) {
2583
- q += ` AND source_ref = ?`;
2584
- args.push(sourceRef);
3524
+ await this.db.withTransactionAsync(async (tx) => {
3525
+ if (params.clearAll) {
3526
+ deletedEntryIds.push(...await this.entryRepo.findIdsBySource(entityId, null, null, tx, true));
3527
+ const entriesRes = await this.entryRepo.bulkSoftDeleteByEntityId(entityId, tx);
3528
+ const tasksRes = await this.taskRepo.bulkSoftDeleteByEntityId(entityId, tx);
3529
+ await this.metadataRepo.updateCheckpoint(entityId, { memory: 0, heal: 0 }, tx);
3530
+ deletedEntries = entriesRes;
3531
+ deletedTasks = tasksRes;
3532
+ } else {
3533
+ const hasIdSelectors = params.entryId !== void 0 || params.taskId !== void 0;
3534
+ const hasSourceSelectors = params.sourceRef !== void 0 || params.sourceHash !== void 0;
3535
+ if (hasIdSelectors && hasSourceSelectors) {
3536
+ throw new Error("forget() params are mutually exclusive: use entryId/taskId together, or sourceRef/sourceHash together, but not both in the same call");
2585
3537
  }
2586
- if (sourceHash) {
2587
- q += ` AND source_hash = ?`;
2588
- args.push(sourceHash);
3538
+ const sourceRef = params.sourceRef !== void 0 ? normalizeSourceRef(params.sourceRef) : null;
3539
+ if (params.sourceRef !== void 0 && !sourceRef) throw new Error("Invalid sourceRef");
3540
+ const sourceHash = params.sourceHash !== void 0 ? normalizeSourceHash(params.sourceHash) : null;
3541
+ if (params.sourceHash !== void 0 && !sourceHash) throw new Error("Invalid sourceHash (must be 64-char hex string)");
3542
+ if (params.entryId) {
3543
+ const entryId = await this.entryRepo.findIdById(params.entryId, entityId, tx);
3544
+ if (entryId) deletedEntryIds.push(entryId);
2589
3545
  }
2590
- const entriesToDelete = await this.db.getAllAsync(q, args);
2591
- deletedEntryIds.push(...entriesToDelete.map((e) => e.id));
2592
- }
2593
- const entryPromise = params.entryId ? this.db.runAsync(`UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE id = ? AND entity_id = ? AND deleted_at IS NULL`, [now, now, params.entryId, entityId]) : null;
2594
- const taskPromise = params.taskId ? this.db.runAsync(`UPDATE ${this.prefix}tasks SET deleted_at = ?, updated_at = ? WHERE id = ? AND entity_id = ? AND deleted_at IS NULL`, [now, now, params.taskId, entityId]) : null;
2595
- let refPromise = null;
2596
- if (sourceRef || sourceHash) {
2597
- let q = `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`;
2598
- const args = [now, now, entityId];
2599
- if (sourceRef) {
2600
- q += ` AND source_ref = ?`;
2601
- args.push(sourceRef);
3546
+ if (sourceRef || sourceHash) {
3547
+ deletedEntryIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, sourceHash, tx, true));
2602
3548
  }
2603
- if (sourceHash) {
2604
- q += ` AND source_hash = ?`;
2605
- args.push(sourceHash);
2606
- }
2607
- refPromise = this.db.runAsync(q, args);
3549
+ const entryPromise = params.entryId ? this.entryRepo.softDelete(params.entryId, entityId, tx).then((r) => r.changes > 0) : null;
3550
+ const taskDeletedPromise = params.taskId ? this.taskRepo.softDeleteById(params.taskId, entityId, tx).then((r) => r.changes > 0) : null;
3551
+ const refPromise = sourceRef || sourceHash ? this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, sourceHash) : null;
3552
+ const [entryResult, taskResult, refResult] = await Promise.all([
3553
+ entryPromise ?? Promise.resolve(false),
3554
+ taskDeletedPromise ?? Promise.resolve(false),
3555
+ refPromise ?? Promise.resolve(0)
3556
+ ]);
3557
+ if (entryResult) deletedEntries++;
3558
+ if (taskResult) deletedTasks++;
3559
+ deletedEntries += refResult;
2608
3560
  }
2609
- const [entryResult, taskResult, refResult] = await Promise.all([
2610
- entryPromise ?? Promise.resolve(null),
2611
- taskPromise ?? Promise.resolve(null),
2612
- refPromise ?? Promise.resolve(null)
2613
- ]);
2614
- if (entryResult) deletedEntries += entryResult.changes;
2615
- if (taskResult) deletedTasks += taskResult.changes;
2616
- if (refResult) deletedEntries += refResult.changes;
2617
- }
3561
+ });
2618
3562
  await this.rebuildMiniSearchIndex(entityId);
2619
3563
  this.vectorCache.delete(entityId);
2620
3564
  const uniqueDeletedIds = Array.from(new Set(deletedEntryIds));
@@ -2713,25 +3657,28 @@ ${chunk}`;
2713
3657
  const now = Date.now();
2714
3658
  const insertedFacts = [];
2715
3659
  const deletedSourceFactIds = [];
2716
- await this.db.withTransactionAsync(async () => {
2717
- const existingSourceFacts = await this.db.getAllAsync(
2718
- `SELECT id FROM ${this.prefix}entries WHERE source_ref = ? AND entity_id = ? AND deleted_at IS NULL`,
2719
- [sourceRef, entityId]
2720
- );
2721
- for (const row of existingSourceFacts) {
2722
- deletedSourceFactIds.push(row.id);
2723
- }
2724
- await this.db.runAsync(
2725
- `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE source_ref = ? AND entity_id = ? AND deleted_at IS NULL`,
2726
- [now, now, sourceRef, entityId]
2727
- );
3660
+ await this.db.withTransactionAsync(async (tx) => {
3661
+ deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
3662
+ await this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, null);
2728
3663
  for (const fact of allValidFacts) {
2729
3664
  const id = generateId("fact_");
2730
- await this.db.runAsync(
2731
- `INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, source_hash, source_ref, created_at, updated_at)
2732
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2733
- [id, entityId, fact.title, fact.body, JSON.stringify(fact.tags), fact.confidence, "immutable_document", sourceHash, sourceRef, now, now]
2734
- );
3665
+ const wikiFact = {
3666
+ id,
3667
+ entity_id: entityId,
3668
+ title: fact.title,
3669
+ body: fact.body,
3670
+ tags: fact.tags,
3671
+ confidence: fact.confidence,
3672
+ source_type: "immutable_document",
3673
+ source_hash: sourceHash,
3674
+ source_ref: sourceRef,
3675
+ created_at: now,
3676
+ updated_at: now,
3677
+ last_accessed_at: null,
3678
+ access_count: 0,
3679
+ deleted_at: null
3680
+ };
3681
+ await this.entryRepo.upsert(wikiFact, tx);
2735
3682
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
2736
3683
  }
2737
3684
  });