@equationalapplications/core-llm-wiki 7.9.0 → 7.9.2

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-Kb1uVj1E.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-Kb1uVj1E.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-Kb1uVj1E.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-Kb1uVj1E.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
  }
@@ -3645,39 +3635,28 @@ var _SearchService = class _SearchService {
3645
3635
  /**
3646
3636
  * Fully resets the search service.
3647
3637
  */
3648
- clearAll() {
3638
+ async clearAll() {
3649
3639
  this.vectorCache.clear();
3650
- this.miniSearch = this.createMiniSearch();
3651
- this.miniSearchEntryIdsByEntity.clear();
3640
+ await this.indexStrategy.replaceAll([]);
3652
3641
  this.staleEntities.clear();
3653
3642
  this.staleEpochs.clear();
3654
3643
  }
3655
3644
  /**
3656
- * Executes a keyword search against the active MiniSearch index.
3645
+ * Executes a keyword search against the active index strategy.
3657
3646
  */
3658
- searchKeyword(query, entityIds, limit) {
3659
- const entityIdSet = new Set(entityIds);
3660
- const results = this.miniSearch.search(query, {
3661
- filter: (r) => entityIdSet.has(r.entity_id),
3662
- combineWith: "OR"
3663
- });
3647
+ async searchKeyword(query, entityIds, limit) {
3648
+ const results = finiteResults(await this.indexStrategy.search(query, { entityIds, limit }));
3664
3649
  return results.sort((a, b) => this._compareSearchResults(a, b)).slice(0, limit);
3665
3650
  }
3666
3651
  /**
3667
- * Pre-fetches MiniSearch scores for candidate hydration, used during hybrid weighting.
3652
+ * Pre-fetches keyword scores for candidate hydration, used during hybrid weighting.
3668
3653
  */
3669
- getMiniSearchScores(query, entityIds, preFilterLimit) {
3670
- const entityIdSet = new Set(entityIds);
3671
- let results = this.miniSearch.search(query, {
3672
- filter: (r) => entityIdSet.has(r.entity_id),
3673
- combineWith: "OR"
3674
- }).sort((a, b) => this._compareSearchResults(a, b));
3675
- if (preFilterLimit !== void 0) {
3676
- results = results.slice(0, preFilterLimit);
3677
- }
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));
3678
3657
  if (results.length === 0) return /* @__PURE__ */ new Map();
3679
- const maxMsScore = Math.max(1, results[0]?.score ?? 1);
3680
- 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]));
3681
3660
  }
3682
3661
  /**
3683
3662
  * Score candidate rows using in-process JS cosine similarity.
@@ -3739,53 +3718,16 @@ var _SearchService = class _SearchService {
3739
3718
  return scored.slice(0, limit);
3740
3719
  }
3741
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
+ */
3742
3728
  async rebuildIndex(entityId) {
3743
- if (entityId) {
3744
- const rows2 = await this.entryRepo.findMiniSearchRows(entityId);
3745
- const previousIds = this.miniSearchEntryIdsByEntity.get(entityId);
3746
- if (previousIds) {
3747
- for (const id of previousIds) {
3748
- this.miniSearch.discard(id);
3749
- }
3750
- }
3751
- const documents2 = rows2.map((row) => this.normalizeMiniSearchRow(row));
3752
- if (documents2.length > 0) {
3753
- this.miniSearch.addAll(documents2);
3754
- }
3755
- this.miniSearchEntryIdsByEntity.set(
3756
- entityId,
3757
- new Set(documents2.map((document) => document.id))
3758
- );
3759
- return;
3760
- }
3761
- const rows = await this.entryRepo.findMiniSearchRows();
3762
- this.miniSearch.removeAll();
3763
- this.miniSearchEntryIdsByEntity.clear();
3764
- const documents = rows.map((row) => this.normalizeMiniSearchRow(row));
3765
- if (documents.length > 0) {
3766
- this.miniSearch.addAll(documents);
3767
- }
3768
- for (const document of documents) {
3769
- const ids = this.miniSearchEntryIdsByEntity.get(document.entity_id) ?? /* @__PURE__ */ new Set();
3770
- ids.add(document.id);
3771
- this.miniSearchEntryIdsByEntity.set(document.entity_id, ids);
3772
- }
3773
- }
3774
- normalizeMiniSearchRow(row) {
3775
- return {
3776
- id: row.id,
3777
- entity_id: row.entity_id,
3778
- title: row.title,
3779
- body: row.body,
3780
- tags: (() => {
3781
- try {
3782
- const parsed = JSON.parse(row.tags);
3783
- return Array.isArray(parsed) ? parsed.join(" ") : row.tags;
3784
- } catch {
3785
- return row.tags;
3786
- }
3787
- })()
3788
- };
3729
+ const rows = await this.entryRepo.findMiniSearchRows(entityId);
3730
+ await this.indexStrategy.replaceEntity(entityId, rows.map(toIndexDoc));
3789
3731
  }
3790
3732
  _tieBreakSort(items) {
3791
3733
  items.sort((a, b) => this._compareScoredRows(a, b));
@@ -3800,11 +3742,11 @@ var _SearchService = class _SearchService {
3800
3742
  return a.id.localeCompare(b.id);
3801
3743
  }
3802
3744
  /**
3803
- * MiniSearch breaks equal-score ties by internal insertion order, which
3804
- * syncEntries changes: a discarded-and-re-added id moves to the end. Every
3805
- * caller truncates these results to a limit, so which ids survive a tie at
3806
- * the boundary must not depend on write history. Re-sort exact score ties
3807
- * 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.
3808
3750
  */
3809
3751
  _compareSearchResults(a, b) {
3810
3752
  const scoreDiff = b.score - a.score;
@@ -3825,6 +3767,198 @@ _SearchService.MAX_VECTOR_CACHE_ENTITIES = 16;
3825
3767
  */
3826
3768
  _SearchService.MAX_VECTOR_CACHE_FACTS_PER_ENTITY = 500;
3827
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
+ }
3828
3962
 
3829
3963
  // src/services/JobManager.ts
3830
3964
  var JobManager = class {
@@ -6488,7 +6622,7 @@ async function gateCandidates(deps, ctx, candidates, cfg) {
6488
6622
  }
6489
6623
  }
6490
6624
  if (!ranked) {
6491
- 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 }));
6492
6626
  }
6493
6627
  const facts = ranked.length > 0 ? await deps.entryRepo.findByIds(ranked.map((r) => r.id), [entityId]) : [];
6494
6628
  const byId = new Map(facts.map((f) => [f.id, f]));
@@ -7931,7 +8065,7 @@ var MaintenanceService = class {
7931
8065
  if (!query) return [];
7932
8066
  const cached = cache?.get(query);
7933
8067
  if (cached) return cached;
7934
- const hits = this.searchService.searchKeyword(
8068
+ const hits = await this.searchService.searchKeyword(
7935
8069
  query,
7936
8070
  [entityId],
7937
8071
  cap * HEAL_ANCHOR_SEARCH_OVERFETCH
@@ -9103,7 +9237,7 @@ var RetrievalService = class {
9103
9237
  let miniSearchScores;
9104
9238
  if (effectivePreFilterLimit !== void 0) {
9105
9239
  populateCache = false;
9106
- 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));
9107
9241
  if (preResults.length === 0) {
9108
9242
  candidateRows = null;
9109
9243
  } else {
@@ -9130,7 +9264,7 @@ var RetrievalService = class {
9130
9264
  candidateRows = this._withoutIds(await this.entryRepo.findWithEmbeddingsByEntityIds(scoredEntityIds), excludedIds);
9131
9265
  }
9132
9266
  if (weight !== void 0 && weight < 1) {
9133
- miniSearchScores = this.searchService.getMiniSearchScores(trimmedQuery, scoredEntityIds);
9267
+ miniSearchScores = await this.searchService.getKeywordScores(trimmedQuery, scoredEntityIds);
9134
9268
  }
9135
9269
  }
9136
9270
  if (candidateRows === null) {
@@ -9373,7 +9507,7 @@ var RetrievalService = class {
9373
9507
  const hasActiveFloorsKeywordFallback = sanitizedTierFloors !== void 0 && Object.values(sanitizedTierFloors).some((f) => f > 0);
9374
9508
  const keywordOversampledLimit = hasActiveFloorsKeywordFallback ? Number.MAX_SAFE_INTEGER : padLimit(Math.max(maxResults * 2, maxResults + 50));
9375
9509
  const preFilteredIds = effectivePreFilterLimit !== void 0 ? new Set(candidateRows.map((r) => r.id)) : void 0;
9376
- const keywordResults = this.searchService.searchKeyword(trimmedQuery, scoredEntityIds, keywordOversampledLimit);
9510
+ const keywordResults = await this.searchService.searchKeyword(trimmedQuery, scoredEntityIds, keywordOversampledLimit);
9377
9511
  const topResults = preFilteredIds === void 0 ? keywordResults : keywordResults.filter((r) => preFilteredIds.has(r.id));
9378
9512
  const topResultIds = new Set(topResults.map((r) => r.id));
9379
9513
  const candidateMap = new Map(candidateRows.filter((r) => topResultIds.has(r.id)).map((row) => [row.id, row]));
@@ -9476,7 +9610,7 @@ var RetrievalService = class {
9476
9610
  if (!usedEmbed && scoredEntityIds.length > 0) {
9477
9611
  const hasActiveFloors = sanitizedTierFloors !== void 0 && Object.values(sanitizedTierFloors).some((f) => f > 0);
9478
9612
  const fallbackOversampledLimit = hasActiveFloors ? Number.MAX_SAFE_INTEGER : padLimit(Math.max(maxResults * 2, maxResults + 50));
9479
- 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));
9480
9614
  const candidates = results.map((r) => ({
9481
9615
  id: r.id,
9482
9616
  entity_id: r.entity_id,
@@ -10308,7 +10442,10 @@ var WikiMemory = class {
10308
10442
  );
10309
10443
  this.lintService = new LintService(this.lintRepo, this.ontologyService);
10310
10444
  this.embeddingService = new EmbeddingService(this.db, this.options, this.entryRepo, this.metadataRepo);
10311
- this.searchService = new SearchService(this.entryRepo);
10445
+ this.searchService = new SearchService(
10446
+ this.entryRepo,
10447
+ new MiniSearchIndexStrategy()
10448
+ );
10312
10449
  this.temporalService = new TemporalService(this.db, this.options, this.entryRepo, this.searchService, this.embeddingService);
10313
10450
  this.jobManager = new JobManager(this.prefix);
10314
10451
  this.promptService = new PromptService(options.config?.prompts, resolveGrounding(options.config?.grounding));