@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.
package/dist/index.d.mts CHANGED
@@ -1,8 +1,53 @@
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
 
6
+ /**
7
+ * Slices a string like `String.prototype.slice`, but clamps out-of-range
8
+ * indices instead of relying on JS's implicit clamping, normalizes a
9
+ * start-after-end range by swapping the two bounds, and nudges either bound
10
+ * off a UTF-16 surrogate pair boundary so slicing never splits one code
11
+ * point in half.
12
+ *
13
+ * @param value - The source string to slice.
14
+ * @param start - Start index; negative counts from the end, out-of-range
15
+ * values are clamped to `[0, value.length]`.
16
+ * @param end - End index (exclusive); defaults to `value.length` when
17
+ * omitted. Same clamping/negative-index rules as `start`.
18
+ * @returns The sliced substring, never splitting a surrogate pair.
19
+ */
20
+ declare function safeSlice(value: string, start: number, end?: number): string;
21
+ /**
22
+ * Splits `input` into chunks of at most `maxChunkLength` characters,
23
+ * preferring to split on a paragraph break, then a sentence terminator,
24
+ * then whitespace, falling back to a hard cut only when none of those are
25
+ * found within the window. Consecutive chunks overlap by up to `overlap`
26
+ * characters so context isn't lost at a chunk boundary. This is the exact
27
+ * chunking algorithm `IngestionService.ingestDocument` uses before
28
+ * embedding, so callers can reproduce ingest-time chunk boundaries exactly
29
+ * given the same `maxChunkLength`/`overlap` (see `DEFAULT_MAX_CHUNK_LENGTH`
30
+ * and `DEFAULT_CHUNK_OVERLAP` for the defaults ingest uses).
31
+ *
32
+ * @param input - The text to chunk; leading/trailing whitespace is trimmed
33
+ * before chunking. Empty/whitespace-only input returns the empty result
34
+ * below without validating `maxChunkLength`/`overlap`.
35
+ * @param maxChunkLength - For non-empty input, maximum characters per chunk;
36
+ * must be an integer >= 2.
37
+ * @param overlap - For non-empty input, maximum number of characters each
38
+ * chunk repeats from the end of the previous chunk; must be a
39
+ * non-negative integer less than `maxChunkLength`. A chunk repeats fewer
40
+ * characters when the previous chunk was shorter than `overlap`.
41
+ * @returns `chunks` — the resulting chunk strings (empty array for
42
+ * empty/whitespace-only input); `truncated` — `true` if any split had to
43
+ * fall back to a hard cut (no paragraph/sentence/whitespace boundary
44
+ * found in the window).
45
+ */
46
+ declare function chunkText(input: string, maxChunkLength: number, overlap: number): {
47
+ chunks: string[];
48
+ truncated: boolean;
49
+ };
50
+
6
51
  declare function formatContext(bundle: MemoryBundle, options?: FormatContextOptions): string;
7
52
 
8
53
  /**
@@ -38,6 +83,22 @@ type GetRandomValues = (bytes: Uint8Array) => void;
38
83
  */
39
84
  declare function configureRandomSource(fn: GetRandomValues | null): void;
40
85
 
