@equationalapplications/core-llm-wiki 7.8.0 → 7.9.1

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,7 +1,6 @@
1
- import { M as MemoryBundle, F as FormatContextOptions, G as GraphNeighborhood, a as MemoryDump, b as FormattedMemoryDump, R as ReadOptions, S as SQLiteAdapter, W as WikiOptions, c as WikiMemory } from './testing-CWZaXKLj.mjs';
2
- export { C as ChunkFailure, d as ClassifierAnswer, e as ClassifierQuestion, f as ClassifyRequest, g as ClassifyResponse, D as DegradedRecord, h as DraftPage, E as EmbedFactResult, i as EmbedFailureKind, j as EmbeddingMarkerKind, k as EntityMaintenanceReport, l as EntityStatus, m as ExtractedFact, n as ExtractedFactEdge, o as ExtractedFactWithOntology, p as ExtractedTask, q as GraphTraversalOptions, r as GroundingConfig, H as HEAL_BATCH_SIZE, s as HEAL_RECHECK_MS, t as HOOK_TIMEOUT_MARKER, u as HealResult, I as IngestDocumentResult, L as LLMProvider, v as LibrarianConfig, w as LibrarianGateConfig, x as MaintenanceJob, y as MaintenanceStopReason, O as ONTOLOGY_BACKFILL_BATCH_SIZE, z as ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, A as ONTOLOGY_BACKFILL_RECHECK_MS, B as OntologyBackfillResult, J as OntologyConfig, K as OntologyEdgeType, N as OntologyManifest, P as OntologyMode, Q as OntologyNodeType, T as OntologyPromptContext, U as OntologyUpdates, V as PendingMaintenance, X as PendingSourceStatus, Y as PromptOverrides, Z as PromptService, _ as PrunePartialFailureError, $ as ReembedResult, a0 as RunPendingMaintenanceOptions, a1 as RunPendingMaintenanceResult, a2 as SupersedeReplacement, a3 as VectorRanker, a4 as VectorRankerFallback, a5 as VectorRankerRankArgs, a6 as VectorRankerSemanticResult, a7 as WikiBusyError, a8 as WikiBusyOperation, a9 as WikiCheckpoint, aa as WikiConfig, ab as WikiDiagnostic, ac as WikiDiagnosticCode, ad as WikiDiagnosticDetail, ae as WikiDiagnosticOperation, af as WikiDiagnosticSeverity, ag as WikiDiagnosticTrigger, ah as WikiDraftNotFound, ai as WikiDuplicateHashError, aj as WikiEdge, ak as WikiEvent, al as WikiFact, am as WikiGraphNodeOwnershipConflict, an as WikiIngestEmptyError, ao as WikiInstructions, ap as WikiInvalidReadOptions, aq as WikiLintReport, ar as WikiMemoryTestAccess, as as WikiOutboxEvent, at as WikiParseError, au as WikiSourceRefHashCollision, av as WikiStrictOntologyViolation, aw as WikiSupersedeError, ax as WikiSupersedeReason, ay as WikiTask, az as WikiTransactionError, aA as validateManifest } from './testing-CWZaXKLj.mjs';
1
+ import { M as MemoryBundle, F as FormatContextOptions, G as GraphNeighborhood, a as MemoryDump, b as FormattedMemoryDump, R as ReadOptions, S as SQLiteAdapter, W as WikiOptions, c as WikiMemory } from './testing-Bf6etdUy.mjs';
2
+ export { C as ChunkFailure, d as ClassifierAnswer, e as ClassifierQuestion, f as ClassifyRequest, g as ClassifyResponse, D as DegradedRecord, h as DraftPage, E as EmbedFactResult, i as EmbedFailureKind, j as EmbeddingMarkerKind, k as EntityMaintenanceReport, l as EntityStatus, m as ExtractedFact, n as ExtractedFactEdge, o as ExtractedFactWithOntology, p as ExtractedTask, q as GraphTraversalOptions, r as GroundingConfig, H as HEAL_BATCH_SIZE, s as HEAL_RECHECK_MS, t as HOOK_TIMEOUT_MARKER, u as HealResult, I as IngestDocumentResult, L as LLMProvider, v as LibrarianConfig, w as LibrarianGateConfig, x as MaintenanceJob, y as MaintenanceStopReason, O as ONTOLOGY_BACKFILL_BATCH_SIZE, z as ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, A as ONTOLOGY_BACKFILL_RECHECK_MS, B as OntologyBackfillResult, J as OntologyConfig, K as OntologyEdgeType, N as OntologyManifest, P as OntologyMode, Q as OntologyNodeType, T as OntologyPromptContext, U as OntologyUpdates, V as PendingMaintenance, X as PendingSourceStatus, Y as PromptOverrides, Z as PromptService, _ as PrunePartialFailureError, $ as ReembedResult, a0 as RunPendingMaintenanceOptions, a1 as RunPendingMaintenanceResult, a2 as SupersedeReplacement, a3 as VectorRanker, a4 as VectorRankerFallback, a5 as VectorRankerRankArgs, a6 as VectorRankerSemanticResult, a7 as WikiBusyError, a8 as WikiBusyOperation, a9 as WikiCheckpoint, aa as WikiConfig, ab as WikiDiagnostic, ac as WikiDiagnosticCode, ad as WikiDiagnosticDetail, ae as WikiDiagnosticOperation, af as WikiDiagnosticSeverity, ag as WikiDiagnosticTrigger, ah as WikiDraftNotFound, ai as WikiDuplicateHashError, aj as WikiEdge, ak as WikiEvent, al as WikiFact, am as WikiGraphNodeOwnershipConflict, an as WikiIngestEmptyError, ao as WikiInstructions, ap as WikiInvalidReadOptions, aq as WikiLintReport, ar as WikiMemoryTestAccess, as as WikiOutboxEvent, at as WikiParseError, au as WikiSourceRefHashCollision, av as WikiStrictOntologyViolation, aw as WikiSupersedeError, ax as WikiSupersedeReason, ay as WikiTask, az as WikiTransactionError, aA as validateManifest } from './testing-Bf6etdUy.mjs';
3
3
  import { OkfFile } from '@equationalapplications/core-okf';
