@equationalapplications/core-llm-wiki 4.23.0 → 5.0.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.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { M as MemoryBundle, F as FormatContextOptions, G as GraphNeighborhood, a as MemoryDump, b as FormattedMemoryDump, O as OntologyManifest, R as ReadOptions, S as SQLiteAdapter, W as WikiOptions, c as WikiMemory } from './testing-CBjAuTSl.mjs';
2
- export { E as EntityStatus, d as ExtractedFact, e as ExtractedFactEdge, f as ExtractedFactWithOntology, g as ExtractedTask, h as GraphTraversalOptions, H as HOOK_TIMEOUT_MARKER, L as LLMProvider, i as ONTOLOGY_BACKFILL_BATCH_SIZE, j as ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, k as ONTOLOGY_BACKFILL_RECHECK_MS, l as OntologyBackfillResult, m as OntologyConfig, n as OntologyEdgeType, o as OntologyMode, p as OntologyNodeType, q as OntologyPromptContext, r as OntologyUpdates, P as PromptOverrides, s as PromptService, t as PrunePartialFailureError, V as VectorRanker, u as VectorRankerFallback, v as VectorRankerRankArgs, w as VectorRankerSemanticResult, x as WikiBusyError, y as WikiBusyOperation, z as WikiCheckpoint, A as WikiConfig, B as WikiEdge, C as WikiEvent, D as WikiFact, I as WikiMemoryTestAccess, J as WikiOutboxEvent, K as WikiTask, N as WikiTransactionError } from './testing-CBjAuTSl.mjs';
1
+ import { M as MemoryBundle, F as FormatContextOptions, G as GraphNeighborhood, a as MemoryDump, b as FormattedMemoryDump, O as OntologyManifest, R as ReadOptions, S as SQLiteAdapter, W as WikiOptions, c as WikiMemory } from './testing-DCRNie7k.mjs';
2
+ export { E as EntityStatus, d as ExtractedFact, e as ExtractedFactEdge, f as ExtractedFactWithOntology, g as ExtractedTask, h as GraphTraversalOptions, H as HEAL_BATCH_SIZE, i as HEAL_RECHECK_MS, j as HOOK_TIMEOUT_MARKER, k as HealResult, L as LLMProvider, l as ONTOLOGY_BACKFILL_BATCH_SIZE, m as ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, n as ONTOLOGY_BACKFILL_RECHECK_MS, o as OntologyBackfillResult, p as OntologyConfig, q as OntologyEdgeType, r as OntologyMode, s as OntologyNodeType, t as OntologyPromptContext, u as OntologyUpdates, P as PromptOverrides, v as PromptService, w as PrunePartialFailureError, V as VectorRanker, x as VectorRankerFallback, y as VectorRankerRankArgs, z as VectorRankerSemanticResult, A as WikiBusyError, B as WikiBusyOperation, C as WikiCheckpoint, D as WikiConfig, I as WikiEdge, J as WikiEvent, K as WikiFact, N as WikiMemoryTestAccess, Q as WikiOutboxEvent, T as WikiTask, U as WikiTransactionError } from './testing-DCRNie7k.mjs';
3
3
  import { OkfFile } from '@equationalapplications/core-okf';
4
4
  import 'minisearch';
