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