86
+ /**
87
+ * Default maximum characters per chunk that `ingestDocument` uses when a
88
+ * caller doesn't override `maxChunkLength` (directly or via
89
+ * `WikiOptions.config.maxChunkLength`).
90
+ */
91
+ declare const DEFAULT_MAX_CHUNK_LENGTH = 12000;
92
+ /**
93
+ * Default/fallback character overlap between consecutive chunks.
94
+ * `ingestDocument` uses this when a caller doesn't override `chunkOverlap`
95
+ * (directly or via `WikiOptions.config.chunkOverlap`), and clamps the
96
+ * effective overlap (including this default) to `maxChunkLength - 1` when
97
+ * needed. The clamp is a no-op for the shipped defaults; it can also apply
98
+ * when a custom `maxChunkLength` makes the resolved overlap too large.
99
+ */
100
+ declare const DEFAULT_CHUNK_OVERLAP = 400;
101
+
41
102
  interface LibrarianOptions {
42
103
  /** If provided, replaces the default Librarian system instructions. */
43
104
  systemPrompt?: string;
@@ -64,4 +125,4 @@ declare const ONTOLOGY_BACKFILL_SYSTEM_PROMPT = "You are a knowledge classificat
64
125
 
65
126
  declare function createWiki(db: SQLiteAdapter, options: WikiOptions): WikiMemory;
66
127
 
67
- export { DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT, FormatContextOptions, FormattedMemoryDump, GraphNeighborhood, type LibrarianOptions, type LibrarianPromptVariables, MemoryBundle, MemoryDump, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, type OkfImportOptions, OntologyManifest, ReadOptions, SQLiteAdapter, WikiMemory, WikiOptions, configureRandomSource, createWiki, formatContext, formatGraphContext, formatMemoryDump, formatOkfBundle, hydrateLibrarianPrompt, mapLibrarianOptionsToReadOptions, parseEmbedding, parseOkfBundle, validateLibrarianPromptTemplate, validateManifest };
128
+ export { DEFAULT_CHUNK_OVERLAP, DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT, DEFAULT_MAX_CHUNK_LENGTH, FormatContextOptions, FormattedMemoryDump, GraphNeighborhood, type LibrarianOptions, type LibrarianPromptVariables, MemoryBundle, MemoryDump, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, type OkfImportOptions, OntologyManifest, ReadOptions, SQLiteAdapter, WikiMemory, WikiOptions, chunkText, configureRandomSource, createWiki, formatContext, formatGraphContext, formatMemoryDump, formatOkfBundle, hydrateLibrarianPrompt, mapLibrarianOptionsToReadOptions, parseEmbedding, parseOkfBundle, safeSlice, validateLibrarianPromptTemplate, validateManifest };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,53 @@
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
 
6
+ /**
7
+ * Slices a string like `String.prototype.slice`, but clamps out-of-range
8
+ * indices instead of relying on JS's implicit clamping, normalizes a
9
+ * start-after-end range by swapping the two bounds, and nudges either bound
10
+ * off a UTF-16 surrogate pair boundary so slicing never splits one code
11
+ * point in half.
12
+ *
13
+ * @param value - The source string to slice.
14
+ * @param start - Start index; negative counts from the end, out-of-range
15
+ * values are clamped to `[0, value.length]`.
16
+ * @param end - End index (exclusive); defaults to `value.length` when
17
+ * omitted. Same clamping/negative-index rules as `start`.
18
+ * @returns The sliced substring, never splitting a surrogate pair.
19
+ */
20
+ declare function safeSlice(value: string, start: number, end?: number): string;
21
+ /**
22
+ * Splits `input` into chunks of at most `maxChunkLength` characters,
23
+ * preferring to split on a paragraph break, then a sentence terminator,
24
+ * then whitespace, falling back to a hard cut only when none of those are
25
+ * found within the window. Consecutive chunks overlap by up to `overlap`
26
+ * characters so context isn't lost at a chunk boundary. This is the exact
27
+ * chunking algorithm `IngestionService.ingestDocument` uses before
28
+ * embedding, so callers can reproduce ingest-time chunk boundaries exactly
29
+ * given the same `maxChunkLength`/`overlap` (see `DEFAULT_MAX_CHUNK_LENGTH`
30
+ * and `DEFAULT_CHUNK_OVERLAP` for the defaults ingest uses).
31
+ *
32
+ * @param input - The text to chunk; leading/trailing whitespace is trimmed
33
+ * before chunking. Empty/whitespace-only input returns the empty result
34
+ * below without validating `maxChunkLength`/`overlap`.
35
+ * @param maxChunkLength - For non-empty input, maximum characters per chunk;
36
+ * must be an integer >= 2.
37
+ * @param overlap - For non-empty input, maximum number of characters each
38
+ * chunk repeats from the end of the previous chunk; must be a
39
+ * non-negative integer less than `maxChunkLength`. A chunk repeats fewer
40
+ * characters when the previous chunk was shorter than `overlap`.
41
+ * @returns `chunks` — the resulting chunk strings (empty array for
42
+ * empty/whitespace-only input); `truncated` — `true` if any split had to
43
+ * fall back to a hard cut (no paragraph/sentence/whitespace boundary
44
+ * found in the window).
45
+ */
46
+ declare function chunkText(input: string, maxChunkLength: number, overlap: number): {
47
+ chunks: string[];
48
+ truncated: boolean;
49
+ };
50
+
6
51
  declare function formatContext(bundle: MemoryBundle, options?: FormatContextOptions): string;
7
52
 
8
53
  /**
@@ -38,6 +83,22 @@ type GetRandomValues = (bytes: Uint8Array) => void;
38
83
  */
39
84
  declare function configureRandomSource(fn: GetRandomValues | null): void;
40
85
 
86
+ /**
87
+ * Default maximum characters per chunk that `ingestDocument` uses when a
88
+ * caller doesn't override `maxChunkLength` (directly or via
89
+ * `WikiOptions.config.maxChunkLength`).
90
+ */
91
+ declare const DEFAULT_MAX_CHUNK_LENGTH = 12000;
92
+ /**
93
+ * Default/fallback character overlap between consecutive chunks.
94
+ * `ingestDocument` uses this when a caller doesn't override `chunkOverlap`
95
+ * (directly or via `WikiOptions.config.chunkOverlap`), and clamps the
96
+ * effective overlap (including this default) to `maxChunkLength - 1` when
97
+ * needed. The clamp is a no-op for the shipped defaults; it can also apply
98
+ * when a custom `maxChunkLength` makes the resolved overlap too large.
99
+ */
100
+ declare const DEFAULT_CHUNK_OVERLAP = 400;
101
+
41
102
  interface LibrarianOptions {
42
103
  /** If provided, replaces the default Librarian system instructions. */
43
104
  systemPrompt?: string;
@@ -64,4 +125,4 @@ declare const ONTOLOGY_BACKFILL_SYSTEM_PROMPT = "You are a knowledge classificat
64
125
 
65
126
  declare function createWiki(db: SQLiteAdapter, options: WikiOptions): WikiMemory;
66
127
 
67
- export { DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT, FormatContextOptions, FormattedMemoryDump, GraphNeighborhood, type LibrarianOptions, type LibrarianPromptVariables, MemoryBundle, MemoryDump, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, type OkfImportOptions, OntologyManifest, ReadOptions, SQLiteAdapter, WikiMemory, WikiOptions, configureRandomSource, createWiki, formatContext, formatGraphContext, formatMemoryDump, formatOkfBundle, hydrateLibrarianPrompt, mapLibrarianOptionsToReadOptions, parseEmbedding, parseOkfBundle, validateLibrarianPromptTemplate, validateManifest };
128
+ export { DEFAULT_CHUNK_OVERLAP, DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT, DEFAULT_MAX_CHUNK_LENGTH, FormatContextOptions, FormattedMemoryDump, GraphNeighborhood, type LibrarianOptions, type LibrarianPromptVariables, MemoryBundle, MemoryDump, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, type OkfImportOptions, OntologyManifest, ReadOptions, SQLiteAdapter, WikiMemory, WikiOptions, chunkText, configureRandomSource, createWiki, formatContext, formatGraphContext, formatMemoryDump, formatOkfBundle, hydrateLibrarianPrompt, mapLibrarianOptionsToReadOptions, parseEmbedding, parseOkfBundle, safeSlice, validateLibrarianPromptTemplate, validateManifest };
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
@@ -2988,6 +3063,10 @@ ${JSON.stringify(facts, null, 2)}`
2988
3063
  }
2989
3064
  };
2990
3065
 
3066
+ // src/utils/chunkingDefaults.ts
3067
+ var DEFAULT_MAX_CHUNK_LENGTH = 12e3;
3068
+ var DEFAULT_CHUNK_OVERLAP = 400;
3069
+
2991
3070
  // src/services/IngestionService.ts
2992
3071
  var IngestionService = class {
2993
3072
  constructor(db, prefix, options, entryRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
@@ -3006,10 +3085,10 @@ var IngestionService = class {
3006
3085
  if (!sourceRef) throw new Error("Invalid sourceRef");
3007
3086
  const sourceHash = normalizeSourceHash(params.sourceHash);
3008
3087
  if (!sourceHash) throw new Error("Invalid sourceHash (must be 64-char hex string)");
3009
- const maxChunkLength = params.maxChunkLength ?? this.options.config?.maxChunkLength ?? 12e3;
3010
- const rawOverlap = params.chunkOverlap ?? this.options.config?.chunkOverlap ?? 400;
3088
+ const maxChunkLength = params.maxChunkLength ?? this.options.config?.maxChunkLength ?? DEFAULT_MAX_CHUNK_LENGTH;
3089
+ const rawOverlap = params.chunkOverlap ?? this.options.config?.chunkOverlap ?? DEFAULT_CHUNK_OVERLAP;
3011
3090
  const chunkOverlap = Math.min(
3012
- Number.isFinite(rawOverlap) && rawOverlap >= 0 ? Math.floor(rawOverlap) : 400,
3091
+ Number.isFinite(rawOverlap) && rawOverlap >= 0 ? Math.floor(rawOverlap) : DEFAULT_CHUNK_OVERLAP,
3013
3092
  maxChunkLength - 1
3014
3093
  );
3015
3094
  const rawConcurrency = params.chunkConcurrency ?? this.options.config?.chunkConcurrency ?? 1;
@@ -3252,6 +3331,8 @@ var ONTOLOGY_BACKFILL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
3252
3331
  var HEAL_MAX_ANCHORS = 50;
3253
3332
  var HEAL_ANCHOR_SEARCH_OVERFETCH = 4;
3254
3333
  var HEAL_MAX_PROMPT_CHARS = 4e4;
3334
+ var HEAL_BATCH_SIZE = 25;
3335
+ var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
3255
3336
  var MaintenanceService = class {
3256
3337
  constructor(db, prefix, options, entryRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
3257
3338
  this.db = db;
@@ -3352,7 +3433,7 @@ var MaintenanceService = class {
3352
3433
  async runHeal(entityId, options) {
3353
3434
  this.jobManager.acquireLock("heal", entityId);
3354
3435
  try {
3355
- await this.doRunHeal(entityId, options?.promptOverride);
3436
+ return await this.doRunHeal(entityId, options);
3356
3437
  } finally {
3357
3438
  this.jobManager.releaseLock("heal", entityId);
3358
3439
  }
@@ -3604,9 +3685,21 @@ var MaintenanceService = class {
3604
3685
  }
3605
3686
  this.searchService.evictCache(entityId);
3606
3687
  }
3607
- /** Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook. */
3608
- async doRunHeal(entityId, promptOverride) {
3688
+ /**
3689
+ * Core heal pass (locks handled by {@link runHeal}). Package-internal orchestration hook.
3690
+ *
3691
+ * Bounded: at most `batchSize` candidates per pass (#67). Loop on
3692
+ * `result.remaining > 0` for convergence — see {@link HealResult.remaining}
3693
+ * for what convergence means here.
3694
+ */
3695
+ async doRunHeal(entityId, options) {
3696
+ const promptOverride = options?.promptOverride;
3697
+ const batchSize = options?.batchSize ?? HEAL_BATCH_SIZE;
3698
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
3699
+ throw new Error("Invalid batchSize: must be an integer >= 1");
3700
+ }
3609
3701
  const now = Date.now();
3702
+ const recheckCutoff = now - HEAL_RECHECK_MS;
3610
3703
  const orphanAfterDays = this.options.config?.orphanAfterDays !== void 0 ? this.options.config?.orphanAfterDays : 30;
3611
3704
  const staleInferredAfterDays = this.options.config?.staleInferredAfterDays !== void 0 ? this.options.config?.staleInferredAfterDays : 60;
3612
3705
  const MS_PER_DAY = 24 * 60 * 60 * 1e3;
@@ -3617,6 +3710,7 @@ var MaintenanceService = class {
3617
3710
  throw new Error("Invalid staleInferredAfterDays: must be a finite number >= 0 or null");
3618
3711
  }
3619
3712
  const orphanedIds = [];
3713
+ const staleDowngradedIds = [];
3620
3714
  await this.db.withTransactionAsync(async (tx) => {
3621
3715
  if (orphanAfterDays !== null) {
3622
3716
  const orphanThreshold = now - orphanAfterDays * MS_PER_DAY;
@@ -3624,7 +3718,7 @@ var MaintenanceService = class {
3624
3718
  }
3625
3719
  if (staleInferredAfterDays !== null) {
3626
3720
  const staleThreshold = now - staleInferredAfterDays * MS_PER_DAY;
3627
- await this.entryRepo.downgradeStaleInferred(entityId, staleThreshold, tx);
3721
+ staleDowngradedIds.push(...await this.entryRepo.downgradeStaleInferred(entityId, staleThreshold, tx));
3628
3722
  }
3629
3723
  });
3630
3724
  for (const factId of orphanedIds) {
@@ -3634,7 +3728,21 @@ var MaintenanceService = class {
3634
3728
  console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during heal orphan pass for ${factId}:`, hookErr);
3635
3729
  }