4
- import 'minisearch';
5
4
 
6
5
  /**
7
6
  * Slices a string like `String.prototype.slice`, but clamps out-of-range
package/dist/index.d.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { M as MemoryBundle, F as FormatContextOptions, G as GraphNeighborhood, a as MemoryDump, b as FormattedMemoryDump, R as ReadOptions, S as SQLiteAdapter, W as WikiOptions, c as WikiMemory } from './testing-CWZaXKLj.js';
2
- export { C as ChunkFailure, d as ClassifierAnswer, e as ClassifierQuestion, f as ClassifyRequest, g as ClassifyResponse, D as DegradedRecord, h as DraftPage, E as EmbedFactResult, i as EmbedFailureKind, j as EmbeddingMarkerKind, k as EntityMaintenanceReport, l as EntityStatus, m as ExtractedFact, n as ExtractedFactEdge, o as ExtractedFactWithOntology, p as ExtractedTask, q as GraphTraversalOptions, r as GroundingConfig, H as HEAL_BATCH_SIZE, s as HEAL_RECHECK_MS, t as HOOK_TIMEOUT_MARKER, u as HealResult, I as IngestDocumentResult, L as LLMProvider, v as LibrarianConfig, w as LibrarianGateConfig, x as MaintenanceJob, y as MaintenanceStopReason, O as ONTOLOGY_BACKFILL_BATCH_SIZE, z as ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, A as ONTOLOGY_BACKFILL_RECHECK_MS, B as OntologyBackfillResult, J as OntologyConfig, K as OntologyEdgeType, N as OntologyManifest, P as OntologyMode, Q as OntologyNodeType, T as OntologyPromptContext, U as OntologyUpdates, V as PendingMaintenance, X as PendingSourceStatus, Y as PromptOverrides, Z as PromptService, _ as PrunePartialFailureError, $ as ReembedResult, a0 as RunPendingMaintenanceOptions, a1 as RunPendingMaintenanceResult, a2 as SupersedeReplacement, a3 as VectorRanker, a4 as VectorRankerFallback, a5 as VectorRankerRankArgs, a6 as VectorRankerSemanticResult, a7 as WikiBusyError, a8 as WikiBusyOperation, a9 as WikiCheckpoint, aa as WikiConfig, ab as WikiDiagnostic, ac as WikiDiagnosticCode, ad as WikiDiagnosticDetail, ae as WikiDiagnosticOperation, af as WikiDiagnosticSeverity, ag as WikiDiagnosticTrigger, ah as WikiDraftNotFound, ai as WikiDuplicateHashError, aj as WikiEdge, ak as WikiEvent, al as WikiFact, am as WikiGraphNodeOwnershipConflict, an as WikiIngestEmptyError, ao as WikiInstructions, ap as WikiInvalidReadOptions, aq as WikiLintReport, ar as WikiMemoryTestAccess, as as WikiOutboxEvent, at as WikiParseError, au as WikiSourceRefHashCollision, av as WikiStrictOntologyViolation, aw as WikiSupersedeError, ax as WikiSupersedeReason, ay as WikiTask, az as WikiTransactionError, aA as validateManifest } from './testing-CWZaXKLj.js';
1
+ import { M as MemoryBundle, F as FormatContextOptions, G as GraphNeighborhood, a as MemoryDump, b as FormattedMemoryDump, R as ReadOptions, S as SQLiteAdapter, W as WikiOptions, c as WikiMemory } from './testing-Bf6etdUy.js';
2
+ export { C as ChunkFailure, d as ClassifierAnswer, e as ClassifierQuestion, f as ClassifyRequest, g as ClassifyResponse, D as DegradedRecord, h as DraftPage, E as EmbedFactResult, i as EmbedFailureKind, j as EmbeddingMarkerKind, k as EntityMaintenanceReport, l as EntityStatus, m as ExtractedFact, n as ExtractedFactEdge, o as ExtractedFactWithOntology, p as ExtractedTask, q as GraphTraversalOptions, r as GroundingConfig, H as HEAL_BATCH_SIZE, s as HEAL_RECHECK_MS, t as HOOK_TIMEOUT_MARKER, u as HealResult, I as IngestDocumentResult, L as LLMProvider, v as LibrarianConfig, w as LibrarianGateConfig, x as MaintenanceJob, y as MaintenanceStopReason, O as ONTOLOGY_BACKFILL_BATCH_SIZE, z as ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, A as ONTOLOGY_BACKFILL_RECHECK_MS, B as OntologyBackfillResult, J as OntologyConfig, K as OntologyEdgeType, N as OntologyManifest, P as OntologyMode, Q as OntologyNodeType, T as OntologyPromptContext, U as OntologyUpdates, V as PendingMaintenance, X as PendingSourceStatus, Y as PromptOverrides, Z as PromptService, _ as PrunePartialFailureError, $ as ReembedResult, a0 as RunPendingMaintenanceOptions, a1 as RunPendingMaintenanceResult, a2 as SupersedeReplacement, a3 as VectorRanker, a4 as VectorRankerFallback, a5 as VectorRankerRankArgs, a6 as VectorRankerSemanticResult, a7 as WikiBusyError, a8 as WikiBusyOperation, a9 as WikiCheckpoint, aa as WikiConfig, ab as WikiDiagnostic, ac as WikiDiagnosticCode, ad as WikiDiagnosticDetail, ae as WikiDiagnosticOperation, af as WikiDiagnosticSeverity, ag as WikiDiagnosticTrigger, ah as WikiDraftNotFound, ai as WikiDuplicateHashError, aj as WikiEdge, ak as WikiEvent, al as WikiFact, am as WikiGraphNodeOwnershipConflict, an as WikiIngestEmptyError, ao as WikiInstructions, ap as WikiInvalidReadOptions, aq as WikiLintReport, ar as WikiMemoryTestAccess, as as WikiOutboxEvent, at as WikiParseError, au as WikiSourceRefHashCollision, av as WikiStrictOntologyViolation, aw as WikiSupersedeError, ax as WikiSupersedeReason, ay as WikiTask, az as WikiTransactionError, aA as validateManifest } from './testing-Bf6etdUy.js';
3
3
  import { OkfFile } from '@equationalapplications/core-okf';
4
- import 'minisearch';
5
4
 
6
5
  /**
7
6
  * Slices a string like `String.prototype.slice`, but clamps out-of-range
package/dist/index.js CHANGED
@@ -3469,11 +3469,28 @@ function parseEmbedding(blob, text) {
3469
3469
  return null;
3470
3470
  }
3471
3471
 
3472
+ // src/utils/indexDoc.ts
3473
+ function toIndexDoc(row) {
3474
+ return {
3475
+ id: row.id,
3476
+ entity_id: row.entity_id,
3477
+ title: row.title,
3478
+ body: row.body,
3479
+ tags: (() => {
3480
+ try {
3481
+ const parsed = JSON.parse(row.tags);
3482
+ return Array.isArray(parsed) ? parsed.join(" ") : row.tags;
3483
+ } catch {
3484
+ return row.tags;
3485
+ }
3486
+ })()
3487
+ };
3488
+ }
3489
+
3472
3490
  // src/services/SearchService.ts
3473
3491
  var _SearchService = class _SearchService {
3474
- constructor(entryRepo) {
3492
+ constructor(entryRepo, indexStrategy) {
3475
3493
  this.entryRepo = entryRepo;
3476
- this.miniSearchEntryIdsByEntity = /* @__PURE__ */ new Map();
3477
3494
  this.vectorCache = /* @__PURE__ */ new Map();