5
5
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { M as MemoryBundle, F as FormatContextOptions, G as GraphNeighborhood, a as MemoryDump, b as FormattedMemoryDump, O as OntologyManifest, R as ReadOptions, S as SQLiteAdapter, W as WikiOptions, c as WikiMemory } from './testing-CBjAuTSl.js';
2
- export { E as EntityStatus, d as ExtractedFact, e as ExtractedFactEdge, f as ExtractedFactWithOntology, g as ExtractedTask, h as GraphTraversalOptions, H as HOOK_TIMEOUT_MARKER, L as LLMProvider, i as ONTOLOGY_BACKFILL_BATCH_SIZE, j as ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, k as ONTOLOGY_BACKFILL_RECHECK_MS, l as OntologyBackfillResult, m as OntologyConfig, n as OntologyEdgeType, o as OntologyMode, p as OntologyNodeType, q as OntologyPromptContext, r as OntologyUpdates, P as PromptOverrides, s as PromptService, t as PrunePartialFailureError, V as VectorRanker, u as VectorRankerFallback, v as VectorRankerRankArgs, w as VectorRankerSemanticResult, x as WikiBusyError, y as WikiBusyOperation, z as WikiCheckpoint, A as WikiConfig, B as WikiEdge, C as WikiEvent, D as WikiFact, I as WikiMemoryTestAccess, J as WikiOutboxEvent, K as WikiTask, N as WikiTransactionError } from './testing-CBjAuTSl.js';
1
+ import { M as MemoryBundle, F as FormatContextOptions, G as GraphNeighborhood, a as MemoryDump, b as FormattedMemoryDump, O as OntologyManifest, R as ReadOptions, S as SQLiteAdapter, W as WikiOptions, c as WikiMemory } from './testing-DCRNie7k.js';
2
+ export { E as EntityStatus, d as ExtractedFact, e as ExtractedFactEdge, f as ExtractedFactWithOntology, g as ExtractedTask, h as GraphTraversalOptions, H as HEAL_BATCH_SIZE, i as HEAL_RECHECK_MS, j as HOOK_TIMEOUT_MARKER, k as HealResult, L as LLMProvider, l as ONTOLOGY_BACKFILL_BATCH_SIZE, m as ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, n as ONTOLOGY_BACKFILL_RECHECK_MS, o as OntologyBackfillResult, p as OntologyConfig, q as OntologyEdgeType, r as OntologyMode, s as OntologyNodeType, t as OntologyPromptContext, u as OntologyUpdates, P as PromptOverrides, v as PromptService, w as PrunePartialFailureError, V as VectorRanker, x as VectorRankerFallback, y as VectorRankerRankArgs, z as VectorRankerSemanticResult, A as WikiBusyError, B as WikiBusyOperation, C as WikiCheckpoint, D as WikiConfig, I as WikiEdge, J as WikiEvent, K as WikiFact, N as WikiMemoryTestAccess, Q as WikiOutboxEvent, T as WikiTask, U as WikiTransactionError } from './testing-DCRNie7k.js';
3
3
  import { OkfFile } from '@equationalapplications/core-okf';
4
4
  import 'minisearch';
5
5
 
package/dist/index.js CHANGED
@@ -36,7 +36,8 @@ async function setupDatabase(db, prefix) {
36
36
  embedding TEXT,
37
37
  embedding_blob BLOB,
38
38
  okf_type TEXT,
39
- ontology_checked_at INTEGER
39
+ ontology_checked_at INTEGER,
40
+ heal_checked_at INTEGER
40
41
  );
41
42
 
42
43
  CREATE INDEX IF NOT EXISTS ${prefix}entries_entity_idx ON ${prefix}entries(entity_id);
@@ -353,6 +354,20 @@ var MIGRATIONS = [
353
354
  );
354
355
  }
355
356
  }
357
+ },
358
+ {
359
+ version: 8,
360
+ description: "Add heal_checked_at to entries for heal recheck cooldown",
361
+ run: async (db, prefix) => {
362
+ const cols = await db.getAllAsync(
363
+ `PRAGMA table_info(${prefix}entries)`
364
+ );
365
+ if (!cols.some((c) => c.name === "heal_checked_at")) {
366
+ await db.execAsync(
367
+ `ALTER TABLE ${prefix}entries ADD COLUMN heal_checked_at INTEGER`
368
+ );
369
+ }
370
+ }
356
371
  }
357
372
  ];
