@equationalapplications/core-llm-wiki 4.23.1 → 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.
@@ -100,6 +100,39 @@ interface OntologyBackfillResult {
100
100
  * Stamped with the recheck cooldown, so they reappear as `deferred` next pass. */
101
101
  skipped: number;
102
102
  }
103
+ /** Result of a single heal run. */
104
+ interface HealResult {
105
+ /**
106
+ * Heal candidates sent to the model this run. Counts candidates whose batch
107
+ * came back unusable and landed in `skipped` as well — they reached the
108
+ * provider too, so this is provider exposure, not successful throughput.
109
+ */
110
+ scanned: number;
111
+ /**
112
+ * Facts whose confidence was lowered to `tentative` — the stale-inferred SQL
113
+ * pass (`inferred` → `tentative`) plus any model-directed downgrades, deduped.
114
+ */
115
+ downgraded: number;
116
+ /** Facts soft-deleted — the orphan-marking SQL pass plus model-directed deletes, deduped. */
117
+ deleted: number;
118
+ /** New facts synthesized by heal. */
119
+ newFactsCreated: number;
120
+ /** Candidates a batch could not process even alone, skipped so the pass could finish. */
121
+ skipped: number;
122
+ /**
123
+ * Heal candidates still eligible after this run — convergence signal: loop while > 0.
124
+ *
125
+ * This does NOT mean what it means for OntologyBackfillResult. Untyped facts are
126
+ * a finite backlog that drains permanently; heal's candidates are the live mutable
127
+ * corpus. `remaining === 0` means "every mutable fact is inside the recheck
128
+ * cooldown", not "there is no more work" — it climbs back as the cooldown lapses
129
+ * and as new facts are written. A host loop terminates, but it converges to
130
+ * "corpus swept once this cooldown window", not to a drained queue.
131
+ */
132
+ remaining: number;
133
+ /** Heal candidates inside the recheck cooldown. */
134
+ deferred: number;
135
+ }
103
136
  interface WikiConfig {
104
137
  /**
105
138
  * Prefix applied to every SQL table/index/trigger name. Must match
@@ -664,11 +697,14 @@ declare class EntryRepository extends BaseRepository {
664
697
  findAllByEntityId(entityId: string, tx?: SQLiteAdapter): Promise<WikiFact[]>;
665
698
  /**
666
699
  * Fetch live, mutable entries for an entity — everything heal is allowed to
667
- * downgrade or delete. Heal previously loaded every row via
668
- * findAllByEntityId and filtered in JS, which on a document-heavy corpus
669
- * meant loading 2560 rows to keep 31.
700
+ * downgrade or delete — oldest first, capped at `limit`, skipping anything
701
+ * stamped inside the recheck cooldown (heal_checked_at > recheckCutoff).
702
+ *
703
+ * Oldest-first rather than newest-first: with a cooldown in place, newest-first
704
+ * would keep re-selecting recently-touched facts every pass while older ones
705
+ * wait indefinitely to even enter a batch.
670
706
  */
671
- findHealCandidatesByEntityId(entityId: string, tx?: SQLiteAdapter): Promise<WikiFact[]>;
707
+ findHealCandidatesByEntityId(entityId: string, limit: number, recheckCutoff: number, tx?: SQLiteAdapter): Promise<WikiFact[]>;
672
708
  /**
673
709
  * Resolve search hits to document anchors. The MiniSearch index holds every
674
710
  * fact, not only immutable_document rows, so the source-type restriction has
@@ -719,8 +755,12 @@ declare class EntryRepository extends BaseRepository {
719
755
  /**
720
756
  * Downgrade stale inferred entries to 'tentative'.
721
757
  * Used by MaintenanceService.doRunHeal().
758
+ *
759
+ * Returns the ids it downgraded so the caller can fold them into
760
+ * {@link HealResult.downgraded} without double-counting a fact the model also
761
+ * downgraded in the same pass.
722
762
  */
723
- downgradeStaleInferred(entityId: string, staleThreshold: number, tx: SQLiteAdapter): Promise<number>;
763
+ downgradeStaleInferred(entityId: string, staleThreshold: number, tx: SQLiteAdapter): Promise<string[]>;
724
764
  /**
725
765
  * Downgrade specific entries to 'tentative' by IDs.
726
766
  * Used by MaintenanceService.doRunHeal().
@@ -804,6 +844,34 @@ declare class EntryRepository extends BaseRepository {
804
844
  title: string;
805
845
  okf_type: string | null;
806
846
  }>>;
847
+ /** Counts live mutable facts: eligible (past cooldown) vs deferred (in cooldown). */
848
+ countHealCandidatesByEntityId(entityId: string, recheckCutoff: number, tx?: SQLiteAdapter): Promise<{
849
+ eligible: number;
850
+ deferred: number;
851
+ }>;
852
+ /**
853
+ * Stamps the heal recheck cooldown. NEVER touches updated_at — import merge
854
+ * resolution is last-write-wins on updated_at and a bump here would make an
855
+ * unchanged local fact beat a genuinely newer remote edit.
856
+ *
857
+ * Soft-deleted rows are skipped: a candidate heal deleted earlier in the same
858
+ * transaction is no longer a candidate under any future pass, so its cooldown
859
+ * value is irrelevant. This means the stamped count can be lower than the
860
+ * offered-candidate count.
861
+ */
862
+ markHealChecked(ids: string[], entityId: string, now: number, tx: SQLiteAdapter): Promise<void>;
863
+ /**
864
+ * Lightweight full-breadth index (id, title) over all live librarian_inferred
865
+ * facts. This is heal's fuzzy-dedupe corpus, deliberately kept independent of
866
+ * the bounded candidate window: seeding dedupe from a batchSize-limited read
867
+ * would let a synthesized fact duplicating a fact outside the window pass the
868
+ * Jaccard check, and a convergence loop would multiply those duplicates across
869
+ * passes. Mirrors findTitleIndexByEntityId — full breadth, two columns, cheap.
870
+ */
871
+ findInferredTitlesByEntityId(entityId: string, tx?: SQLiteAdapter): Promise<Array<{
872
+ id: string;
873
+ title: string;
874
+ }>>;
807
875
  }
808
876
 
809
877
  declare class MetadataRepository extends BaseRepository {
@@ -1203,6 +1271,15 @@ declare class EventRepository extends BaseRepository {
1203
1271
  declare const ONTOLOGY_BACKFILL_BATCH_SIZE = 25;
1204
1272
  declare const ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS = 40000;
1205
1273
  declare const ONTOLOGY_BACKFILL_RECHECK_MS: number;
1274
+ /**
1275
+ * Heal candidates fetched per pass. Bounds the pool runBatched draws from, not
1276
+ * the size of an individual LLM call — runBatched still packs ~10 candidates per
1277
+ * request and splits further on failure. At this default one pass costs roughly
1278
+ * 2-3 provider calls instead of an unbounded count (#67).
1279
+ */
1280
+ declare const HEAL_BATCH_SIZE = 25;
1281
+ /** Cooldown before an already-healed fact is offered again. Matches ontology backfill. */
1282
+ declare const HEAL_RECHECK_MS: number;
1206
1283
  declare class MaintenanceService {
1207
1284
  private db;
1208
1285
  private prefix;
@@ -1231,7 +1308,8 @@ declare class MaintenanceService {
1231
1308
  }): Promise<void>;
1232
1309
  runHeal(entityId: string, options?: {
1233
1310
  promptOverride?: string;
1234
- }): Promise<void>;
1311
+ batchSize?: number;
1312
+ }): Promise<HealResult>;
1235
1313
  runOntologyBackfill(entityId: string, options?: {
1236
1314
  promptOverride?: string;
1237
1315
  batchSize?: number;
@@ -1258,8 +1336,17 @@ declare class MaintenanceService {
1258
1336
  }>;
1259
1337
  /** Core librarian pass (locks handled by {@link runLibrarian}). Package-internal orchestration hook. */
1260
1338
  doRunLibrarian(entityId: string, promptOverride?: string): Promise<void>;
1261
- /** Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook. */
1262
- doRunHeal(entityId: string, promptOverride?: string): Promise<void>;
1339
+ /**
1340
+ * Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook.
1341
+ *
1342
+ * Bounded: at most `batchSize` candidates per pass (#67). Loop on
1343
+ * `result.remaining > 0` for convergence — see {@link HealResult.remaining}
1344
+ * for what convergence means here.
1345
+ */
1346
+ doRunHeal(entityId: string, options?: {
1347
+ promptOverride?: string;
1348
+ batchSize?: number;
1349
+ }): Promise<HealResult>;
1263
1350
  /** Core ontology backfill pass (locks handled by {@link runOntologyBackfill}). Package-internal orchestration hook. */
1264
1351
  doRunOntologyBackfill(entityId: string, options?: {
1265
1352
  promptOverride?: string;
@@ -1371,6 +1458,12 @@ declare class WriteService {
1371
1458
  constructor(db: SQLiteAdapter, options: WikiOptions, entryRepo: EntryRepository, eventRepo: EventRepository, metadataRepo: MetadataRepository, jobManager: JobManager, maintenanceService: MaintenanceService);
1372
1459
  write(entityId: string, event: Omit<WikiEvent, 'id' | 'entity_id' | 'created_at'>): Promise<void>;
1373
1460
  private runLibrarianThenMaybeHeal;
1461
+ /**
1462
+ * Run one bounded auto-heal pass if the heal checkpoint has fallen
1463
+ * `autoHealThreshold` events behind. Called after every write (see
1464
+ * {@link write}) so a partial pass retries on the next write.
1465
+ */
1466
+ private maybeRunHeal;
1374
1467
  }
1375
1468
 
1376
1469
  /**
@@ -1452,13 +1545,28 @@ declare class WikiMemory {
1452
1545
  promptOverride?: string;
1453
1546
  }): Promise<void>;
1454
1547
  /**
1548
+ * Reviews stored facts with the LLM: removes orphans, downgrades stale
1549
+ * inferences, synthesizes corrections.
1550
+ *
1551
+ * Bounded per call: covers at most `batchSize` candidates (default 25), so
1552
+ * one call no longer sweeps the whole entity. Hosts own the cadence; loop
1553
+ * `while (result.remaining > 0)` for convergence.
1554
+ *
1555
+ * `remaining === 0` means "every mutable fact is inside the 7-day recheck
1556
+ * cooldown", not "there is no more work" — heal's candidate set is the live
1557
+ * mutable corpus, not a draining backlog, so it climbs back as the cooldown
1558
+ * lapses and as new facts are written. Do not write a loop expecting a drain.
1559
+ *
1455
1560
  * @param options.promptOverride - Applies only to this manual call. Does NOT affect
1456
1561
  * WriteService-triggered auto-runs. For persistent prompt customization across auto-runs,
1457
1562
  * set `options.config.prompts.healSystemPrompt` at WikiMemory construction time.
1563
+ * @param options.batchSize - Candidates per run (default 25) for providers with
1564
+ * tighter context limits.
1458
1565
  */
1459
1566
  runHeal(entityId: string, options?: {
1460
1567
  promptOverride?: string;
1461
- }): Promise<void>;
1568
+ batchSize?: number;
1569
+ }): Promise<HealResult>;
1462
1570
  /**
1463
1571
  * Types already-persisted untyped facts (okf_type IS NULL) in place via one
1464
1572
  * librarian-style LLM call. Strictly additive: never creates, deletes, or
@@ -1552,4 +1660,4 @@ declare class WikiMemory {
1552
1660
  }): Promise<void>;
1553
1661
  }
1554
1662
 
1555
- export { WriteService as $, type WikiConfig as A, type WikiEdge as B, type WikiEvent as C, type WikiFact as D, type EntityStatus as E, type FormatContextOptions as F, type GraphNeighborhood as G, HOOK_TIMEOUT_MARKER as H, type WikiMemoryTestAccess as I, type WikiOutboxEvent as J, type WikiTask as K, type LLMProvider as L, type MemoryBundle as M, WikiTransactionError as N, type OntologyManifest as O, type PromptOverrides as P, EmbeddingService as Q, type ReadOptions as R, type SQLiteAdapter as S, ImportExportService as T, IngestionService as U, type VectorRanker as V, type WikiOptions as W, JobManager as X, MaintenanceService as Y, RetrievalService as Z, SearchService as _, type MemoryDump as a, type FormattedMemoryDump as b, WikiMemory as c, type ExtractedFact as d, type ExtractedFactEdge as e, type ExtractedFactWithOntology as f, type ExtractedTask as g, type GraphTraversalOptions as h, ONTOLOGY_BACKFILL_BATCH_SIZE as i, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS as j, ONTOLOGY_BACKFILL_RECHECK_MS as k, type OntologyBackfillResult as l, type OntologyConfig as m, type OntologyEdgeType as n, type OntologyMode as o, type OntologyNodeType as p, type OntologyPromptContext as q, type OntologyUpdates as r, PromptService as s, PrunePartialFailureError as t, type VectorRankerFallback as u, type VectorRankerRankArgs as v, type VectorRankerSemanticResult as w, WikiBusyError as x, type WikiBusyOperation as y, type WikiCheckpoint as z };
1663
+ export { MaintenanceService as $, WikiBusyError as A, type WikiBusyOperation as B, type WikiCheckpoint as C, type WikiConfig as D, type EntityStatus as E, type FormatContextOptions as F, type GraphNeighborhood as G, HEAL_BATCH_SIZE as H, type WikiEdge as I, type WikiEvent as J, type WikiFact as K, type LLMProvider as L, type MemoryBundle as M, type WikiMemoryTestAccess as N, type OntologyManifest as O, type PromptOverrides as P, type WikiOutboxEvent as Q, type ReadOptions as R, type SQLiteAdapter as S, type WikiTask as T, WikiTransactionError as U, type VectorRanker as V, type WikiOptions as W, EmbeddingService as X, ImportExportService as Y, IngestionService as Z, JobManager as _, type MemoryDump as a, RetrievalService as a0, SearchService as a1, WriteService as a2, type FormattedMemoryDump as b, WikiMemory as c, type ExtractedFact as d, type ExtractedFactEdge as e, type ExtractedFactWithOntology as f, type ExtractedTask as g, type GraphTraversalOptions as h, HEAL_RECHECK_MS as i, HOOK_TIMEOUT_MARKER as j, type HealResult as k, ONTOLOGY_BACKFILL_BATCH_SIZE as l, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS as m, ONTOLOGY_BACKFILL_RECHECK_MS as n, type OntologyBackfillResult as o, type OntologyConfig as p, type OntologyEdgeType as q, type OntologyMode as r, type OntologyNodeType as s, type OntologyPromptContext as t, type OntologyUpdates as u, PromptService as v, PrunePartialFailureError as w, type VectorRankerFallback as x, type VectorRankerRankArgs as y, type VectorRankerSemanticResult as z };
@@ -100,6 +100,39 @@ interface OntologyBackfillResult {
100
100
  * Stamped with the recheck cooldown, so they reappear as `deferred` next pass. */
101
101
  skipped: number;
102
102
  }
103
+ /** Result of a single heal run. */
104
+ interface HealResult {
105
+ /**
106
+ * Heal candidates sent to the model this run. Counts candidates whose batch
107
+ * came back unusable and landed in `skipped` as well — they reached the
108
+ * provider too, so this is provider exposure, not successful throughput.
109
+ */
110
+ scanned: number;
111
+ /**
112
+ * Facts whose confidence was lowered to `tentative` — the stale-inferred SQL
113
+ * pass (`inferred` → `tentative`) plus any model-directed downgrades, deduped.
114
+ */
115
+ downgraded: number;
116
+ /** Facts soft-deleted — the orphan-marking SQL pass plus model-directed deletes, deduped. */
117
+ deleted: number;
118
+ /** New facts synthesized by heal. */
119
+ newFactsCreated: number;
120
+ /** Candidates a batch could not process even alone, skipped so the pass could finish. */
121
+ skipped: number;
122
+ /**
123
+ * Heal candidates still eligible after this run — convergence signal: loop while > 0.
124
+ *
125
+ * This does NOT mean what it means for OntologyBackfillResult. Untyped facts are
126
+ * a finite backlog that drains permanently; heal's candidates are the live mutable
127
+ * corpus. `remaining === 0` means "every mutable fact is inside the recheck
128
+ * cooldown", not "there is no more work" — it climbs back as the cooldown lapses
129
+ * and as new facts are written. A host loop terminates, but it converges to
130
+ * "corpus swept once this cooldown window", not to a drained queue.
131
+ */
132
+ remaining: number;
133
+ /** Heal candidates inside the recheck cooldown. */
134
+ deferred: number;
135
+ }
103
136
  interface WikiConfig {
104
137
  /**
105
138
  * Prefix applied to every SQL table/index/trigger name. Must match
@@ -664,11 +697,14 @@ declare class EntryRepository extends BaseRepository {
664
697
  findAllByEntityId(entityId: string, tx?: SQLiteAdapter): Promise<WikiFact[]>;
665
698
  /**
666
699
  * Fetch live, mutable entries for an entity — everything heal is allowed to
667
- * downgrade or delete. Heal previously loaded every row via
668
- * findAllByEntityId and filtered in JS, which on a document-heavy corpus
669
- * meant loading 2560 rows to keep 31.
700
+ * downgrade or delete — oldest first, capped at `limit`, skipping anything
701
+ * stamped inside the recheck cooldown (heal_checked_at > recheckCutoff).
702
+ *
703
+ * Oldest-first rather than newest-first: with a cooldown in place, newest-first
704
+ * would keep re-selecting recently-touched facts every pass while older ones
705
+ * wait indefinitely to even enter a batch.
670
706
  */
671
- findHealCandidatesByEntityId(entityId: string, tx?: SQLiteAdapter): Promise<WikiFact[]>;
707
+ findHealCandidatesByEntityId(entityId: string, limit: number, recheckCutoff: number, tx?: SQLiteAdapter): Promise<WikiFact[]>;
672
708
  /**
673
709
  * Resolve search hits to document anchors. The MiniSearch index holds every
674
710
  * fact, not only immutable_document rows, so the source-type restriction has
@@ -719,8 +755,12 @@ declare class EntryRepository extends BaseRepository {
719
755
  /**
720
756
  * Downgrade stale inferred entries to 'tentative'.
721
757
  * Used by MaintenanceService.doRunHeal().
758
+ *
759
+ * Returns the ids it downgraded so the caller can fold them into
760
+ * {@link HealResult.downgraded} without double-counting a fact the model also
761
+ * downgraded in the same pass.
722
762
  */
723
- downgradeStaleInferred(entityId: string, staleThreshold: number, tx: SQLiteAdapter): Promise<number>;
763
+ downgradeStaleInferred(entityId: string, staleThreshold: number, tx: SQLiteAdapter): Promise<string[]>;
724
764
  /**
725
765
  * Downgrade specific entries to 'tentative' by IDs.
726
766
  * Used by MaintenanceService.doRunHeal().
@@ -804,6 +844,34 @@ declare class EntryRepository extends BaseRepository {
804
844
  title: string;
805
845
  okf_type: string | null;
806
846
  }>>;
847
+ /** Counts live mutable facts: eligible (past cooldown) vs deferred (in cooldown). */
848
+ countHealCandidatesByEntityId(entityId: string, recheckCutoff: number, tx?: SQLiteAdapter): Promise<{
849
+ eligible: number;
850
+ deferred: number;
851
+ }>;
852
+ /**
853
+ * Stamps the heal recheck cooldown. NEVER touches updated_at — import merge
854
+ * resolution is last-write-wins on updated_at and a bump here would make an
855
+ * unchanged local fact beat a genuinely newer remote edit.
856
+ *
857
+ * Soft-deleted rows are skipped: a candidate heal deleted earlier in the same
858
+ * transaction is no longer a candidate under any future pass, so its cooldown
859
+ * value is irrelevant. This means the stamped count can be lower than the
860
+ * offered-candidate count.
861
+ */
862
+ markHealChecked(ids: string[], entityId: string, now: number, tx: SQLiteAdapter): Promise<void>;
863
+ /**
864
+ * Lightweight full-breadth index (id, title) over all live librarian_inferred
865
+ * facts. This is heal's fuzzy-dedupe corpus, deliberately kept independent of
866
+ * the bounded candidate window: seeding dedupe from a batchSize-limited read
867
+ * would let a synthesized fact duplicating a fact outside the window pass the
868
+ * Jaccard check, and a convergence loop would multiply those duplicates across
869
+ * passes. Mirrors findTitleIndexByEntityId — full breadth, two columns, cheap.
870
+ */
871
+ findInferredTitlesByEntityId(entityId: string, tx?: SQLiteAdapter): Promise<Array<{
872
+ id: string;
873
+ title: string;
874
+ }>>;
807
875
  }
808
876
 
809
877
  declare class MetadataRepository extends BaseRepository {
@@ -1203,6 +1271,15 @@ declare class EventRepository extends BaseRepository {
1203
1271
  declare const ONTOLOGY_BACKFILL_BATCH_SIZE = 25;
1204
1272
  declare const ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS = 40000;
1205
1273
  declare const ONTOLOGY_BACKFILL_RECHECK_MS: number;
1274
+ /**
1275
+ * Heal candidates fetched per pass. Bounds the pool runBatched draws from, not
1276
+ * the size of an individual LLM call — runBatched still packs ~10 candidates per
1277
+ * request and splits further on failure. At this default one pass costs roughly
1278
+ * 2-3 provider calls instead of an unbounded count (#67).
1279
+ */
1280
+ declare const HEAL_BATCH_SIZE = 25;
1281
+ /** Cooldown before an already-healed fact is offered again. Matches ontology backfill. */
1282
+ declare const HEAL_RECHECK_MS: number;
1206
1283
  declare class MaintenanceService {
1207
1284
  private db;
1208
1285
  private prefix;
@@ -1231,7 +1308,8 @@ declare class MaintenanceService {
1231
1308
  }): Promise<void>;
1232
1309
  runHeal(entityId: string, options?: {
1233
1310
  promptOverride?: string;
1234
- }): Promise<void>;
1311
+ batchSize?: number;
1312
+ }): Promise<HealResult>;
1235
1313
  runOntologyBackfill(entityId: string, options?: {
1236
1314
  promptOverride?: string;
1237
1315
  batchSize?: number;
@@ -1258,8 +1336,17 @@ declare class MaintenanceService {
1258
1336
  }>;
1259
1337
  /** Core librarian pass (locks handled by {@link runLibrarian}). Package-internal orchestration hook. */
1260
1338
  doRunLibrarian(entityId: string, promptOverride?: string): Promise<void>;
1261
- /** Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook. */
1262
- doRunHeal(entityId: string, promptOverride?: string): Promise<void>;
1339
+ /**
1340
+ * Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook.
1341
+ *
1342
+ * Bounded: at most `batchSize` candidates per pass (#67). Loop on
1343
+ * `result.remaining > 0` for convergence — see {@link HealResult.remaining}
1344
+ * for what convergence means here.
1345
+ */
1346
+ doRunHeal(entityId: string, options?: {
1347
+ promptOverride?: string;
1348
+ batchSize?: number;
1349
+ }): Promise<HealResult>;
1263
1350
  /** Core ontology backfill pass (locks handled by {@link runOntologyBackfill}). Package-internal orchestration hook. */
1264
1351
  doRunOntologyBackfill(entityId: string, options?: {
1265
1352
  promptOverride?: string;
@@ -1371,6 +1458,12 @@ declare class WriteService {
1371
1458
  constructor(db: SQLiteAdapter, options: WikiOptions, entryRepo: EntryRepository, eventRepo: EventRepository, metadataRepo: MetadataRepository, jobManager: JobManager, maintenanceService: MaintenanceService);
1372
1459
  write(entityId: string, event: Omit<WikiEvent, 'id' | 'entity_id' | 'created_at'>): Promise<void>;
1373
1460
  private runLibrarianThenMaybeHeal;
1461
+ /**
1462
+ * Run one bounded auto-heal pass if the heal checkpoint has fallen
1463
+ * `autoHealThreshold` events behind. Called after every write (see
1464
+ * {@link write}) so a partial pass retries on the next write.
1465
+ */
1466
+ private maybeRunHeal;
1374
1467
  }
1375
1468
 
1376
1469
  /**
@@ -1452,13 +1545,28 @@ declare class WikiMemory {
1452
1545
  promptOverride?: string;
1453
1546
  }): Promise<void>;
1454
1547
  /**
1548
+ * Reviews stored facts with the LLM: removes orphans, downgrades stale
1549
+ * inferences, synthesizes corrections.
1550
+ *
1551
+ * Bounded per call: covers at most `batchSize` candidates (default 25), so
1552
+ * one call no longer sweeps the whole entity. Hosts own the cadence; loop
1553
+ * `while (result.remaining > 0)` for convergence.
1554
+ *
1555
+ * `remaining === 0` means "every mutable fact is inside the 7-day recheck
1556
+ * cooldown", not "there is no more work" — heal's candidate set is the live
1557
+ * mutable corpus, not a draining backlog, so it climbs back as the cooldown
1558
+ * lapses and as new facts are written. Do not write a loop expecting a drain.
1559
+ *
1455
1560
  * @param options.promptOverride - Applies only to this manual call. Does NOT affect
1456
1561
  * WriteService-triggered auto-runs. For persistent prompt customization across auto-runs,
1457
1562
  * set `options.config.prompts.healSystemPrompt` at WikiMemory construction time.
1563
+ * @param options.batchSize - Candidates per run (default 25) for providers with
1564
+ * tighter context limits.
1458
1565
  */
1459
1566
  runHeal(entityId: string, options?: {
1460
1567
  promptOverride?: string;
1461
- }): Promise<void>;
1568
+ batchSize?: number;
1569
+ }): Promise<HealResult>;
1462
1570
  /**
1463
1571
  * Types already-persisted untyped facts (okf_type IS NULL) in place via one
1464
1572
  * librarian-style LLM call. Strictly additive: never creates, deletes, or
@@ -1552,4 +1660,4 @@ declare class WikiMemory {
1552
1660
  }): Promise<void>;
1553
1661
  }
1554
1662
 
1555
- export { WriteService as $, type WikiConfig as A, type WikiEdge as B, type WikiEvent as C, type WikiFact as D, type EntityStatus as E, type FormatContextOptions as F, type GraphNeighborhood as G, HOOK_TIMEOUT_MARKER as H, type WikiMemoryTestAccess as I, type WikiOutboxEvent as J, type WikiTask as K, type LLMProvider as L, type MemoryBundle as M, WikiTransactionError as N, type OntologyManifest as O, type PromptOverrides as P, EmbeddingService as Q, type ReadOptions as R, type SQLiteAdapter as S, ImportExportService as T, IngestionService as U, type VectorRanker as V, type WikiOptions as W, JobManager as X, MaintenanceService as Y, RetrievalService as Z, SearchService as _, type MemoryDump as a, type FormattedMemoryDump as b, WikiMemory as c, type ExtractedFact as d, type ExtractedFactEdge as e, type ExtractedFactWithOntology as f, type ExtractedTask as g, type GraphTraversalOptions as h, ONTOLOGY_BACKFILL_BATCH_SIZE as i, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS as j, ONTOLOGY_BACKFILL_RECHECK_MS as k, type OntologyBackfillResult as l, type OntologyConfig as m, type OntologyEdgeType as n, type OntologyMode as o, type OntologyNodeType as p, type OntologyPromptContext as q, type OntologyUpdates as r, PromptService as s, PrunePartialFailureError as t, type VectorRankerFallback as u, type VectorRankerRankArgs as v, type VectorRankerSemanticResult as w, WikiBusyError as x, type WikiBusyOperation as y, type WikiCheckpoint as z };
1663
+ export { MaintenanceService as $, WikiBusyError as A, type WikiBusyOperation as B, type WikiCheckpoint as C, type WikiConfig as D, type EntityStatus as E, type FormatContextOptions as F, type GraphNeighborhood as G, HEAL_BATCH_SIZE as H, type WikiEdge as I, type WikiEvent as J, type WikiFact as K, type LLMProvider as L, type MemoryBundle as M, type WikiMemoryTestAccess as N, type OntologyManifest as O, type PromptOverrides as P, type WikiOutboxEvent as Q, type ReadOptions as R, type SQLiteAdapter as S, type WikiTask as T, WikiTransactionError as U, type VectorRanker as V, type WikiOptions as W, EmbeddingService as X, ImportExportService as Y, IngestionService as Z, JobManager as _, type MemoryDump as a, RetrievalService as a0, SearchService as a1, WriteService as a2, type FormattedMemoryDump as b, WikiMemory as c, type ExtractedFact as d, type ExtractedFactEdge as e, type ExtractedFactWithOntology as f, type ExtractedTask as g, type GraphTraversalOptions as h, HEAL_RECHECK_MS as i, HOOK_TIMEOUT_MARKER as j, type HealResult as k, ONTOLOGY_BACKFILL_BATCH_SIZE as l, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS as m, ONTOLOGY_BACKFILL_RECHECK_MS as n, type OntologyBackfillResult as o, type OntologyConfig as p, type OntologyEdgeType as q, type OntologyMode as r, type OntologyNodeType as s, type OntologyPromptContext as t, type OntologyUpdates as u, PromptService as v, PrunePartialFailureError as w, type VectorRankerFallback as x, type VectorRankerRankArgs as y, type VectorRankerSemanticResult as z };
@@ -1,2 +1,2 @@
1
- export { Q as EmbeddingService, T as ImportExportService, U as IngestionService, X as JobManager, X as JobManagerType, Y as MaintenanceService, Z as RetrievalService, _ as SearchService, _ as SearchServiceType, I as WikiMemoryTestAccess, $ as WriteService } from './testing-CBjAuTSl.mjs';
1
+ export { X as EmbeddingService, Y as ImportExportService, Z as IngestionService, _ as JobManager, _ as JobManagerType, $ as MaintenanceService, a0 as RetrievalService, a1 as SearchService, a1 as SearchServiceType, N as WikiMemoryTestAccess, a2 as WriteService } from './testing-DCRNie7k.mjs';
2
2
  import 'minisearch';
package/dist/testing.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { Q as EmbeddingService, T as ImportExportService, U as IngestionService, X as JobManager, X as JobManagerType, Y as MaintenanceService, Z as RetrievalService, _ as SearchService, _ as SearchServiceType, I as WikiMemoryTestAccess, $ as WriteService } from './testing-CBjAuTSl.js';
1
+ export { X as EmbeddingService, Y as ImportExportService, Z as IngestionService, _ as JobManager, _ as JobManagerType, $ as MaintenanceService, a0 as RetrievalService, a1 as SearchService, a1 as SearchServiceType, N as WikiMemoryTestAccess, a2 as WriteService } from './testing-DCRNie7k.js';
2
2
  import 'minisearch';
package/dist/testing.js CHANGED
@@ -1261,6 +1261,8 @@ var ONTOLOGY_BACKFILL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1261
1261
  var HEAL_MAX_ANCHORS = 50;
1262
1262
  var HEAL_ANCHOR_SEARCH_OVERFETCH = 4;
1263
1263
  var HEAL_MAX_PROMPT_CHARS = 4e4;
1264
+ var HEAL_BATCH_SIZE = 25;
1265
+ var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1264
1266
  var MaintenanceService = class {
1265
1267
  constructor(db, prefix, options, entryRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1266
1268
  this.db = db;
@@ -1361,7 +1363,7 @@ var MaintenanceService = class {
1361
1363
  async runHeal(entityId, options) {
1362
1364
  this.jobManager.acquireLock("heal", entityId);
1363
1365
  try {
1364
- await this.doRunHeal(entityId, options?.promptOverride);
1366
+ return await this.doRunHeal(entityId, options);
1365
1367
  } finally {
1366
1368
  this.jobManager.releaseLock("heal", entityId);
1367
1369
  }
@@ -1613,9 +1615,21 @@ var MaintenanceService = class {
1613
1615
  }
1614
1616
  this.searchService.evictCache(entityId);
1615
1617
  }
1616
- /** Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook. */
1617
- async doRunHeal(entityId, promptOverride) {
1618
+ /**
1619
+ * Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook.
1620
+ *
1621
+ * Bounded: at most `batchSize` candidates per pass (#67). Loop on
1622
+ * `result.remaining > 0` for convergence — see {@link HealResult.remaining}
1623
+ * for what convergence means here.
1624
+ */
1625
+ async doRunHeal(entityId, options) {
1626
+ const promptOverride = options?.promptOverride;
1627
+ const batchSize = options?.batchSize ?? HEAL_BATCH_SIZE;
1628
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
1629
+ throw new Error("Invalid batchSize: must be an integer >= 1");
1630
+ }
1618
1631
  const now = Date.now();
1632
+ const recheckCutoff = now - HEAL_RECHECK_MS;
1619
1633
  const orphanAfterDays = this.options.config?.orphanAfterDays !== void 0 ? this.options.config?.orphanAfterDays : 30;
1620
1634
  const staleInferredAfterDays = this.options.config?.staleInferredAfterDays !== void 0 ? this.options.config?.staleInferredAfterDays : 60;
1621
1635
  const MS_PER_DAY = 24 * 60 * 60 * 1e3;
@@ -1626,6 +1640,7 @@ var MaintenanceService = class {
1626
1640
  throw new Error("Invalid staleInferredAfterDays: must be a finite number >= 0 or null");
1627
1641
  }
1628
1642
  const orphanedIds = [];
1643
+ const staleDowngradedIds = [];
1629
1644
  await this.db.withTransactionAsync(async (tx) => {
1630
1645
  if (orphanAfterDays !== null) {
1631
1646
  const orphanThreshold = now - orphanAfterDays * MS_PER_DAY;
@@ -1633,7 +1648,7 @@ var MaintenanceService = class {
1633
1648
  }
1634
1649
  if (staleInferredAfterDays !== null) {
1635
1650
  const staleThreshold = now - staleInferredAfterDays * MS_PER_DAY;
1636
- await this.entryRepo.downgradeStaleInferred(entityId, staleThreshold, tx);
1651
+ staleDowngradedIds.push(...await this.entryRepo.downgradeStaleInferred(entityId, staleThreshold, tx));
1637
1652
  }
1638
1653
  });
1639
1654
  for (const factId of orphanedIds) {
@@ -1643,7 +1658,21 @@ var MaintenanceService = class {
1643
1658
  console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during heal orphan pass for ${factId}:`, hookErr);
1644
1659
  }
1645
1660
  }
1646
- const healCandidates = await this.entryRepo.findHealCandidatesByEntityId(entityId);
1661
+ const healCandidates = await this.entryRepo.findHealCandidatesByEntityId(entityId, batchSize, recheckCutoff);
1662
+ if (healCandidates.length === 0) {
1663
+ await this.searchService.sync(entityId);
1664
+ this.searchService.evictCache(entityId);
1665
+ const counts2 = await this.entryRepo.countHealCandidatesByEntityId(entityId, recheckCutoff);
1666
+ return {
1667
+ scanned: 0,
1668
+ downgraded: staleDowngradedIds.length,
1669
+ deleted: orphanedIds.length,
1670
+ newFactsCreated: 0,
1671
+ skipped: 0,
1672
+ remaining: counts2.eligible,
1673
+ deferred: counts2.deferred
1674
+ };
1675
+ }
1647
1676
  const allTasks = await this.taskRepo.findAllPending([entityId]);
1648
1677
  const recentEvents = await this.eventRepo.getRecent(entityId, 20);
1649
1678
  const toPromptShape = (f) => {
@@ -1696,7 +1725,7 @@ var MaintenanceService = class {
1696
1725
  const validNewFacts = newFacts.map(validateFact).filter((f) => f !== null);
1697
1726
  const insertedFacts = [];
1698
1727
  const uniqueDeletedFactIds = Array.from(new Set(safeDeleted));
1699
- const healFactsForDedupe = [...healCandidates];
1728
+ const healFactsForDedupe = (await this.entryRepo.findInferredTitlesByEntityId(entityId)).filter((f) => !safeDeletedSet.has(f.id));
1700
1729
  await this.db.withTransactionAsync(async (tx) => {
1701
1730
  await this.entryRepo.downgradeByIds(safeDowngraded, entityId, tx);
1702
1731
  await this.entryRepo.softDeleteByIds(safeDeleted, entityId, tx);
@@ -1705,7 +1734,6 @@ var MaintenanceService = class {
1705
1734
  let skip = false;
1706
1735
  if (newTokens.size >= MIN_TOKENS_TO_QUALIFY) {
1707
1736
  for (const existing of healFactsForDedupe) {
1708
- if (existing.source_type !== "librarian_inferred") continue;
1709
1737
  const existingTokens = titleTokens(existing.title);
1710
1738
  if (existingTokens.size >= MIN_TOKENS_TO_QUALIFY) {
1711
1739
  if (jaccardScore(newTokens, existingTokens) >= FUZZY_THRESHOLD) {
@@ -1735,8 +1763,14 @@ var MaintenanceService = class {
1735
1763
  };
1736
1764
  await this.entryRepo.upsert(factObj, tx);
1737
1765
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1738
- healFactsForDedupe.push(factObj);
1766
+ healFactsForDedupe.push({ id, title: fact.title });
1739
1767
  }
1768
+ await this.entryRepo.markHealChecked(
1769
+ [...healCandidates.map((f) => f.id), ...insertedFacts.map((f) => f.id)],
1770
+ entityId,
1771
+ now,
1772
+ tx
1773
+ );
1740
1774
  });
1741
1775
  await this.searchService.sync(entityId);
1742
1776
  for (const factId of uniqueDeletedFactIds) {
@@ -1750,6 +1784,20 @@ var MaintenanceService = class {
1750
1784
  await this.embeddingService.embedFact(fact);
1751
1785
  }
1752
1786
  this.searchService.evictCache(entityId);
1787
+ let scanned = outcome.skipped.length;
1788
+ for (const batchResult of outcome.results) scanned += batchResult.batch.length;
1789
+ const allDowngraded = /* @__PURE__ */ new Set([...staleDowngradedIds, ...safeDowngraded]);
1790
+ const allDeleted = /* @__PURE__ */ new Set([...orphanedIds, ...uniqueDeletedFactIds]);
1791
+ const counts = await this.entryRepo.countHealCandidatesByEntityId(entityId, recheckCutoff);
1792
+ return {
1793
+ scanned,
1794
+ downgraded: allDowngraded.size,
1795
+ deleted: allDeleted.size,
1796
+ newFactsCreated: insertedFacts.length,
1797
+ skipped: outcome.skipped.length,
1798
+ remaining: counts.eligible,
1799
+ deferred: counts.deferred
1800
+ };
1753
1801
  }
1754
1802
  /** Core ontology backfill pass (locks handled by {@link runOntologyBackfill}). Package-internal orchestration hook. */
1755
1803
  async doRunOntologyBackfill(entityId, options) {
@@ -3113,6 +3161,7 @@ var WriteService = class {
3113
3161
  let shouldRunLibrarian = false;
3114
3162
  let librarianCount = 0;
3115
3163
  let prevMemoryCheckpoint = 0;
3164
+ let eventCount = 0;
3116
3165
  await this.db.withTransactionAsync(async (tx) => {
3117
3166
  await this.eventRepo.add(newEvent, tx);
3118
3167
  const threshold = this.options.config?.autoLibrarianThreshold || 20;
@@ -3120,6 +3169,7 @@ var WriteService = class {
3120
3169
  this.eventRepo.count(entityId, tx),
3121
3170
  this.metadataRepo.getCheckpoint(entityId, tx)
3122
3171
  ]);
3172
+ eventCount = count;
3123
3173
  let memoryCheckpoint = cp.memory ?? 0;
3124
3174
  if (memoryCheckpoint > count) memoryCheckpoint = 0;
3125
3175
  if (count - memoryCheckpoint >= threshold) {
@@ -3141,6 +3191,8 @@ var WriteService = class {
3141
3191
  if (!(e instanceof WikiBusyError)) throw e;
3142
3192
  await this.metadataRepo.updateCheckpoint(entityId, { memory: prevMemoryCheckpoint }, this.db);
3143
3193
  }
3194
+ } else if (!this.jobManager.isBlocked("librarian", entityId)) {
3195
+ this.maybeRunHeal(entityId, eventCount).catch(console.error);
3144
3196
  }
3145
3197
  }
3146
3198
  async runLibrarianThenMaybeHeal(entityId, currentEventCount, prevCheckpoint) {
@@ -3151,6 +3203,14 @@ var WriteService = class {
3151
3203
  await this.metadataRepo.updateCheckpoint(entityId, { memory: prevCheckpoint }, this.db);
3152
3204
  throw e;
3153
3205
  }
3206
+ await this.maybeRunHeal(entityId, currentEventCount);
3207
+ }
3208
+ /**
3209
+ * Run one bounded auto-heal pass if the heal checkpoint has fallen
3210
+ * `autoHealThreshold` events behind. Called after every write (see
3211
+ * {@link write}) so a partial pass retries on the next write.
3212
+ */
3213
+ async maybeRunHeal(entityId, currentEventCount) {
3154
3214
  const autoHealThreshold = this.options.config?.autoHealThreshold || 100;
3155
3215
  const cp = await this.metadataRepo.getCheckpoint(entityId, this.db);
3156
3216
  let healCheckpoint = cp.heal ?? 0;
@@ -3158,8 +3218,10 @@ var WriteService = class {
3158
3218
  const shouldRunHeal = currentEventCount - healCheckpoint >= autoHealThreshold;
3159
3219
  if (shouldRunHeal && this.jobManager.tryAcquireAutoHealLock(entityId)) {
3160
3220
  try {
3161
- await this.maintenanceService.doRunHeal(entityId);
3162
- await this.metadataRepo.updateCheckpoint(entityId, { heal: currentEventCount }, this.db);
3221
+ const result = await this.maintenanceService.doRunHeal(entityId);
3222
+ if (result.remaining === 0) {
3223
+ await this.metadataRepo.updateCheckpoint(entityId, { heal: currentEventCount }, this.db);
3224
+ }
3163
3225
  } finally {
3164
3226
  this.jobManager.releaseLock("heal", entityId);
3165
3227
  }