3478
3495
  /**
3479
3496
  * Serializes rebuilds. `rebuildIndex` awaits a repository read between
@@ -3499,35 +3516,13 @@ var _SearchService = class _SearchService {
3499
3516
  * its flag must outlive the turn (#233 review).
3500
3517
  */
3501
3518
  this.staleEpochs = /* @__PURE__ */ new Map();
3502
- this.miniSearch = this.createMiniSearch();
3503
- }
3504
- /**
3505
- * A fresh index with the production options. clearAll() swaps one in because
3506
- * MiniSearch.removeAll() empties the index but leaves dirtCount (and its
3507
- * vacuum bookkeeping) at its old value, which would trip syncEntries'
3508
- * conditional vacuum early after a clear.
3509
- */
3510
- createMiniSearch() {
3511
- return new MiniSearch__default.default({
3512
- fields: ["title", "body", "tags"],
3513
- storeFields: ["entity_id"],
3514
- // Vacuuming is driven explicitly at the end of each serialized rebuild
3515
- // (see sync). Auto-vacuum fires on its own schedule, asynchronously with
3516
- // respect to the caller, and traversing the tree mid-rebuild is what
3517
- // threw the uncaught TypeError in MiniSearch.performVacuuming (#64).
3518
- autoVacuum: false,
3519
- searchOptions: {
3520
- boost: { title: 2 },
3521
- fuzzy: 0.2,
3522
- prefix: true
3523
- }
3524
- });
3519
+ this.indexStrategy = indexStrategy;
3525
3520
  }
3526
3521
  /**
3527
3522
  * Rebuilds the search index and clears the vector cache for a given entity.
3528
3523
  * A direct replacement for manually syncing state after a DB transaction.
3529
3524
  *
3530
- * Rebuilds are serialized per instance and never reject: the MiniSearch index
3525
+ * Rebuilds are serialized per instance and never reject: the keyword index
3531
3526
  * is a rebuildable cache over SQLite, so degraded keyword search is the
3532
3527
  * correct failure mode and killing the host process is not.
3533
3528
  */
@@ -3536,19 +3531,21 @@ var _SearchService = class _SearchService {
3536
3531
  try {
3537
3532
  try {
3538
3533
  const epochsBefore = new Map(this.staleEpochs);
3539
- await this.rebuildIndex(entityId);
3540
- if (entityId) {
3541
- if ((this.staleEpochs.get(entityId) ?? 0) === (epochsBefore.get(entityId) ?? 0)) {
3542
- this.staleEntities.delete(entityId);
3543
- }
3544
- } else {
3534
+ if (entityId === void 0) {
3535
+ const rows = await this.entryRepo.findMiniSearchRows();
3536
+ await this.indexStrategy.replaceAll(rows.map(toIndexDoc));
3545
3537
  for (const id of [...this.staleEntities]) {
3546
3538
  if ((this.staleEpochs.get(id) ?? 0) === (epochsBefore.get(id) ?? 0)) {
3547
3539
  this.staleEntities.delete(id);
3548
3540
  }
3549
3541
  }
3542
+ } else {
3543
+ await this.rebuildIndex(entityId);
3544
+ if ((this.staleEpochs.get(entityId) ?? 0) === (epochsBefore.get(entityId) ?? 0)) {
3545
+ this.staleEntities.delete(entityId);
3546
+ }
3550
3547
  }
3551
- await this.miniSearch.vacuum();
3548
+ await this.indexStrategy.vacuum?.();
3552
3549
  } finally {
3553
3550
  this.evictCache(entityId);
3554
3551
  }
@@ -3573,27 +3570,20 @@ var _SearchService = class _SearchService {
3573
3570
  */
3574
3571
  async syncEntries(entityId, ids) {
3575
3572
  const uniqueIds = [...new Set(ids)];
3576
- if (uniqueIds.length === 0 && !this.staleEntities.has(entityId) && this.miniSearchEntryIdsByEntity.has(entityId)) {
3573
+ const indexed = this.indexStrategy.hasIndexedEntity?.(entityId);
3574
+ if (uniqueIds.length === 0 && !this.staleEntities.has(entityId) && indexed === true) {
3577
3575
  return this.syncChain;
3578
3576
  }
3579
3577
  const work = this.syncChain.then(async () => {
3580
3578
  try {
3581
3579
  try {
3582
3580
  const epochsBefore = new Map(this.staleEpochs);
3583
- const needsRebuild = () => this.staleEntities.has(entityId) || !this.miniSearchEntryIdsByEntity.has(entityId);
3581
+ const needsRebuild = () => this.staleEntities.has(entityId) || this.indexStrategy.hasIndexedEntity?.(entityId) !== true;
3584
3582
  if (!needsRebuild()) {
3585
3583
  const rows = await this.entryRepo.findMiniSearchRowsByIds(entityId, uniqueIds);
3586
- const tracked = this.miniSearchEntryIdsByEntity.get(entityId);
3587
- if (tracked && !this.staleEntities.has(entityId)) {
3588
- for (const id of uniqueIds) {
3589
- if (tracked.delete(id) && this.miniSearch.has(id)) this.miniSearch.discard(id);
3590
- }
3591
- const documents = rows.map((row) => this.normalizeMiniSearchRow(row));
3592
- if (documents.length > 0) this.miniSearch.addAll(documents);
3593
- for (const document of documents) tracked.add(document.id);
3594
- if (this.miniSearch.dirtCount > 0) {
3595
- await this.miniSearch.vacuum();
3596
- }
3584
+ if (!this.staleEntities.has(entityId)) {
3585
+ await this.indexStrategy.replace(entityId, uniqueIds, rows.map(toIndexDoc));
3586
+ await this.indexStrategy.vacuum?.();
3597
3587
  return;
3598
3588
  }
3599
3589
  }
@@ -3601,7 +3591,7 @@ var _SearchService = class _SearchService {
3601
3591
  if ((this.staleEpochs.get(entityId) ?? 0) === (epochsBefore.get(entityId) ?? 0)) {
3602
3592
  this.staleEntities.delete(entityId);
3603
3593
  }
3604
- await this.miniSearch.vacuum();
3594
+ await this.indexStrategy.vacuum?.();
3605
3595
  } finally {
3606
3596
  this.evictCache(entityId);
3607
3597
  }
@@ -3621,6 +3611,16 @@ var _SearchService = class _SearchService {
3621
3611
  this.staleEntities.add(entityId);
3622
3612
  this.staleEpochs.set(entityId, (this.staleEpochs.get(entityId) ?? 0) + 1);
3623
3613
  }
3614
+ /**
3615
+ * Runs syncEntries(id, []) for each entity marked stale when this is called,
3616
+ * so only those entities are rebuilt. Each rebuild still takes its own turn
3617
+ * on the sync chain, and like syncEntries this never rejects. With nothing
3618
+ * stale it resolves at once, without waiting on work already on the chain.
3619
+ */
3620
+ async syncStale() {
3621
+ const ids = [...this.staleEntities];
3622
+ await Promise.all(ids.map((id) => this.syncEntries(id, [])));
3623
+ }
3624
3624
  /**
3625
3625
  * Clears the parsed vector cache. Useful for mid-loop flush guarantees
3626
3626
  * or memory pressure evictions.
@@ -3635,39 +3635,28 @@ var _SearchService = class _SearchService {
3635
3635
  /**
3636
3636
  * Fully resets the search service.
3637
3637
  */
3638
- clearAll() {
3638
+ async clearAll() {
3639
3639
  this.vectorCache.clear();
3640
- this.miniSearch = this.createMiniSearch();
3641
- this.miniSearchEntryIdsByEntity.clear();
3640
+ await this.indexStrategy.replaceAll([]);
3642
3641
  this.staleEntities.clear();
3643
3642
  this.staleEpochs.clear();
3644
3643
  }
3645
3644
  /**
3646
- * Executes a keyword search against the active MiniSearch index.
3645
+ * Executes a keyword search against the active index strategy.
3647
3646
  */
3648
- searchKeyword(query, entityIds, limit) {
3649
- const entityIdSet = new Set(entityIds);
3650
- const results = this.miniSearch.search(query, {
3651
- filter: (r) => entityIdSet.has(r.entity_id),
3652
- combineWith: "OR"
3653
- });
3647
+ async searchKeyword(query, entityIds, limit) {
3648
+ const results = finiteResults(await this.indexStrategy.search(query, { entityIds, limit }));
3654
3649
  return results.sort((a, b) => this._compareSearchResults(a, b)).slice(0, limit);
3655
3650
  }
3656
3651
  /**
3657
- * Pre-fetches MiniSearch scores for candidate hydration, used during hybrid weighting.
3652
+ * Pre-fetches keyword scores for candidate hydration, used during hybrid weighting.
3658
3653
  */
3659
- getMiniSearchScores(query, entityIds, preFilterLimit) {
3660
- const entityIdSet = new Set(entityIds);
3661
- let results = this.miniSearch.search(query, {
3662
- filter: (r) => entityIdSet.has(r.entity_id),
3663
- combineWith: "OR"
3664
- }).sort((a, b) => this._compareSearchResults(a, b));
3665
- if (preFilterLimit !== void 0) {
3666
- results = results.slice(0, preFilterLimit);
3667
- }
3654
+ async getKeywordScores(query, entityIds, preFilterLimit) {
3655
+ const opts = preFilterLimit !== void 0 ? { entityIds, limit: preFilterLimit, preFilterLimit } : { entityIds, limit: Number.MAX_SAFE_INTEGER };
3656
+ const results = finiteResults(await this.indexStrategy.search(query, opts));
3668
3657
  if (results.length === 0) return /* @__PURE__ */ new Map();
3669
- const maxMsScore = Math.max(1, results[0]?.score ?? 1);
3670
- return new Map(results.map((r) => [r.id, r.score / maxMsScore]));
3658
+ const maxScore = Math.max(1, results[0]?.score ?? 1);
3659
+ return new Map(results.map((r) => [r.id, r.score / maxScore]));
3671
3660
  }
3672
3661
  /**
3673
3662
  * Score candidate rows using in-process JS cosine similarity.
@@ -3729,53 +3718,16 @@ var _SearchService = class _SearchService {
3729
3718
  return scored.slice(0, limit);
3730
3719
  }
3731
3720
  // --- Internal Index Management ---
3721
+ /**
3722
+ * Rebuild a single entity's index from scratch. Reads every row for
3723
+ * `entityId` from the repository (already filtered by `deleted_at IS NULL`)
3724
+ * and hands the lot to `indexStrategy.replaceEntity`, which owns the
3725
+ * drop-then-add bookkeeping so callers don't need to know the strategy's
3726
+ * id-tracking shape.
3727
+ */
3732
3728
  async rebuildIndex(entityId) {
3733
- if (entityId) {
3734
- const rows2 = await this.entryRepo.findMiniSearchRows(entityId);
3735
- const previousIds = this.miniSearchEntryIdsByEntity.get(entityId);
3736
- if (previousIds) {
3737
- for (const id of previousIds) {
3738
- this.miniSearch.discard(id);
3739
- }
3740
- }
3741
- const documents2 = rows2.map((row) => this.normalizeMiniSearchRow(row));
3742
- if (documents2.length > 0) {
3743
- this.miniSearch.addAll(documents2);
3744
- }
3745
- this.miniSearchEntryIdsByEntity.set(
3746
- entityId,
3747
- new Set(documents2.map((document) => document.id))
3748
- );
3749
- return;
3750
- }
3751
- const rows = await this.entryRepo.findMiniSearchRows();
3752
- this.miniSearch.removeAll();
3753
- this.miniSearchEntryIdsByEntity.clear();
3754
- const documents = rows.map((row) => this.normalizeMiniSearchRow(row));
3755
- if (documents.length > 0) {
3756
- this.miniSearch.addAll(documents);
3757
- }
3758
- for (const document of documents) {
3759
- const ids = this.miniSearchEntryIdsByEntity.get(document.entity_id) ?? /* @__PURE__ */ new Set();
3760
- ids.add(document.id);
3761
- this.miniSearchEntryIdsByEntity.set(document.entity_id, ids);
3762
- }
3763
- }
3764
- normalizeMiniSearchRow(row) {
3765
- return {
3766
- id: row.id,
3767
- entity_id: row.entity_id,
3768
- title: row.title,
3769
- body: row.body,
3770
- tags: (() => {
3771
- try {
3772
- const parsed = JSON.parse(row.tags);
3773
- return Array.isArray(parsed) ? parsed.join(" ") : row.tags;
3774
- } catch {
3775
- return row.tags;
3776
- }
3777
- })()
3778
- };
3729
+ const rows = await this.entryRepo.findMiniSearchRows(entityId);
3730
+ await this.indexStrategy.replaceEntity(entityId, rows.map(toIndexDoc));
3779
3731
  }
3780
3732
  _tieBreakSort(items) {
3781
3733
  items.sort((a, b) => this._compareScoredRows(a, b));
@@ -3790,11 +3742,11 @@ var _SearchService = class _SearchService {
3790
3742
  return a.id.localeCompare(b.id);
3791
3743
  }
3792
3744
  /**
3793
- * MiniSearch breaks equal-score ties by internal insertion order, which
3794
- * syncEntries changes: a discarded-and-re-added id moves to the end. Every
3795
- * caller truncates these results to a limit, so which ids survive a tie at
3796
- * the boundary must not depend on write history. Re-sort exact score ties
3797
- * by id — the same final tie-break _compareScoredRows applies.
3745
+ * The keyword index breaks equal-score ties by internal insertion order,
3746
+ * which syncEntries changes: a discarded-and-re-added id moves to the end.
3747
+ * Every caller truncates these results to a limit, so which ids survive a
3748
+ * tie at the boundary must not depend on write history. Re-sort exact
3749
+ * score ties by id — the same final tie-break _compareScoredRows applies.
3798
3750
  */
3799
3751
  _compareSearchResults(a, b) {
3800
3752
  const scoreDiff = b.score - a.score;
@@ -3815,6 +3767,198 @@ _SearchService.MAX_VECTOR_CACHE_ENTITIES = 16;
3815
3767
  */
3816
3768
  _SearchService.MAX_VECTOR_CACHE_FACTS_PER_ENTITY = 500;
3817
3769
  var SearchService = _SearchService;
3770
+ function finiteResults(results) {
3771
+ return results.every((r) => Number.isFinite(r.score)) ? results : results.filter((r) => Number.isFinite(r.score));
3772
+ }
3773
+ var MiniSearchIndexStrategy = class {
3774
+ constructor() {
3775
+ /**
3776
+ * Per-entity id set. `replaceEntity()` and `hasIndexedEntity()` read it;
3777
+ * `addDocuments()` and `discardIds()` keep it in sync. `SearchService` does
3778
+ * not see this map; it only sees `hasIndexedEntity`. The previous
3779
+ * `miniSearchEntryIdsByEntity` lived on `SearchService`.
3780
+ */
3781
+ this.entryIdsByEntity = /* @__PURE__ */ new Map();
3782
+ /**
3783
+ * Reverse of `entryIdsByEntity`. `replace(entityId, ids, …)` uses it to
3784
+ * decide which ids are owned by `entityId` (the only safe membership test
3785
+ * — a rebuild that failed between its discard pass and the set replacement
3786
+ * can leave the forward set claiming ids the MiniSearch instance does not
3787
+ * hold). The pre-extraction `syncEntries` deleted from one set per id, not
3788
+ * from every entity's set.
3789
+ */
3790
+ this.entityIdById = /* @__PURE__ */ new Map();
3791
+ this.index = this.createIndex();
3792
+ }
3793
+ /**
3794
+ * Build a fresh MiniSearch instance with the project's index options.
3795
+ * Called from the constructor and from `replaceAll`. We do not use
3796
+ * `removeAll()` on an existing instance because that empties the index but
3797
+ * leaves `dirtCount` and the vacuum bookkeeping intact, which would trip
3798
+ * `syncEntries`' conditional vacuum early after a clear.
3799
+ */
3800
+ createIndex() {
3801
+ return new MiniSearch__default.default({
3802
+ fields: ["title", "body", "tags"],
3803
+ storeFields: ["entity_id"],
3804
+ // Vacuuming is driven explicitly at the end of each serialized rebuild
3805
+ // (see SearchService.syncEntries). Auto-vacuum fires on its own
3806
+ // schedule, asynchronously with respect to the caller, and traversing
3807
+ // the tree mid-rebuild is what threw the uncaught TypeError in
3808
+ // MiniSearch.performVacuuming (#64).
3809
+ autoVacuum: false,
3810
+ searchOptions: {
3811
+ boost: { title: 2 },
3812
+ fuzzy: 0.2,
3813
+ prefix: true
3814
+ }
3815
+ });
3816
+ }
3817
+ /**
3818
+ * Incremental sync. Removes only ids currently tracked under `entityId`
3819
+ * (other entities' docs are untouched), then upserts `documents` by id.
3820
+ * The same id appearing in both `ids` and `documents` is discarded first
3821
+ * so the upsert does not throw on a duplicate id.
3822
+ *
3823
+ * Every document must carry `entityId` as its `entity_id`; a mismatch is
3824
+ * rejected before the write mutates anything. A tracking-only call
3825
+ * (`replace(entityId, [], [])`) registers the entity as indexed, matching
3826
+ * the interface contract.
3827
+ *
3828
+ * Atomicity: never awaits between the discard pass and `addDocuments`, so
3829
+ * a concurrent `search()` sees the index wholly before or wholly after,
3830
+ * never half-updated. This is the precondition the pre-extraction
3831
+ * `syncEntries` kept with its "no await from here to `addAll`" comment.
3832
+ */
3833
+ async replace(entityId, ids, documents) {
3834
+ assertOwnedBy(entityId, documents);
3835
+ this.discardIds(ids, entityId);
3836
+ if (!this.entryIdsByEntity.has(entityId)) this.entryIdsByEntity.set(entityId, /* @__PURE__ */ new Set());
3837
+ this.addDocuments(documents);
3838
+ }
3839
+ /**
3840
+ * Per-entity rebuild. Removes every id tracked under `entityId` (with the
3841
+ * `index.has(id)` guard so a stale tracking set cannot make `discard()`
3842
+ * throw), then writes `documents`. The entity stays registered as
3843
+ * indexed — even when `documents` is empty — so the next `syncEntries`
3844
+ * call can take the fast path. Every document must belong to `entityId`.
3845
+ */
3846
+ async replaceEntity(entityId, documents) {
3847
+ assertOwnedBy(entityId, documents);
3848
+ const previous = this.entryIdsByEntity.get(entityId);
3849
+ if (previous) this.discardIds([...previous]);
3850
+ this.entryIdsByEntity.set(entityId, /* @__PURE__ */ new Set());
3851
+ this.addDocuments(documents);
3852
+ }
3853
+ /**
3854
+ * Global rebuild / clear. Replaces the MiniSearch instance and clears both
3855
+ * id maps, so `hasIndexedEntity` returns `false` for every entity afterwards.
3856
+ * `SearchService.clearAll()` calls `replaceAll([])`.
3857
+ */
3858
+ async replaceAll(documents) {
3859
+ this.index = this.createIndex();
3860
+ this.entryIdsByEntity.clear();
3861
+ this.entityIdById.clear();
3862
+ this.addDocuments(documents);
3863
+ }
3864
+ /**
3865
+ * Best-match-first search over the documents whose `entity_id` is in
3866
+ * `options.entityIds`. `preFilterLimit` overrides `limit` (hybrid retrieval
3867
+ * uses this to over-fetch candidates before the `limit` cap). Scores are
3868
+ * raw BM25+ — `SearchService.getKeywordScores` and `searchKeyword` apply
3869
+ * any scaling, not the strategy.
3870
+ */
3871
+ async search(query, options) {
3872
+ if (options.entityIds.length === 0) return [];
3873
+ const entityIdSet = new Set(options.entityIds);
3874
+ const limit = options.preFilterLimit ?? options.limit;
3875
+ const raw = this.index.search(query, {
3876
+ filter: (r) => entityIdSet.has(r.entity_id),
3877
+ combineWith: "OR"
3878
+ });
3879
+ raw.sort((a, b) => {
3880
+ const scoreDiff = b.score - a.score;
3881
+ if (!Number.isNaN(scoreDiff) && scoreDiff !== 0) return scoreDiff;
3882
+ return a.id.localeCompare(b.id);
3883
+ });
3884
+ return raw.slice(0, limit).map((r) => ({
3885
+ id: r.id,
3886
+ entity_id: r.entity_id,
3887
+ score: r.score
3888
+ }));
3889
+ }
3890
+ /**
3891
+ * Explicit vacuum. MiniSearch 7.2.0's `dirtCount` rule: search after an
3892
+ * un-vacuumed discard inverts the relative scores of term-sharing
3893
+ * documents at any index size. `SearchService.syncEntries` calls this
3894
+ * after a discard-bearing turn. No-op when `dirtCount === 0`.
3895
+ */
3896
+ async vacuum() {
3897
+ if (this.index.dirtCount > 0) {
3898
+ await this.index.vacuum();
3899
+ }
3900
+ }
3901
+ /**
3902
+ * `true` when `entityId` has been indexed at least once under the current
3903
+ * strategy state. Backed by `entryIdsByEntity.has(entityId)`, which is
3904
+ * kept in sync by `addDocuments`, `replaceEntity`, and `replaceAll`.
3905
+ */
3906
+ hasIndexedEntity(entityId) {
3907
+ return this.entryIdsByEntity.has(entityId);
3908
+ }
3909
+ /**
3910
+ * Add `documents` to the MiniSearch instance and register them in
3911
+ * `entryIdsByEntity` and `entityIdById`. MiniSearch mutates documents
3912
+ * during `add`, so each doc is copied first. `add` throws on an id the
3913
+ * index already holds — `replace` and `replaceEntity` discard first for
3914
+ * that reason.
3915
+ *
3916
+ * Adds and registers one document at a time: if a duplicate id throws
3917
+ * part-way through, every document already in the index is tracked, so
3918
+ * the next rebuild can discard it instead of failing on it again. This is
3919
+ * the precondition the per-turn recovery path relies on (#258 review).
3920
+ */
3921
+ addDocuments(documents) {
3922
+ for (const d of documents) {
3923
+ const doc = { ...d };
3924
+ this.index.add(doc);
3925
+ const set = this.entryIdsByEntity.get(doc.entity_id) ?? /* @__PURE__ */ new Set();
3926
+ set.add(doc.id);
3927
+ this.entryIdsByEntity.set(doc.entity_id, set);
3928
+ this.entityIdById.set(doc.id, doc.entity_id);
3929
+ }
3930
+ }
3931
+ /**
3932
+ * Discard `ids` from the MiniSearch instance and from both id maps, but
3933
+ * only for ids currently owned by `entityId` (the second argument). When
3934
+ * `entityId` is omitted (used by `replaceEntity` for the bulk rebuild
3935
+ * path), every id is discarded regardless of owning entity.
3936
+ *
3937
+ * `index.has(id)` is the membership test of record: a rebuild that failed
3938
+ * between its own discard pass and the set replacement can leave the
3939
+ * tracking set claiming ids the index no longer holds — `discard()` throws
3940
+ * on those, so we guard.
3941
+ */
3942
+ discardIds(ids, entityId) {
3943
+ for (const id of ids) {
3944
+ const owning = this.entityIdById.get(id);
3945
+ if (owning === void 0) continue;
3946
+ if (entityId !== void 0 && owning !== entityId) continue;
3947
+ if (this.index.has(id)) this.index.discard(id);
3948
+ this.entryIdsByEntity.get(owning)?.delete(id);
3949
+ this.entityIdById.delete(id);
3950
+ }
3951
+ }
3952
+ };
3953
+ function assertOwnedBy(entityId, documents) {
3954
+ for (const doc of documents) {
3955
+ if (doc.entity_id !== entityId) {
3956
+ throw new Error(
3957
+ `IndexStrategy write for entity ${entityId} got document ${doc.id} under entity ${doc.entity_id}`
3958
+ );
3959
+ }
3960
+ }
3961
+ }
3818
3962
 
3819
3963
  // src/services/JobManager.ts
3820
3964
  var JobManager = class {
@@ -6478,7 +6622,7 @@ async function gateCandidates(deps, ctx, candidates, cfg) {
6478
6622
  }
6479
6623
  }
6480
6624
  if (!ranked) {
6481
- ranked = deps.searchService.searchKeyword(`${c.fact.title} ${c.fact.body}`, [entityId], cfg.k + nonLive.size).filter((h) => !nonLive.has(h.id)).slice(0, cfg.k).map((h) => ({ id: h.id, score: null }));
6625
+ ranked = (await deps.searchService.searchKeyword(`${c.fact.title} ${c.fact.body}`, [entityId], cfg.k + nonLive.size)).filter((h) => !nonLive.has(h.id)).slice(0, cfg.k).map((h) => ({ id: h.id, score: null }));
6482
6626
  }
6483
6627
  const facts = ranked.length > 0 ? await deps.entryRepo.findByIds(ranked.map((r) => r.id), [entityId]) : [];
6484
6628
  const byId = new Map(facts.map((f) => [f.id, f]));
@@ -7921,7 +8065,7 @@ var MaintenanceService = class {
7921
8065
  if (!query) return [];
7922
8066
  const cached = cache?.get(query);
7923
8067
  if (cached) return cached;
7924
- const hits = this.searchService.searchKeyword(
8068
+ const hits = await this.searchService.searchKeyword(
7925
8069
  query,
7926
8070
  [entityId],
7927
8071
  cap * HEAL_ANCHOR_SEARCH_OVERFETCH
@@ -9093,7 +9237,7 @@ var RetrievalService = class {
9093
9237
  let miniSearchScores;
9094
9238
  if (effectivePreFilterLimit !== void 0) {
9095
9239
  populateCache = false;
9096
- const preResults = this.searchService.searchKeyword(trimmedQuery, scoredEntityIds, Number.MAX_SAFE_INTEGER).filter((r) => !excludedIds.has(r.id));
9240
+ const preResults = (await this.searchService.searchKeyword(trimmedQuery, scoredEntityIds, Number.MAX_SAFE_INTEGER)).filter((r) => !excludedIds.has(r.id));
9097
9241
  if (preResults.length === 0) {
9098
9242
  candidateRows = null;
9099
9243
  } else {
@@ -9120,7 +9264,7 @@ var RetrievalService = class {
9120
9264
  candidateRows = this._withoutIds(await this.entryRepo.findWithEmbeddingsByEntityIds(scoredEntityIds), excludedIds);
9121
9265
  }
9122
9266
  if (weight !== void 0 && weight < 1) {
9123
- miniSearchScores = this.searchService.getMiniSearchScores(trimmedQuery, scoredEntityIds);
9267
+ miniSearchScores = await this.searchService.getKeywordScores(trimmedQuery, scoredEntityIds);
9124
9268
  }
9125
9269
  }
9126
9270
  if (candidateRows === null) {
@@ -9363,7 +9507,7 @@ var RetrievalService = class {
9363
9507
  const hasActiveFloorsKeywordFallback = sanitizedTierFloors !== void 0 && Object.values(sanitizedTierFloors).some((f) => f > 0);
9364
9508
  const keywordOversampledLimit = hasActiveFloorsKeywordFallback ? Number.MAX_SAFE_INTEGER : padLimit(Math.max(maxResults * 2, maxResults + 50));
9365
9509
  const preFilteredIds = effectivePreFilterLimit !== void 0 ? new Set(candidateRows.map((r) => r.id)) : void 0;
9366
- const keywordResults = this.searchService.searchKeyword(trimmedQuery, scoredEntityIds, keywordOversampledLimit);
9510
+ const keywordResults = await this.searchService.searchKeyword(trimmedQuery, scoredEntityIds, keywordOversampledLimit);
9367
9511
  const topResults = preFilteredIds === void 0 ? keywordResults : keywordResults.filter((r) => preFilteredIds.has(r.id));
9368
9512
  const topResultIds = new Set(topResults.map((r) => r.id));
9369
9513
  const candidateMap = new Map(candidateRows.filter((r) => topResultIds.has(r.id)).map((row) => [row.id, row]));
@@ -9466,7 +9610,7 @@ var RetrievalService = class {
9466
9610
  if (!usedEmbed && scoredEntityIds.length > 0) {
9467
9611
  const hasActiveFloors = sanitizedTierFloors !== void 0 && Object.values(sanitizedTierFloors).some((f) => f > 0);
9468
9612
  const fallbackOversampledLimit = hasActiveFloors ? Number.MAX_SAFE_INTEGER : padLimit(Math.max(maxResults * 2, maxResults + 50));
9469
- const results = this.searchService.searchKeyword(trimmedQuery, scoredEntityIds, fallbackOversampledLimit).filter((r) => !excludedIds.has(r.id));
9613
+ const results = (await this.searchService.searchKeyword(trimmedQuery, scoredEntityIds, fallbackOversampledLimit)).filter((r) => !excludedIds.has(r.id));
9470
9614
  const candidates = results.map((r) => ({
9471
9615
  id: r.id,
9472
9616
  entity_id: r.entity_id,
@@ -10298,7 +10442,10 @@ var WikiMemory = class {
10298
10442
  );
10299
10443
  this.lintService = new LintService(this.lintRepo, this.ontologyService);
10300
10444
  this.embeddingService = new EmbeddingService(this.db, this.options, this.entryRepo, this.metadataRepo);
10301
- this.searchService = new SearchService(this.entryRepo);
10445
+ this.searchService = new SearchService(
10446
+ this.entryRepo,
10447
+ new MiniSearchIndexStrategy()
10448
+ );
10302
10449
  this.temporalService = new TemporalService(this.db, this.options, this.entryRepo, this.searchService, this.embeddingService);
10303
10450
  this.jobManager = new JobManager(this.prefix);
10304
10451
  this.promptService = new PromptService(options.config?.prompts, resolveGrounding(options.config?.grounding));
@@ -10691,6 +10838,32 @@ var WikiMemory = class {
10691
10838
  clearVectorCache() {
10692
10839
  this.searchService.evictCache();
10693
10840
  }
10841
+ /**
10842
+ * Brings the keyword search index up to date with SQLite after a host
10843
+ * transaction that used `upsertGraph` has committed. Core never sees that
10844
+ * commit, so without this the nodes stay out of keyword, hybrid and
10845
+ * fallback retrieval until the entity's next core write.
10846
+ *
10847
+ * With `entityId`, rebuilds that entity if it is stale or has never been
10848
+ * indexed; otherwise it is a no-op that still waits for rebuilds already
10849
+ * queued. Without an argument, rebuilds every entity currently marked
10850
+ * stale; when none is, it resolves immediately and does not wait for rebuilds
10851
+ * already queued.
10852
+ *
10853
+ * Call only after the host's transaction commits: called inside it, the
10854
+ * rebuild may index rows that later roll back, or miss them and clear the
10855
+ * stale flag anyway. Serialized with core's internal index syncs and takes
10856
+ * no maintenance lock. Never rejects for a valid `entityId` — on failure the
10857
+ * entity stays stale and the next call retries it. Does not compute
10858
+ * embeddings; that remains the maintenance sweep's job.
10859
+ *
10860
+ * @throws {TypeError} when `entityId` is given but is not a non-empty string.
10861
+ */
10862
+ async syncSearchIndex(entityId) {
10863
+ if (entityId === void 0) return this.searchService.syncStale();
10864
+ assertEntityId(entityId);
10865
+ return this.searchService.syncEntries(entityId, []);
10866
+ }
10694
10867
  async exportDump(entityIds) {
10695
10868
  return this.importExportService.exportDump(entityIds);
10696
10869
  }
@@ -10737,9 +10910,11 @@ var WikiMemory = class {
10737
10910
  * MUST be called from inside an open `db.withTransactionAsync` callback —
10738
10911
  * the third argument is the caller's `tx`. The method does not open a
10739
10912
  * nested transaction, does not acquire any lock, and does not perform
10740
- * post-commit work (search sync, embedding, cache eviction). Hosts that
10741
- * want embeddings synced should drive the existing maintenance sweep
10742
- * (`runLibrarian` / `runHeal` / `runOntologyBackfill` / `runPrune`,
10913
+ * post-commit work (search sync, embedding, cache eviction). After the
10914
+ * transaction commits, call `syncSearchIndex(entityId)` — or
10915
+ * `syncSearchIndex()` after writing several entities in one transaction —
10916
+ * to make the new nodes keyword-searchable. Embeddings still come from the maintenance
10917
+ * sweep (`runLibrarian` / `runHeal` / `runOntologyBackfill` / `runPrune`,
10743
10918
  * scoped via `listEntityIds`).
10744
10919
  *
10745
10920
  * Contract (full pre-flight validation lives inside