358
373
  for (let i = 1; i < MIGRATIONS.length; i++) {
@@ -709,17 +724,21 @@ var EntryRepository = class extends BaseRepository {
709
724
  }
710
725
  /**
711
726
  * Fetch live, mutable entries for an entity — everything heal is allowed to
712
- * downgrade or delete. Heal previously loaded every row via
713
- * findAllByEntityId and filtered in JS, which on a document-heavy corpus
714
- * meant loading 2560 rows to keep 31.
727
+ * downgrade or delete — oldest first, capped at `limit`, skipping anything
728
+ * stamped inside the recheck cooldown (heal_checked_at > recheckCutoff).
729
+ *
730
+ * Oldest-first rather than newest-first: with a cooldown in place, newest-first
731
+ * would keep re-selecting recently-touched facts every pass while older ones
732
+ * wait indefinitely to even enter a batch.
715
733
  */
716
- async findHealCandidatesByEntityId(entityId, tx) {
734
+ async findHealCandidatesByEntityId(entityId, limit, recheckCutoff, tx) {
717
735
  const executor = this.getExecutor(tx);
718
736
  const rows = await executor.getAllAsync(
719
737
  `SELECT * FROM ${this.prefix}entries
720
738
  WHERE entity_id = ? AND deleted_at IS NULL AND source_type != 'immutable_document'
721
- ORDER BY updated_at DESC`,
722
- [entityId]
739
+ AND (heal_checked_at IS NULL OR heal_checked_at <= ?)
740
+ ORDER BY updated_at ASC LIMIT ?`,
741
+ [entityId, recheckCutoff, limit]
723
742
  );
724
743
  return rows.map(mapRowToFact);
725
744
  }
@@ -882,6 +901,10 @@ var EntryRepository = class extends BaseRepository {
882
901
  /**
883
902
  * Downgrade stale inferred entries to 'tentative'.
884
903
  * Used by MaintenanceService.doRunHeal().
904
+ *
905
+ * Returns the ids it downgraded so the caller can fold them into
906
+ * {@link HealResult.downgraded} without double-counting a fact the model also
907
+ * downgraded in the same pass.
885
908
  */
886
909
  async downgradeStaleInferred(entityId, staleThreshold, tx) {
887
910
  const executor = this.getExecutor(tx);
@@ -893,8 +916,8 @@ var EntryRepository = class extends BaseRepository {
893
916
  AND source_type != 'immutable_document' AND deleted_at IS NULL`,
894
917
  [entityId, staleThreshold, staleThreshold]
895
918
  );
896
- if (eligibleRows.length === 0) return 0;
897
- const result = await executor.runAsync(
919
+ if (eligibleRows.length === 0) return [];
920
+ await executor.runAsync(
898
921
  `UPDATE ${this.prefix}entries
899
922
  SET confidence = 'tentative', updated_at = ?
900
923
  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`,
@@ -909,7 +932,7 @@ var EntryRepository = class extends BaseRepository {
909
932
  payload: { id: row.id, entity_id: entityId, confidence: "tentative", updated_at: now }
910
933
  }, tx);
911
934
  }
912
- return result.changes;
935
+ return eligibleRows.map((r) => r.id);
913
936
  }
914
937
  /**
915
938
  * Downgrade specific entries to 'tentative' by IDs.
@@ -1228,6 +1251,58 @@ var EntryRepository = class extends BaseRepository {
1228
1251
  );
1229
1252
  return rows.map((r) => ({ id: r.id, title: r.title, okf_type: r.okf_type ?? null }));
1230
1253
  }
1254
+ /** Counts live mutable facts: eligible (past cooldown) vs deferred (in cooldown). */
1255
+ async countHealCandidatesByEntityId(entityId, recheckCutoff, tx) {
1256
+ const executor = this.getExecutor(tx);
1257
+ const row = await executor.getFirstAsync(
1258
+ `SELECT
1259
+ SUM(CASE WHEN heal_checked_at IS NULL OR heal_checked_at <= ? THEN 1 ELSE 0 END) AS eligible,
1260
+ SUM(CASE WHEN heal_checked_at IS NOT NULL AND heal_checked_at > ? THEN 1 ELSE 0 END) AS deferred
1261
+ FROM ${this.prefix}entries
1262
+ WHERE entity_id = ? AND deleted_at IS NULL AND source_type != 'immutable_document'`,
1263
+ [recheckCutoff, recheckCutoff, entityId]
1264
+ );
1265
+ return { eligible: Number(row?.eligible ?? 0), deferred: Number(row?.deferred ?? 0) };
1266
+ }
1267
+ /**
1268
+ * Stamps the heal recheck cooldown. NEVER touches updated_at — import merge
1269
+ * resolution is last-write-wins on updated_at and a bump here would make an
1270
+ * unchanged local fact beat a genuinely newer remote edit.
1271
+ *
1272
+ * Soft-deleted rows are skipped: a candidate heal deleted earlier in the same
1273
+ * transaction is no longer a candidate under any future pass, so its cooldown
1274
+ * value is irrelevant. This means the stamped count can be lower than the
1275
+ * offered-candidate count.
1276
+ */
1277
+ async markHealChecked(ids, entityId, now, tx) {
1278
+ if (ids.length === 0) return;
1279
+ for (let i = 0; i < ids.length; i += this.chunkSize) {
1280
+ const chunk = ids.slice(i, i + this.chunkSize);
1281
+ const placeholders = chunk.map(() => "?").join(",");
1282
+ await tx.runAsync(
1283
+ `UPDATE ${this.prefix}entries SET heal_checked_at = ?
1284
+ WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1285
+ [now, ...chunk, entityId]
1286
+ );
1287
+ }
1288
+ }
1289
+ /**
1290
+ * Lightweight full-breadth index (id, title) over all live librarian_inferred
1291
+ * facts. This is heal's fuzzy-dedupe corpus, deliberately kept independent of
1292
+ * the bounded candidate window: seeding dedupe from a batchSize-limited read
1293
+ * would let a synthesized fact duplicating a fact outside the window pass the
1294
+ * Jaccard check, and a convergence loop would multiply those duplicates across
1295
+ * passes. Mirrors findTitleIndexByEntityId — full breadth, two columns, cheap.
1296
+ */
1297
+ async findInferredTitlesByEntityId(entityId, tx) {
1298
+ const executor = this.getExecutor(tx);
1299
+ const rows = await executor.getAllAsync(
1300
+ `SELECT id, title FROM ${this.prefix}entries
1301
+ WHERE entity_id = ? AND deleted_at IS NULL AND source_type = 'librarian_inferred'`,
1302
+ [entityId]
1303
+ );
1304
+ return rows.map((r) => ({ id: r.id, title: r.title }));
1305
+ }
1231
1306
  };
1232
1307
 
1233
1308
  // src/utils/ids.ts
@@ -3252,6 +3327,8 @@ var ONTOLOGY_BACKFILL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
3252
3327
  var HEAL_MAX_ANCHORS = 50;
3253
3328
  var HEAL_ANCHOR_SEARCH_OVERFETCH = 4;
3254
3329
  var HEAL_MAX_PROMPT_CHARS = 4e4;
3330
+ var HEAL_BATCH_SIZE = 25;
3331
+ var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
3255
3332
  var MaintenanceService = class {
3256
3333
  constructor(db, prefix, options, entryRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
3257
3334
  this.db = db;
@@ -3352,7 +3429,7 @@ var MaintenanceService = class {
3352
3429
  async runHeal(entityId, options) {
3353
3430
  this.jobManager.acquireLock("heal", entityId);
3354
3431
  try {
3355
- await this.doRunHeal(entityId, options?.promptOverride);
3432
+ return await this.doRunHeal(entityId, options);
3356
3433
  } finally {
3357
3434
  this.jobManager.releaseLock("heal", entityId);
3358
3435
  }
@@ -3604,9 +3681,21 @@ var MaintenanceService = class {
3604
3681
  }
3605
3682
  this.searchService.evictCache(entityId);
3606
3683
  }
3607
- /** Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook. */
3608
- async doRunHeal(entityId, promptOverride) {
3684
+ /**
3685
+ * Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook.
3686
+ *
3687
+ * Bounded: at most `batchSize` candidates per pass (#67). Loop on
3688
+ * `result.remaining > 0` for convergence — see {@link HealResult.remaining}
3689
+ * for what convergence means here.
3690
+ */
3691
+ async doRunHeal(entityId, options) {
3692
+ const promptOverride = options?.promptOverride;
3693
+ const batchSize = options?.batchSize ?? HEAL_BATCH_SIZE;
3694
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
3695
+ throw new Error("Invalid batchSize: must be an integer >= 1");
3696
+ }
3609
3697
  const now = Date.now();
3698
+ const recheckCutoff = now - HEAL_RECHECK_MS;
3610
3699
  const orphanAfterDays = this.options.config?.orphanAfterDays !== void 0 ? this.options.config?.orphanAfterDays : 30;
3611
3700
  const staleInferredAfterDays = this.options.config?.staleInferredAfterDays !== void 0 ? this.options.config?.staleInferredAfterDays : 60;
3612
3701
  const MS_PER_DAY = 24 * 60 * 60 * 1e3;
@@ -3617,6 +3706,7 @@ var MaintenanceService = class {
3617
3706
  throw new Error("Invalid staleInferredAfterDays: must be a finite number >= 0 or null");
3618
3707
  }
3619
3708
  const orphanedIds = [];
3709
+ const staleDowngradedIds = [];
3620
3710
  await this.db.withTransactionAsync(async (tx) => {
3621
3711
  if (orphanAfterDays !== null) {
3622
3712
  const orphanThreshold = now - orphanAfterDays * MS_PER_DAY;
@@ -3624,7 +3714,7 @@ var MaintenanceService = class {
3624
3714
  }
3625
3715
  if (staleInferredAfterDays !== null) {
3626
3716
  const staleThreshold = now - staleInferredAfterDays * MS_PER_DAY;
3627
- await this.entryRepo.downgradeStaleInferred(entityId, staleThreshold, tx);
3717
+ staleDowngradedIds.push(...await this.entryRepo.downgradeStaleInferred(entityId, staleThreshold, tx));
3628
3718
  }
3629
3719
  });
3630
3720
  for (const factId of orphanedIds) {
@@ -3634,7 +3724,21 @@ var MaintenanceService = class {
3634
3724
  console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during heal orphan pass for ${factId}:`, hookErr);
3635
3725
  }
3636
3726
  }
3637
- const healCandidates = await this.entryRepo.findHealCandidatesByEntityId(entityId);
3727
+ const healCandidates = await this.entryRepo.findHealCandidatesByEntityId(entityId, batchSize, recheckCutoff);
3728
+ if (healCandidates.length === 0) {
3729
+ await this.searchService.sync(entityId);
3730
+ this.searchService.evictCache(entityId);
3731
+ const counts2 = await this.entryRepo.countHealCandidatesByEntityId(entityId, recheckCutoff);
3732
+ return {
3733
+ scanned: 0,
3734
+ downgraded: staleDowngradedIds.length,
3735
+ deleted: orphanedIds.length,
3736
+ newFactsCreated: 0,
3737
+ skipped: 0,
3738
+ remaining: counts2.eligible,
3739
+ deferred: counts2.deferred
3740
+ };
3741
+ }
3638
3742
  const allTasks = await this.taskRepo.findAllPending([entityId]);
3639
3743
  const recentEvents = await this.eventRepo.getRecent(entityId, 20);
3640
3744
  const toPromptShape = (f) => {
@@ -3687,7 +3791,7 @@ var MaintenanceService = class {
3687
3791
  const validNewFacts = newFacts.map(validateFact).filter((f) => f !== null);
3688
3792
  const insertedFacts = [];
3689
3793
  const uniqueDeletedFactIds = Array.from(new Set(safeDeleted));
3690
- const healFactsForDedupe = [...healCandidates];
3794
+ const healFactsForDedupe = (await this.entryRepo.findInferredTitlesByEntityId(entityId)).filter((f) => !safeDeletedSet.has(f.id));
3691
3795
  await this.db.withTransactionAsync(async (tx) => {
3692
3796
  await this.entryRepo.downgradeByIds(safeDowngraded, entityId, tx);
3693
3797
  await this.entryRepo.softDeleteByIds(safeDeleted, entityId, tx);
@@ -3696,7 +3800,6 @@ var MaintenanceService = class {
3696
3800
  let skip = false;
3697
3801
  if (newTokens.size >= MIN_TOKENS_TO_QUALIFY) {
3698
3802
  for (const existing of healFactsForDedupe) {
3699
- if (existing.source_type !== "librarian_inferred") continue;
3700
3803
  const existingTokens = titleTokens(existing.title);
3701
3804
  if (existingTokens.size >= MIN_TOKENS_TO_QUALIFY) {
3702
3805
  if (jaccardScore(newTokens, existingTokens) >= FUZZY_THRESHOLD) {
@@ -3726,8 +3829,14 @@ var MaintenanceService = class {
3726
3829
  };
3727
3830
  await this.entryRepo.upsert(factObj, tx);
3728
3831
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
3729
- healFactsForDedupe.push(factObj);
3832
+ healFactsForDedupe.push({ id, title: fact.title });
3730
3833
  }
3834
+ await this.entryRepo.markHealChecked(
3835
+ [...healCandidates.map((f) => f.id), ...insertedFacts.map((f) => f.id)],
3836
+ entityId,
3837
+ now,
3838
+ tx
3839
+ );
3731
3840
  });
3732
3841
  await this.searchService.sync(entityId);
3733
3842
  for (const factId of uniqueDeletedFactIds) {
@@ -3741,6 +3850,20 @@ var MaintenanceService = class {
3741
3850
  await this.embeddingService.embedFact(fact);
3742
3851
  }
3743
3852
  this.searchService.evictCache(entityId);
3853
+ let scanned = outcome.skipped.length;
3854
+ for (const batchResult of outcome.results) scanned += batchResult.batch.length;
3855
+ const allDowngraded = /* @__PURE__ */ new Set([...staleDowngradedIds, ...safeDowngraded]);
3856
+ const allDeleted = /* @__PURE__ */ new Set([...orphanedIds, ...uniqueDeletedFactIds]);
3857
+ const counts = await this.entryRepo.countHealCandidatesByEntityId(entityId, recheckCutoff);
3858
+ return {
3859
+ scanned,
3860
+ downgraded: allDowngraded.size,
3861
+ deleted: allDeleted.size,
3862
+ newFactsCreated: insertedFacts.length,
3863
+ skipped: outcome.skipped.length,
3864
+ remaining: counts.eligible,
3865
+ deferred: counts.deferred
3866
+ };
3744
3867
  }
3745
3868
  /** Core ontology backfill pass (locks handled by {@link runOntologyBackfill}). Package-internal orchestration hook. */
3746
3869
  async doRunOntologyBackfill(entityId, options) {
@@ -5116,6 +5239,7 @@ var WriteService = class {
5116
5239
  let shouldRunLibrarian = false;
5117
5240
  let librarianCount = 0;
5118
5241
  let prevMemoryCheckpoint = 0;
5242
+ let eventCount = 0;
5119
5243
  await this.db.withTransactionAsync(async (tx) => {
5120
5244
  await this.eventRepo.add(newEvent, tx);
5121
5245
  const threshold = this.options.config?.autoLibrarianThreshold || 20;
@@ -5123,6 +5247,7 @@ var WriteService = class {
5123
5247
  this.eventRepo.count(entityId, tx),
5124
5248
  this.metadataRepo.getCheckpoint(entityId, tx)
5125
5249
  ]);
5250
+ eventCount = count;
5126
5251
  let memoryCheckpoint = cp.memory ?? 0;
5127
5252
  if (memoryCheckpoint > count) memoryCheckpoint = 0;
5128
5253
  if (count - memoryCheckpoint >= threshold) {
@@ -5144,6 +5269,8 @@ var WriteService = class {
5144
5269
  if (!(e instanceof WikiBusyError)) throw e;
5145
5270
  await this.metadataRepo.updateCheckpoint(entityId, { memory: prevMemoryCheckpoint }, this.db);
5146
5271
  }
5272
+ } else if (!this.jobManager.isBlocked("librarian", entityId)) {
5273
+ this.maybeRunHeal(entityId, eventCount).catch(console.error);
5147
5274
  }
5148
5275
  }
5149
5276
  async runLibrarianThenMaybeHeal(entityId, currentEventCount, prevCheckpoint) {
@@ -5154,6 +5281,14 @@ var WriteService = class {
5154
5281
  await this.metadataRepo.updateCheckpoint(entityId, { memory: prevCheckpoint }, this.db);
5155
5282
  throw e;
5156
5283
  }
5284
+ await this.maybeRunHeal(entityId, currentEventCount);
5285
+ }
5286
+ /**
5287
+ * Run one bounded auto-heal pass if the heal checkpoint has fallen
5288
+ * `autoHealThreshold` events behind. Called after every write (see
5289
+ * {@link write}) so a partial pass retries on the next write.
5290
+ */
5291
+ async maybeRunHeal(entityId, currentEventCount) {
5157
5292
  const autoHealThreshold = this.options.config?.autoHealThreshold || 100;
5158
5293
  const cp = await this.metadataRepo.getCheckpoint(entityId, this.db);
5159
5294
  let healCheckpoint = cp.heal ?? 0;
@@ -5161,8 +5296,10 @@ var WriteService = class {
5161
5296
  const shouldRunHeal = currentEventCount - healCheckpoint >= autoHealThreshold;
5162
5297
  if (shouldRunHeal && this.jobManager.tryAcquireAutoHealLock(entityId)) {
5163
5298
  try {
5164
- await this.maintenanceService.doRunHeal(entityId);
5165
- await this.metadataRepo.updateCheckpoint(entityId, { heal: currentEventCount }, this.db);
5299
+ const result = await this.maintenanceService.doRunHeal(entityId);
5300
+ if (result.remaining === 0) {
5301
+ await this.metadataRepo.updateCheckpoint(entityId, { heal: currentEventCount }, this.db);
5302
+ }
5166
5303
  } finally {
5167
5304
  this.jobManager.releaseLock("heal", entityId);
5168
5305
  }
@@ -5513,9 +5650,23 @@ var WikiMemory = class {
5513
5650
  return this.maintenanceService.runLibrarian(entityId, options);
5514
5651
  }
5515
5652
  /**
5653
+ * Reviews stored facts with the LLM: removes orphans, downgrades stale
5654
+ * inferences, synthesizes corrections.
5655
+ *
5656
+ * Bounded per call: covers at most `batchSize` candidates (default 25), so
5657
+ * one call no longer sweeps the whole entity. Hosts own the cadence; loop
5658
+ * `while (result.remaining > 0)` for convergence.
5659
+ *
5660
+ * `remaining === 0` means "every mutable fact is inside the 7-day recheck
5661
+ * cooldown", not "there is no more work" — heal's candidate set is the live
5662
+ * mutable corpus, not a draining backlog, so it climbs back as the cooldown
5663
+ * lapses and as new facts are written. Do not write a loop expecting a drain.
5664
+ *
5516
5665
  * @param options.promptOverride - Applies only to this manual call. Does NOT affect
5517
5666
  * WriteService-triggered auto-runs. For persistent prompt customization across auto-runs,
5518
5667
  * set `options.config.prompts.healSystemPrompt` at WikiMemory construction time.
5668
+ * @param options.batchSize - Candidates per run (default 25) for providers with
5669
+ * tighter context limits.
5519
5670
  */
5520
5671
  async runHeal(entityId, options) {
5521
5672
  return this.maintenanceService.runHeal(entityId, options);
@@ -6376,6 +6527,8 @@ function createWiki(db, options) {
6376
6527
  }
6377
6528
 
6378
6529
  exports.DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT = DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT;
6530
+ exports.HEAL_BATCH_SIZE = HEAL_BATCH_SIZE;
6531
+ exports.HEAL_RECHECK_MS = HEAL_RECHECK_MS;
6379
6532
  exports.HOOK_TIMEOUT_MARKER = HOOK_TIMEOUT_MARKER;
6380
6533
  exports.ONTOLOGY_BACKFILL_BATCH_SIZE = ONTOLOGY_BACKFILL_BATCH_SIZE;
6381
6534
  exports.ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS = ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS;