@equationalapplications/core-llm-wiki 4.23.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -969,6 +969,10 @@ ${JSON.stringify(facts, null, 2)}`
969
969
  }
970
970
  };
971
971
 
972
+ // src/utils/chunkingDefaults.ts
973
+ var DEFAULT_MAX_CHUNK_LENGTH = 12e3;
974
+ var DEFAULT_CHUNK_OVERLAP = 400;
975
+
972
976
  // src/services/IngestionService.ts
973
977
  var IngestionService = class {
974
978
  constructor(db, prefix, options, entryRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
@@ -987,10 +991,10 @@ var IngestionService = class {
987
991
  if (!sourceRef) throw new Error("Invalid sourceRef");
988
992
  const sourceHash = normalizeSourceHash(params.sourceHash);
989
993
  if (!sourceHash) throw new Error("Invalid sourceHash (must be 64-char hex string)");
990
- const maxChunkLength = params.maxChunkLength ?? this.options.config?.maxChunkLength ?? 12e3;
991
- const rawOverlap = params.chunkOverlap ?? this.options.config?.chunkOverlap ?? 400;
994
+ const maxChunkLength = params.maxChunkLength ?? this.options.config?.maxChunkLength ?? DEFAULT_MAX_CHUNK_LENGTH;
995
+ const rawOverlap = params.chunkOverlap ?? this.options.config?.chunkOverlap ?? DEFAULT_CHUNK_OVERLAP;
992
996
  const chunkOverlap = Math.min(
993
- Number.isFinite(rawOverlap) && rawOverlap >= 0 ? Math.floor(rawOverlap) : 400,
997
+ Number.isFinite(rawOverlap) && rawOverlap >= 0 ? Math.floor(rawOverlap) : DEFAULT_CHUNK_OVERLAP,
994
998
  maxChunkLength - 1
995
999
  );
996
1000
  const rawConcurrency = params.chunkConcurrency ?? this.options.config?.chunkConcurrency ?? 1;
@@ -1261,6 +1265,8 @@ var ONTOLOGY_BACKFILL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1261
1265
  var HEAL_MAX_ANCHORS = 50;
1262
1266
  var HEAL_ANCHOR_SEARCH_OVERFETCH = 4;
1263
1267
  var HEAL_MAX_PROMPT_CHARS = 4e4;
1268
+ var HEAL_BATCH_SIZE = 25;
1269
+ var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1264
1270
  var MaintenanceService = class {
1265
1271
  constructor(db, prefix, options, entryRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1266
1272
  this.db = db;
@@ -1361,7 +1367,7 @@ var MaintenanceService = class {
1361
1367
  async runHeal(entityId, options) {
1362
1368
  this.jobManager.acquireLock("heal", entityId);
1363
1369
  try {
1364
- await this.doRunHeal(entityId, options?.promptOverride);
1370
+ return await this.doRunHeal(entityId, options);
1365
1371
  } finally {
1366
1372
  this.jobManager.releaseLock("heal", entityId);
1367
1373
  }
@@ -1613,9 +1619,21 @@ var MaintenanceService = class {
1613
1619
  }
1614
1620
  this.searchService.evictCache(entityId);
1615
1621
  }
1616
- /** Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook. */
1617
- async doRunHeal(entityId, promptOverride) {
1622
+ /**
1623
+ * Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook.
1624
+ *
1625
+ * Bounded: at most `batchSize` candidates per pass (#67). Loop on
1626
+ * `result.remaining > 0` for convergence — see {@link HealResult.remaining}
1627
+ * for what convergence means here.
1628
+ */
1629
+ async doRunHeal(entityId, options) {
1630
+ const promptOverride = options?.promptOverride;
1631
+ const batchSize = options?.batchSize ?? HEAL_BATCH_SIZE;
1632
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
1633
+ throw new Error("Invalid batchSize: must be an integer >= 1");
1634
+ }
1618
1635
  const now = Date.now();
1636
+ const recheckCutoff = now - HEAL_RECHECK_MS;
1619
1637
  const orphanAfterDays = this.options.config?.orphanAfterDays !== void 0 ? this.options.config?.orphanAfterDays : 30;
1620
1638
  const staleInferredAfterDays = this.options.config?.staleInferredAfterDays !== void 0 ? this.options.config?.staleInferredAfterDays : 60;
1621
1639
  const MS_PER_DAY = 24 * 60 * 60 * 1e3;
@@ -1626,6 +1644,7 @@ var MaintenanceService = class {
1626
1644
  throw new Error("Invalid staleInferredAfterDays: must be a finite number >= 0 or null");
1627
1645
  }
1628
1646
  const orphanedIds = [];
1647
+ const staleDowngradedIds = [];
1629
1648
  await this.db.withTransactionAsync(async (tx) => {
1630
1649
  if (orphanAfterDays !== null) {
1631
1650
  const orphanThreshold = now - orphanAfterDays * MS_PER_DAY;
@@ -1633,7 +1652,7 @@ var MaintenanceService = class {
1633
1652
  }
1634
1653
  if (staleInferredAfterDays !== null) {
1635
1654
  const staleThreshold = now - staleInferredAfterDays * MS_PER_DAY;
1636
- await this.entryRepo.downgradeStaleInferred(entityId, staleThreshold, tx);
1655
+ staleDowngradedIds.push(...await this.entryRepo.downgradeStaleInferred(entityId, staleThreshold, tx));
1637
1656
  }
1638
1657
  });
1639
1658
  for (const factId of orphanedIds) {
@@ -1643,7 +1662,21 @@ var MaintenanceService = class {
1643
1662
  console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during heal orphan pass for ${factId}:`, hookErr);
1644
1663
  }
1645
1664
  }
1646
- const healCandidates = await this.entryRepo.findHealCandidatesByEntityId(entityId);
1665
+ const healCandidates = await this.entryRepo.findHealCandidatesByEntityId(entityId, batchSize, recheckCutoff);
1666
+ if (healCandidates.length === 0) {
1667
+ await this.searchService.sync(entityId);
1668
+ this.searchService.evictCache(entityId);
1669
+ const counts2 = await this.entryRepo.countHealCandidatesByEntityId(entityId, recheckCutoff);
1670
+ return {
1671
+ scanned: 0,
1672
+ downgraded: staleDowngradedIds.length,
1673
+ deleted: orphanedIds.length,
1674
+ newFactsCreated: 0,
1675
+ skipped: 0,
1676
+ remaining: counts2.eligible,
1677
+ deferred: counts2.deferred
1678
+ };
1679
+ }
1647
1680
  const allTasks = await this.taskRepo.findAllPending([entityId]);
1648
1681
  const recentEvents = await this.eventRepo.getRecent(entityId, 20);
1649
1682
  const toPromptShape = (f) => {
@@ -1696,7 +1729,7 @@ var MaintenanceService = class {
1696
1729
  const validNewFacts = newFacts.map(validateFact).filter((f) => f !== null);
1697
1730
  const insertedFacts = [];
1698
1731
  const uniqueDeletedFactIds = Array.from(new Set(safeDeleted));
1699
- const healFactsForDedupe = [...healCandidates];
1732
+ const healFactsForDedupe = (await this.entryRepo.findInferredTitlesByEntityId(entityId)).filter((f) => !safeDeletedSet.has(f.id));
1700
1733
  await this.db.withTransactionAsync(async (tx) => {
1701
1734
  await this.entryRepo.downgradeByIds(safeDowngraded, entityId, tx);
1702
1735
  await this.entryRepo.softDeleteByIds(safeDeleted, entityId, tx);
@@ -1705,7 +1738,6 @@ var MaintenanceService = class {
1705
1738
  let skip = false;
1706
1739
  if (newTokens.size >= MIN_TOKENS_TO_QUALIFY) {
1707
1740
  for (const existing of healFactsForDedupe) {
1708
- if (existing.source_type !== "librarian_inferred") continue;
1709
1741
  const existingTokens = titleTokens(existing.title);
1710
1742
  if (existingTokens.size >= MIN_TOKENS_TO_QUALIFY) {
1711
1743
  if (jaccardScore(newTokens, existingTokens) >= FUZZY_THRESHOLD) {
@@ -1735,8 +1767,14 @@ var MaintenanceService = class {
1735
1767
  };
1736
1768
  await this.entryRepo.upsert(factObj, tx);
1737
1769
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1738
- healFactsForDedupe.push(factObj);
1770
+ healFactsForDedupe.push({ id, title: fact.title });
1739
1771
  }
1772
+ await this.entryRepo.markHealChecked(
1773
+ [...healCandidates.map((f) => f.id), ...insertedFacts.map((f) => f.id)],
1774
+ entityId,
1775
+ now,
1776
+ tx
1777
+ );
1740
1778
  });
1741
1779
  await this.searchService.sync(entityId);
1742
1780
  for (const factId of uniqueDeletedFactIds) {
@@ -1750,6 +1788,20 @@ var MaintenanceService = class {
1750
1788
  await this.embeddingService.embedFact(fact);
1751
1789
  }
1752
1790
  this.searchService.evictCache(entityId);
1791
+ let scanned = outcome.skipped.length;
1792
+ for (const batchResult of outcome.results) scanned += batchResult.batch.length;
1793
+ const allDowngraded = /* @__PURE__ */ new Set([...staleDowngradedIds, ...safeDowngraded]);
1794
+ const allDeleted = /* @__PURE__ */ new Set([...orphanedIds, ...uniqueDeletedFactIds]);
1795
+ const counts = await this.entryRepo.countHealCandidatesByEntityId(entityId, recheckCutoff);
1796
+ return {
1797
+ scanned,
1798
+ downgraded: allDowngraded.size,
1799
+ deleted: allDeleted.size,
1800
+ newFactsCreated: insertedFacts.length,
1801
+ skipped: outcome.skipped.length,
1802
+ remaining: counts.eligible,
1803
+ deferred: counts.deferred
1804
+ };
1753
1805
  }
1754
1806
  /** Core ontology backfill pass (locks handled by {@link runOntologyBackfill}). Package-internal orchestration hook. */
1755
1807
  async doRunOntologyBackfill(entityId, options) {
@@ -3113,6 +3165,7 @@ var WriteService = class {
3113
3165
  let shouldRunLibrarian = false;
3114
3166
  let librarianCount = 0;
3115
3167
  let prevMemoryCheckpoint = 0;
3168
+ let eventCount = 0;
3116
3169
  await this.db.withTransactionAsync(async (tx) => {
3117
3170
  await this.eventRepo.add(newEvent, tx);
3118
3171
  const threshold = this.options.config?.autoLibrarianThreshold || 20;
@@ -3120,6 +3173,7 @@ var WriteService = class {
3120
3173
  this.eventRepo.count(entityId, tx),
3121
3174
  this.metadataRepo.getCheckpoint(entityId, tx)
3122
3175
  ]);
3176
+ eventCount = count;
3123
3177
  let memoryCheckpoint = cp.memory ?? 0;
3124
3178
  if (memoryCheckpoint > count) memoryCheckpoint = 0;
3125
3179
  if (count - memoryCheckpoint >= threshold) {
@@ -3141,6 +3195,8 @@ var WriteService = class {
3141
3195
  if (!(e instanceof WikiBusyError)) throw e;
3142
3196
  await this.metadataRepo.updateCheckpoint(entityId, { memory: prevMemoryCheckpoint }, this.db);
3143
3197
  }
3198
+ } else if (!this.jobManager.isBlocked("librarian", entityId)) {
3199
+ this.maybeRunHeal(entityId, eventCount).catch(console.error);
3144
3200
  }
3145
3201
  }
3146
3202
  async runLibrarianThenMaybeHeal(entityId, currentEventCount, prevCheckpoint) {
@@ -3151,6 +3207,14 @@ var WriteService = class {
3151
3207
  await this.metadataRepo.updateCheckpoint(entityId, { memory: prevCheckpoint }, this.db);
3152
3208
  throw e;
3153
3209
  }
3210
+ await this.maybeRunHeal(entityId, currentEventCount);
3211
+ }
3212
+ /**
3213
+ * Run one bounded auto-heal pass if the heal checkpoint has fallen
3214
+ * `autoHealThreshold` events behind. Called after every write (see
3215
+ * {@link write}) so a partial pass retries on the next write.
3216
+ */
3217
+ async maybeRunHeal(entityId, currentEventCount) {
3154
3218
  const autoHealThreshold = this.options.config?.autoHealThreshold || 100;
3155
3219
  const cp = await this.metadataRepo.getCheckpoint(entityId, this.db);
3156
3220
  let healCheckpoint = cp.heal ?? 0;
@@ -3158,8 +3222,10 @@ var WriteService = class {
3158
3222
  const shouldRunHeal = currentEventCount - healCheckpoint >= autoHealThreshold;
3159
3223
  if (shouldRunHeal && this.jobManager.tryAcquireAutoHealLock(entityId)) {
3160
3224
  try {
3161
- await this.maintenanceService.doRunHeal(entityId);
3162
- await this.metadataRepo.updateCheckpoint(entityId, { heal: currentEventCount }, this.db);
3225
+ const result = await this.maintenanceService.doRunHeal(entityId);
3226
+ if (result.remaining === 0) {
3227
+ await this.metadataRepo.updateCheckpoint(entityId, { heal: currentEventCount }, this.db);
3228
+ }
3163
3229
  } finally {
3164
3230
  this.jobManager.releaseLock("heal", entityId);
3165
3231
  }