3636
3730
  }
3637
- const healCandidates = await this.entryRepo.findHealCandidatesByEntityId(entityId);
3731
+ const healCandidates = await this.entryRepo.findHealCandidatesByEntityId(entityId, batchSize, recheckCutoff);
3732
+ if (healCandidates.length === 0) {
3733
+ await this.searchService.sync(entityId);
3734
+ this.searchService.evictCache(entityId);
3735
+ const counts2 = await this.entryRepo.countHealCandidatesByEntityId(entityId, recheckCutoff);
3736
+ return {
3737
+ scanned: 0,
3738
+ downgraded: staleDowngradedIds.length,
3739
+ deleted: orphanedIds.length,
3740
+ newFactsCreated: 0,
3741
+ skipped: 0,
3742
+ remaining: counts2.eligible,
3743
+ deferred: counts2.deferred
3744
+ };
3745
+ }
3638
3746
  const allTasks = await this.taskRepo.findAllPending([entityId]);
3639
3747
  const recentEvents = await this.eventRepo.getRecent(entityId, 20);
3640
3748
  const toPromptShape = (f) => {
@@ -3687,7 +3795,7 @@ var MaintenanceService = class {
3687
3795
  const validNewFacts = newFacts.map(validateFact).filter((f) => f !== null);
3688
3796
  const insertedFacts = [];
3689
3797
  const uniqueDeletedFactIds = Array.from(new Set(safeDeleted));
3690
- const healFactsForDedupe = [...healCandidates];
3798
+ const healFactsForDedupe = (await this.entryRepo.findInferredTitlesByEntityId(entityId)).filter((f) => !safeDeletedSet.has(f.id));
3691
3799
  await this.db.withTransactionAsync(async (tx) => {
3692
3800
  await this.entryRepo.downgradeByIds(safeDowngraded, entityId, tx);
3693
3801
  await this.entryRepo.softDeleteByIds(safeDeleted, entityId, tx);
@@ -3696,7 +3804,6 @@ var MaintenanceService = class {
3696
3804
  let skip = false;
3697
3805
  if (newTokens.size >= MIN_TOKENS_TO_QUALIFY) {
3698
3806
  for (const existing of healFactsForDedupe) {
3699
- if (existing.source_type !== "librarian_inferred") continue;
3700
3807
  const existingTokens = titleTokens(existing.title);
3701
3808
  if (existingTokens.size >= MIN_TOKENS_TO_QUALIFY) {
3702
3809
  if (jaccardScore(newTokens, existingTokens) >= FUZZY_THRESHOLD) {
@@ -3726,8 +3833,14 @@ var MaintenanceService = class {
3726
3833
  };
3727
3834
  await this.entryRepo.upsert(factObj, tx);
3728
3835
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
3729
- healFactsForDedupe.push(factObj);
3836
+ healFactsForDedupe.push({ id, title: fact.title });
3730
3837
  }
3838
+ await this.entryRepo.markHealChecked(
3839
+ [...healCandidates.map((f) => f.id), ...insertedFacts.map((f) => f.id)],
3840
+ entityId,
3841
+ now,
3842
+ tx
3843
+ );
3731
3844
  });
3732
3845
  await this.searchService.sync(entityId);
3733
3846
  for (const factId of uniqueDeletedFactIds) {
@@ -3741,6 +3854,20 @@ var MaintenanceService = class {
3741
3854
  await this.embeddingService.embedFact(fact);
3742
3855
  }
3743
3856
  this.searchService.evictCache(entityId);
3857
+ let scanned = outcome.skipped.length;
3858
+ for (const batchResult of outcome.results) scanned += batchResult.batch.length;
3859
+ const allDowngraded = /* @__PURE__ */ new Set([...staleDowngradedIds, ...safeDowngraded]);
3860
+ const allDeleted = /* @__PURE__ */ new Set([...orphanedIds, ...uniqueDeletedFactIds]);
3861
+ const counts = await this.entryRepo.countHealCandidatesByEntityId(entityId, recheckCutoff);
3862
+ return {
3863
+ scanned,
3864
+ downgraded: allDowngraded.size,
3865
+ deleted: allDeleted.size,
3866
+ newFactsCreated: insertedFacts.length,
3867
+ skipped: outcome.skipped.length,
3868
+ remaining: counts.eligible,
3869
+ deferred: counts.deferred
3870
+ };
3744
3871
  }
3745
3872
  /** Core ontology backfill pass (locks handled by {@link runOntologyBackfill}). Package-internal orchestration hook. */
3746
3873
  async doRunOntologyBackfill(entityId, options) {
@@ -5116,6 +5243,7 @@ var WriteService = class {
5116
5243
  let shouldRunLibrarian = false;
5117
5244
  let librarianCount = 0;
5118
5245
  let prevMemoryCheckpoint = 0;
5246
+ let eventCount = 0;
5119
5247
  await this.db.withTransactionAsync(async (tx) => {
5120
5248
  await this.eventRepo.add(newEvent, tx);
5121
5249
  const threshold = this.options.config?.autoLibrarianThreshold || 20;
@@ -5123,6 +5251,7 @@ var WriteService = class {
5123
5251
  this.eventRepo.count(entityId, tx),
5124
5252
  this.metadataRepo.getCheckpoint(entityId, tx)
5125
5253
  ]);
5254
+ eventCount = count;
5126
5255
  let memoryCheckpoint = cp.memory ?? 0;
5127
5256
  if (memoryCheckpoint > count) memoryCheckpoint = 0;
5128
5257
  if (count - memoryCheckpoint >= threshold) {
@@ -5144,6 +5273,8 @@ var WriteService = class {
5144
5273
  if (!(e instanceof WikiBusyError)) throw e;
5145
5274
  await this.metadataRepo.updateCheckpoint(entityId, { memory: prevMemoryCheckpoint }, this.db);
5146
5275
  }
5276
+ } else if (!this.jobManager.isBlocked("librarian", entityId)) {
5277
+ this.maybeRunHeal(entityId, eventCount).catch(console.error);
5147
5278
  }
5148
5279
  }
5149
5280
  async runLibrarianThenMaybeHeal(entityId, currentEventCount, prevCheckpoint) {
@@ -5154,6 +5285,14 @@ var WriteService = class {
5154
5285
  await this.metadataRepo.updateCheckpoint(entityId, { memory: prevCheckpoint }, this.db);
5155
5286
  throw e;
5156
5287
  }
5288
+ await this.maybeRunHeal(entityId, currentEventCount);
5289
+ }
5290
+ /**
5291
+ * Run one bounded auto-heal pass if the heal checkpoint has fallen
5292
+ * `autoHealThreshold` events behind. Called after every write (see
5293
+ * {@link write}) so a partial pass retries on the next write.
5294
+ */
5295
+ async maybeRunHeal(entityId, currentEventCount) {
5157
5296
  const autoHealThreshold = this.options.config?.autoHealThreshold || 100;
5158
5297
  const cp = await this.metadataRepo.getCheckpoint(entityId, this.db);
5159
5298
  let healCheckpoint = cp.heal ?? 0;
@@ -5161,8 +5300,10 @@ var WriteService = class {
5161
5300
  const shouldRunHeal = currentEventCount - healCheckpoint >= autoHealThreshold;
5162
5301
  if (shouldRunHeal && this.jobManager.tryAcquireAutoHealLock(entityId)) {
5163
5302
  try {
5164
- await this.maintenanceService.doRunHeal(entityId);
5165
- await this.metadataRepo.updateCheckpoint(entityId, { heal: currentEventCount }, this.db);
5303
+ const result = await this.maintenanceService.doRunHeal(entityId);
5304
+ if (result.remaining === 0) {
5305
+ await this.metadataRepo.updateCheckpoint(entityId, { heal: currentEventCount }, this.db);
5306
+ }
5166
5307
  } finally {
5167
5308
  this.jobManager.releaseLock("heal", entityId);
5168
5309
  }
@@ -5513,9 +5654,23 @@ var WikiMemory = class {
5513
5654
  return this.maintenanceService.runLibrarian(entityId, options);
5514
5655
  }
5515
5656
  /**
5657
+ * Reviews stored facts with the LLM: removes orphans, downgrades stale
5658
+ * inferences, synthesizes corrections.
5659
+ *
5660
+ * Bounded per call: covers at most `batchSize` candidates (default 25), so
5661
+ * one call no longer sweeps the whole entity. Hosts own the cadence; loop
5662
+ * `while (result.remaining > 0)` for convergence.
5663
+ *
5664
+ * `remaining === 0` means "every mutable fact is inside the 7-day recheck
5665
+ * cooldown", not "there is no more work" — heal's candidate set is the live
5666
+ * mutable corpus, not a draining backlog, so it climbs back as the cooldown
5667
+ * lapses and as new facts are written. Do not write a loop expecting a drain.
5668
+ *
5516
5669
  * @param options.promptOverride - Applies only to this manual call. Does NOT affect
5517
5670
  * WriteService-triggered auto-runs. For persistent prompt customization across auto-runs,
5518
5671
  * set `options.config.prompts.healSystemPrompt` at WikiMemory construction time.
5672
+ * @param options.batchSize - Candidates per run (default 25) for providers with
5673
+ * tighter context limits.
5519
5674
  */
5520
5675
  async runHeal(entityId, options) {
5521
5676
  return this.maintenanceService.runHeal(entityId, options);
@@ -6375,7 +6530,11 @@ function createWiki(db, options) {
6375
6530
  return new WikiMemory(db, options);
6376
6531
  }
6377
6532
 
6533
+ exports.DEFAULT_CHUNK_OVERLAP = DEFAULT_CHUNK_OVERLAP;
6378
6534
  exports.DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT = DEFAULT_LIBRARIAN_SYNTHESIS_PROMPT;
6535
+ exports.DEFAULT_MAX_CHUNK_LENGTH = DEFAULT_MAX_CHUNK_LENGTH;
6536
+ exports.HEAL_BATCH_SIZE = HEAL_BATCH_SIZE;
6537
+ exports.HEAL_RECHECK_MS = HEAL_RECHECK_MS;
6379
6538
  exports.HOOK_TIMEOUT_MARKER = HOOK_TIMEOUT_MARKER;
6380
6539
  exports.ONTOLOGY_BACKFILL_BATCH_SIZE = ONTOLOGY_BACKFILL_BATCH_SIZE;
6381
6540
  exports.ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS = ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS;
@@ -6386,6 +6545,7 @@ exports.PrunePartialFailureError = PrunePartialFailureError;
6386
6545
  exports.WikiBusyError = WikiBusyError;
6387
6546
  exports.WikiMemory = WikiMemory;
6388
6547
  exports.WikiTransactionError = WikiTransactionError;
6548
+ exports.chunkText = chunkText;
6389
6549
  exports.configureRandomSource = configureRandomSource;
6390
6550
  exports.createWiki = createWiki;
6391
6551
  exports.formatContext = formatContext;
@@ -6396,6 +6556,7 @@ exports.hydrateLibrarianPrompt = hydrateLibrarianPrompt;
6396
6556
  exports.mapLibrarianOptionsToReadOptions = mapLibrarianOptionsToReadOptions;
6397
6557
  exports.parseEmbedding = parseEmbedding;
6398
6558
  exports.parseOkfBundle = parseOkfBundle;
6559
+ exports.safeSlice = safeSlice;
6399
6560
  exports.validateLibrarianPromptTemplate = validateLibrarianPromptTemplate;
6400
6561
  exports.validateManifest = validateManifest;
6401
6562
  //# sourceMappingURL=index.js.map