@equationalapplications/core-llm-wiki 7.7.5 → 7.7.7

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.
@@ -1171,6 +1171,14 @@ type ReembedCandidateRow = WikiFact & {
1171
1171
  };
1172
1172
  declare class EntryRepository extends BaseRepository {
1173
1173
  private outbox;
1174
+ /**
1175
+ * Column list and liveness predicate shared by findMiniSearchRows and
1176
+ * findMiniSearchRowsByIds, so the full rebuild and the incremental read
1177
+ * can never drift apart (spec 2026-09-28 §4.4: both must produce
1178
+ * identical documents).
1179
+ */
1180
+ private static readonly MINI_SEARCH_COLUMNS;
1181
+ private static readonly MINI_SEARCH_LIVE_WHERE;
1174
1182
  private chunkSize;
1175
1183
  constructor(db: SQLiteAdapter, prefix: string, outbox: OutboxRepository);
1176
1184
  /**
@@ -1375,6 +1383,19 @@ declare class EntryRepository extends BaseRepository {
1375
1383
  body: string;
1376
1384
  tags: string;
1377
1385
  }>>;
1386
+ /**
1387
+ * Keyword-index rows for specific ids — the incremental counterpart of
1388
+ * {@link findMiniSearchRows}, with identical columns. Soft-deleted rows and
1389
+ * rows of other entities are omitted, so a caller can pass every id a write
1390
+ * touched and get back exactly what should be indexed. Spec 2026-09-28 §4.4.
1391
+ */
1392
+ findMiniSearchRowsByIds(entityId: string, ids: readonly string[], tx?: SQLiteAdapter): Promise<Array<{
1393
+ id: string;
1394
+ entity_id: string;
1395
+ title: string;
1396
+ body: string;
1397
+ tags: string;
1398
+ }>>;
1378
1399
  updateEmbeddingBlob(id: string, blob: Uint8Array, tx?: SQLiteAdapter): Promise<void>;
1379
1400
  /**
1380
1401
  * Record a failed embedding attempt. Deliberately does NOT touch updated_at
@@ -1702,7 +1723,29 @@ declare class SearchService {
1702
1723
  * auto-vacuum debt behind the TypeError in #64.
1703
1724
  */
1704
1725
  private syncChain;
1726
+ /**
1727
+ * Entities whose index may have drifted from SQLite: an incremental update
1728
+ * failed part-way, or core wrote rows it could not index (upsertGraph runs in
1729
+ * the host's transaction). Their next syncEntries rebuilds the entity in full.
1730
+ * See spec 2026-09-28 §5.
1731
+ */
1732
+ private staleEntities;
1733
+ /**
1734
+ * Per-entity count of markStale() calls. A rebuild clears an entity's stale
1735
+ * flag only when the count it snapshotted before its read still holds
1736
+ * afterwards: markStale() runs inside the host's still-open transaction, so
1737
+ * a call landing mid-read belongs to rows the read cannot have seen, and
1738
+ * its flag must outlive the turn (#233 review).
1739
+ */
1740
+ private staleEpochs;
1705
1741
  constructor(entryRepo: EntryRepository);
1742
+ /**
1743
+ * A fresh index with the production options. clearAll() swaps one in because
1744
+ * MiniSearch.removeAll() empties the index but leaves dirtCount (and its
1745
+ * vacuum bookkeeping) at its old value, which would trip syncEntries'
1746
+ * conditional vacuum early after a clear.
1747
+ */
1748
+ private createMiniSearch;
1706
1749
  /**
1707
1750
  * Rebuilds the search index and clears the vector cache for a given entity.
1708
1751
  * A direct replacement for manually syncing state after a DB transaction.
@@ -1712,6 +1755,24 @@ declare class SearchService {
1712
1755
  * correct failure mode and killing the host process is not.
1713
1756
  */
1714
1757
  sync(entityId?: string): Promise<void>;
1758
+ /**
1759
+ * Re-indexes only `ids` for `entityId`: drops each from the index, then
1760
+ * re-adds the ones still live in SQLite, so soft-deleted or missing ids end
1761
+ * up absent. Costs O(ids), where sync(entityId) costs O(entity) — callers that
1762
+ * know what a write touched use this so chunked imports stay linear (#232).
1763
+ *
1764
+ * Serialized with sync() on the same chain and, like it, never rejects. An
1765
+ * entity that is stale or has never been indexed gets a full rebuild instead
1766
+ * — including on an empty `ids` list, which is a no-op only for an entity
1767
+ * the index already tracks, and even then one that waits for rebuilds
1768
+ * already queued on the chain.
1769
+ */
1770
+ syncEntries(entityId: string, ids: Iterable<string>): Promise<void>;
1771
+ /**
1772
+ * Forces the entity's next syncEntries to rebuild it in full. For writes core
1773
+ * cannot index itself, such as upsertGraph inside a host transaction.
1774
+ */
1775
+ markStale(entityId: string): void;
1715
1776
  /**
1716
1777
  * Clears the parsed vector cache. Useful for mid-loop flush guarantees
1717
1778
  * or memory pressure evictions.
@@ -1738,6 +1799,14 @@ declare class SearchService {
1738
1799
  private normalizeMiniSearchRow;
1739
1800
  private _tieBreakSort;
1740
1801
  private _compareScoredRows;
1802
+ /**
1803
+ * MiniSearch breaks equal-score ties by internal insertion order, which
1804
+ * syncEntries changes: a discarded-and-re-added id moves to the end. Every
1805
+ * caller truncates these results to a limit, so which ids survive a tie at
1806
+ * the boundary must not depend on write history. Re-sort exact score ties
1807
+ * by id — the same final tie-break _compareScoredRows applies.
1808
+ */
1809
+ private _compareSearchResults;
1741
1810
  }
1742
1811
 
1743
1812
  type OperationType = 'prune' | 'librarian' | 'heal' | 'ingest' | 'reembed' | 'global_reembed' | 'import' | 'global_import' | 'forget' | 'ontologyBackfill';
@@ -1171,6 +1171,14 @@ type ReembedCandidateRow = WikiFact & {
1171
1171
  };
1172
1172
  declare class EntryRepository extends BaseRepository {
1173
1173
  private outbox;
1174
+ /**
1175
+ * Column list and liveness predicate shared by findMiniSearchRows and
1176
+ * findMiniSearchRowsByIds, so the full rebuild and the incremental read
1177
+ * can never drift apart (spec 2026-09-28 §4.4: both must produce
1178
+ * identical documents).
1179
+ */
1180
+ private static readonly MINI_SEARCH_COLUMNS;
1181
+ private static readonly MINI_SEARCH_LIVE_WHERE;
1174
1182
  private chunkSize;
1175
1183
  constructor(db: SQLiteAdapter, prefix: string, outbox: OutboxRepository);
1176
1184
  /**
@@ -1375,6 +1383,19 @@ declare class EntryRepository extends BaseRepository {
1375
1383
  body: string;
1376
1384
  tags: string;
1377
1385
  }>>;
1386
+ /**
1387
+ * Keyword-index rows for specific ids — the incremental counterpart of
1388
+ * {@link findMiniSearchRows}, with identical columns. Soft-deleted rows and
1389
+ * rows of other entities are omitted, so a caller can pass every id a write
1390
+ * touched and get back exactly what should be indexed. Spec 2026-09-28 §4.4.
1391
+ */
1392
+ findMiniSearchRowsByIds(entityId: string, ids: readonly string[], tx?: SQLiteAdapter): Promise<Array<{
1393
+ id: string;
1394
+ entity_id: string;
1395
+ title: string;
1396
+ body: string;
1397
+ tags: string;
1398
+ }>>;
1378
1399
  updateEmbeddingBlob(id: string, blob: Uint8Array, tx?: SQLiteAdapter): Promise<void>;
1379
1400
  /**
1380
1401
  * Record a failed embedding attempt. Deliberately does NOT touch updated_at
@@ -1702,7 +1723,29 @@ declare class SearchService {
1702
1723
  * auto-vacuum debt behind the TypeError in #64.
1703
1724
  */
1704
1725
  private syncChain;
1726
+ /**
1727
+ * Entities whose index may have drifted from SQLite: an incremental update
1728
+ * failed part-way, or core wrote rows it could not index (upsertGraph runs in
1729
+ * the host's transaction). Their next syncEntries rebuilds the entity in full.
1730
+ * See spec 2026-09-28 §5.
1731
+ */
1732
+ private staleEntities;
1733
+ /**
1734
+ * Per-entity count of markStale() calls. A rebuild clears an entity's stale
1735
+ * flag only when the count it snapshotted before its read still holds
1736
+ * afterwards: markStale() runs inside the host's still-open transaction, so
1737
+ * a call landing mid-read belongs to rows the read cannot have seen, and
1738
+ * its flag must outlive the turn (#233 review).
1739
+ */
1740
+ private staleEpochs;
1705
1741
  constructor(entryRepo: EntryRepository);
1742
+ /**
1743
+ * A fresh index with the production options. clearAll() swaps one in because
1744
+ * MiniSearch.removeAll() empties the index but leaves dirtCount (and its
1745
+ * vacuum bookkeeping) at its old value, which would trip syncEntries'
1746
+ * conditional vacuum early after a clear.
1747
+ */
1748
+ private createMiniSearch;
1706
1749
  /**
1707
1750
  * Rebuilds the search index and clears the vector cache for a given entity.
1708
1751
  * A direct replacement for manually syncing state after a DB transaction.
@@ -1712,6 +1755,24 @@ declare class SearchService {
1712
1755
  * correct failure mode and killing the host process is not.
1713
1756
  */
1714
1757
  sync(entityId?: string): Promise<void>;
1758
+ /**
1759
+ * Re-indexes only `ids` for `entityId`: drops each from the index, then
1760
+ * re-adds the ones still live in SQLite, so soft-deleted or missing ids end
1761
+ * up absent. Costs O(ids), where sync(entityId) costs O(entity) — callers that
1762
+ * know what a write touched use this so chunked imports stay linear (#232).
1763
+ *
1764
+ * Serialized with sync() on the same chain and, like it, never rejects. An
1765
+ * entity that is stale or has never been indexed gets a full rebuild instead
1766
+ * — including on an empty `ids` list, which is a no-op only for an entity
1767
+ * the index already tracks, and even then one that waits for rebuilds
1768
+ * already queued on the chain.
1769
+ */
1770
+ syncEntries(entityId: string, ids: Iterable<string>): Promise<void>;
1771
+ /**
1772
+ * Forces the entity's next syncEntries to rebuild it in full. For writes core
1773
+ * cannot index itself, such as upsertGraph inside a host transaction.
1774
+ */
1775
+ markStale(entityId: string): void;
1715
1776
  /**
1716
1777
  * Clears the parsed vector cache. Useful for mid-loop flush guarantees
1717
1778
  * or memory pressure evictions.
@@ -1738,6 +1799,14 @@ declare class SearchService {
1738
1799
  private normalizeMiniSearchRow;
1739
1800
  private _tieBreakSort;
1740
1801
  private _compareScoredRows;
1802
+ /**
1803
+ * MiniSearch breaks equal-score ties by internal insertion order, which
1804
+ * syncEntries changes: a discarded-and-re-added id moves to the end. Every
1805
+ * caller truncates these results to a limit, so which ids survive a tie at
1806
+ * the boundary must not depend on write history. Re-sort exact score ties
1807
+ * by id — the same final tie-break _compareScoredRows applies.
1808
+ */
1809
+ private _compareSearchResults;
1741
1810
  }
1742
1811
 
1743
1812
  type OperationType = 'prune' | 'librarian' | 'heal' | 'ingest' | 'reembed' | 'global_reembed' | 'import' | 'global_import' | 'forget' | 'ontologyBackfill';
@@ -1,3 +1,3 @@
1
- export { aq as EmbeddingService, ar as ImportExportService, as as IngestionService, at as JobManager, at as JobManagerType, au as MaintenanceService, av as RetrievalService, aw as SearchService, aw as SearchServiceType, ai as WikiMemoryTestAccess, ax as WriteService } from './testing-CH_Kk34-.mjs';
1
+ export { aq as EmbeddingService, ar as ImportExportService, as as IngestionService, at as JobManager, at as JobManagerType, au as MaintenanceService, av as RetrievalService, aw as SearchService, aw as SearchServiceType, ai as WikiMemoryTestAccess, ax as WriteService } from './testing-BIFNLUva.mjs';
2
2
  import '@equationalapplications/core-okf';
3
3
  import 'minisearch';
package/dist/testing.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { aq as EmbeddingService, ar as ImportExportService, as as IngestionService, at as JobManager, at as JobManagerType, au as MaintenanceService, av as RetrievalService, aw as SearchService, aw as SearchServiceType, ai as WikiMemoryTestAccess, ax as WriteService } from './testing-CH_Kk34-.js';
1
+ export { aq as EmbeddingService, ar as ImportExportService, as as IngestionService, at as JobManager, at as JobManagerType, au as MaintenanceService, av as RetrievalService, aw as SearchService, aw as SearchServiceType, ai as WikiMemoryTestAccess, ax as WriteService } from './testing-BIFNLUva.js';
2
2
  import '@equationalapplications/core-okf';
3
3
  import 'minisearch';
package/dist/testing.js CHANGED
@@ -1343,7 +1343,11 @@ var ImportExportService = class {
1343
1343
  );
1344
1344
  }
1345
1345
  });
1346
- await this.searchService.sync(entityId);
1346
+ if (merge) {
1347
+ await this.searchService.syncEntries(entityId, [...upsertedFactIds]);
1348
+ } else {
1349
+ await this.searchService.sync(entityId);
1350
+ }
1347
1351
  for (const fact of bundle.facts) {
1348
1352
  if (!fact.deleted_at && upsertedFactIds.has(fact.id) && !factsWithPreservedBlob.has(fact.id)) {
1349
1353
  const clipped = clippedTextByFactId.get(fact.id);
@@ -2095,7 +2099,10 @@ var IngestionService = class {
2095
2099
  return zeroChunkResult(canonical);
2096
2100
  }
2097
2101
  diagBuffer.flush(this.options);
2098
- await this.searchService.sync(entityId);
2102
+ await this.searchService.syncEntries(
2103
+ entityId,
2104
+ [...deletedSourceFactIds, ...insertedFacts.map((fact) => fact.id)]
2105
+ );
2099
2106
  if (failedChunks === 0) {
2100
2107
  const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
2101
2108
  for (const factId of uniqueDeletedSourceFactIds) {
@@ -2765,6 +2772,7 @@ var MaintenanceService = class {
2765
2772
  this._validatePruneDuration(retainSoftDeletedFor, "retainSoftDeletedFor");
2766
2773
  this._validatePruneDuration(retainEventsFor, "retainEventsFor");
2767
2774
  const now = Date.now();
2775
+ let syncedIds = [];
2768
2776
  let deletedEntries = 0;
2769
2777
  let deletedTasks = 0;
2770
2778
  let deletedEvents = 0;
@@ -2783,6 +2791,7 @@ var MaintenanceService = class {
2783
2791
  }
2784
2792
  }
2785
2793
  const succeededIds = succeeded.map((r) => r.id);
2794
+ syncedIds = succeededIds;
2786
2795
  await this.db.withTransactionAsync(async (tx) => {
2787
2796
  if (succeededIds.length > 0) {
2788
2797
  deletedEntries = await this.entryRepo.bulkDeletePruned(entityId, cutoff, succeededIds, tx);
@@ -2790,7 +2799,7 @@ var MaintenanceService = class {
2790
2799
  deletedTasks = await this.taskRepo.bulkDeletePruned(entityId, cutoff, tx);
2791
2800
  });
2792
2801
  if (failure) {
2793
- await this.searchService.sync(entityId);
2802
+ await this.searchService.syncEntries(entityId, syncedIds);
2794
2803
  const remaining = entriesToDelete.length - succeeded.length - 1;
2795
2804
  const isTimeout = failure.cause?.[HOOK_TIMEOUT_MARKER] === true;
2796
2805
  if (isTimeout) {
@@ -2824,7 +2833,7 @@ var MaintenanceService = class {
2824
2833
  if (vacuum) {
2825
2834
  await this.metadataRepo.vacuum();
2826
2835
  }
2827
- await this.searchService.sync(entityId);
2836
+ await this.searchService.syncEntries(entityId, syncedIds);
2828
2837
  return { entries: deletedEntries, tasks: deletedTasks, events: deletedEvents };
2829
2838
  } finally {
2830
2839
  this.jobManager.releaseLock("prune", entityId);
@@ -2994,8 +3003,8 @@ var MaintenanceService = class {
2994
3003
  deletedEntries += refResult;
2995
3004
  }
2996
3005
  });
2997
- await this.searchService.sync(entityId);
2998
3006
  const uniqueDeletedIds = Array.from(new Set(deletedEntryIds));
3007
+ await this.searchService.syncEntries(entityId, uniqueDeletedIds);
2999
3008
  for (const factId of uniqueDeletedIds) {
3000
3009
  try {
3001
3010
  await this.embeddingService.notifyEmbeddingPersistedOrThrow(entityId, factId, null);
@@ -3193,7 +3202,7 @@ var MaintenanceService = class {
3193
3202
  }
3194
3203
  });
3195
3204
  diagBuffer.flush(this.options);
3196
- await this.searchService.sync(entityId);
3205
+ await this.searchService.syncEntries(entityId, insertedFacts.map((f) => f.id));
3197
3206
  for (const fact of insertedFacts) {
3198
3207
  await this.embeddingService.embedFact(fact, { operation: "librarian", trigger });
3199
3208
  }
@@ -3251,7 +3260,7 @@ var MaintenanceService = class {
3251
3260
  }
3252
3261
  const healCandidates = await this.entryRepo.findHealCandidatesByEntityId(entityId, batchSize, recheckCutoff);
3253
3262
  if (healCandidates.length === 0) {
3254
- await this.searchService.sync(entityId);
3263
+ await this.searchService.syncEntries(entityId, Array.from(/* @__PURE__ */ new Set([...orphanedIds, ...staleDowngradedIds])));
3255
3264
  this.searchService.evictCache(entityId);
3256
3265
  const counts2 = await this.entryRepo.countHealCandidatesByEntityId(entityId, recheckCutoff);
3257
3266
  return {
@@ -3421,7 +3430,14 @@ var MaintenanceService = class {
3421
3430
  diagBuffer.push({ ...diagBase, code: "heal_skipped", detail: { factId: item.id, reason } });
3422
3431
  }
3423
3432
  diagBuffer.flush(this.options);
3424
- await this.searchService.sync(entityId);
3433
+ const healSyncIds = Array.from(/* @__PURE__ */ new Set([
3434
+ ...orphanedIds,
3435
+ ...staleDowngradedIds,
3436
+ ...safeDowngraded,
3437
+ ...uniqueDeletedFactIds,
3438
+ ...insertedFacts.map((f) => f.id)
3439
+ ]));
3440
+ await this.searchService.syncEntries(entityId, healSyncIds);
3425
3441
  for (const factId of uniqueDeletedFactIds) {
3426
3442
  try {
3427
3443
  await this.embeddingService.notifyEmbeddingPersisted(entityId, factId, null);
@@ -4506,7 +4522,31 @@ var _SearchService = class _SearchService {
4506
4522
  * auto-vacuum debt behind the TypeError in #64.
4507
4523
  */
4508
4524
  this.syncChain = Promise.resolve();
4509
- this.miniSearch = new MiniSearch__default.default({
4525
+ /**
4526
+ * Entities whose index may have drifted from SQLite: an incremental update
4527
+ * failed part-way, or core wrote rows it could not index (upsertGraph runs in
4528
+ * the host's transaction). Their next syncEntries rebuilds the entity in full.
4529
+ * See spec 2026-09-28 §5.
4530
+ */
4531
+ this.staleEntities = /* @__PURE__ */ new Set();
4532
+ /**
4533
+ * Per-entity count of markStale() calls. A rebuild clears an entity's stale
4534
+ * flag only when the count it snapshotted before its read still holds
4535
+ * afterwards: markStale() runs inside the host's still-open transaction, so
4536
+ * a call landing mid-read belongs to rows the read cannot have seen, and
4537
+ * its flag must outlive the turn (#233 review).
4538
+ */
4539
+ this.staleEpochs = /* @__PURE__ */ new Map();
4540
+ this.miniSearch = this.createMiniSearch();
4541
+ }
4542
+ /**
4543
+ * A fresh index with the production options. clearAll() swaps one in because
4544
+ * MiniSearch.removeAll() empties the index but leaves dirtCount (and its
4545
+ * vacuum bookkeeping) at its old value, which would trip syncEntries'
4546
+ * conditional vacuum early after a clear.
4547
+ */
4548
+ createMiniSearch() {
4549
+ return new MiniSearch__default.default({
4510
4550
  fields: ["title", "body", "tags"],
4511
4551
  storeFields: ["entity_id"],
4512
4552
  // Vacuuming is driven explicitly at the end of each serialized rebuild
@@ -4533,7 +4573,19 @@ var _SearchService = class _SearchService {
4533
4573
  const work = this.syncChain.then(async () => {
4534
4574
  try {
4535
4575
  try {
4576
+ const epochsBefore = new Map(this.staleEpochs);
4536
4577
  await this.rebuildIndex(entityId);
4578
+ if (entityId) {
4579
+ if ((this.staleEpochs.get(entityId) ?? 0) === (epochsBefore.get(entityId) ?? 0)) {
4580
+ this.staleEntities.delete(entityId);
4581
+ }
4582
+ } else {
4583
+ for (const id of [...this.staleEntities]) {
4584
+ if ((this.staleEpochs.get(id) ?? 0) === (epochsBefore.get(id) ?? 0)) {
4585
+ this.staleEntities.delete(id);
4586
+ }
4587
+ }
4588
+ }
4537
4589
  await this.miniSearch.vacuum();
4538
4590
  } finally {
4539
4591
  this.evictCache(entityId);
@@ -4545,6 +4597,68 @@ var _SearchService = class _SearchService {
4545
4597
  this.syncChain = work;
4546
4598
  return work;
4547
4599
  }
4600
+ /**
4601
+ * Re-indexes only `ids` for `entityId`: drops each from the index, then
4602
+ * re-adds the ones still live in SQLite, so soft-deleted or missing ids end
4603
+ * up absent. Costs O(ids), where sync(entityId) costs O(entity) — callers that
4604
+ * know what a write touched use this so chunked imports stay linear (#232).
4605
+ *
4606
+ * Serialized with sync() on the same chain and, like it, never rejects. An
4607
+ * entity that is stale or has never been indexed gets a full rebuild instead
4608
+ * — including on an empty `ids` list, which is a no-op only for an entity
4609
+ * the index already tracks, and even then one that waits for rebuilds
4610
+ * already queued on the chain.
4611
+ */
4612
+ async syncEntries(entityId, ids) {
4613
+ const uniqueIds = [...new Set(ids)];
4614
+ if (uniqueIds.length === 0 && !this.staleEntities.has(entityId) && this.miniSearchEntryIdsByEntity.has(entityId)) {
4615
+ return this.syncChain;
4616
+ }
4617
+ const work = this.syncChain.then(async () => {
4618
+ try {
4619
+ try {
4620
+ const epochsBefore = new Map(this.staleEpochs);
4621
+ const needsRebuild = () => this.staleEntities.has(entityId) || !this.miniSearchEntryIdsByEntity.has(entityId);
4622
+ if (!needsRebuild()) {
4623
+ const rows = await this.entryRepo.findMiniSearchRowsByIds(entityId, uniqueIds);
4624
+ const tracked = this.miniSearchEntryIdsByEntity.get(entityId);
4625
+ if (tracked && !this.staleEntities.has(entityId)) {
4626
+ for (const id of uniqueIds) {
4627
+ if (tracked.delete(id) && this.miniSearch.has(id)) this.miniSearch.discard(id);
4628
+ }
4629
+ const documents = rows.map((row) => this.normalizeMiniSearchRow(row));
4630
+ if (documents.length > 0) this.miniSearch.addAll(documents);
4631
+ for (const document of documents) tracked.add(document.id);
4632
+ if (this.miniSearch.dirtCount > 0) {
4633
+ await this.miniSearch.vacuum();
4634
+ }
4635
+ return;
4636
+ }
4637
+ }
4638
+ await this.rebuildIndex(entityId);
4639
+ if ((this.staleEpochs.get(entityId) ?? 0) === (epochsBefore.get(entityId) ?? 0)) {
4640
+ this.staleEntities.delete(entityId);
4641
+ }
4642
+ await this.miniSearch.vacuum();
4643
+ } finally {
4644
+ this.evictCache(entityId);
4645
+ }
4646
+ } catch (err) {
4647
+ this.staleEntities.add(entityId);
4648
+ console.warn(`[WikiMemory] search index incremental sync failed for ${entityId}:`, err);
4649
+ }
4650
+ });
4651
+ this.syncChain = work;
4652
+ return work;
4653
+ }
4654
+ /**
4655
+ * Forces the entity's next syncEntries to rebuild it in full. For writes core
4656
+ * cannot index itself, such as upsertGraph inside a host transaction.
4657
+ */
4658
+ markStale(entityId) {
4659
+ this.staleEntities.add(entityId);
4660
+ this.staleEpochs.set(entityId, (this.staleEpochs.get(entityId) ?? 0) + 1);
4661
+ }
4548
4662
  /**
4549
4663
  * Clears the parsed vector cache. Useful for mid-loop flush guarantees
4550
4664
  * or memory pressure evictions.
@@ -4561,8 +4675,10 @@ var _SearchService = class _SearchService {
4561
4675
  */
4562
4676
  clearAll() {
4563
4677
  this.vectorCache.clear();
4564
- this.miniSearch.removeAll();
4678
+ this.miniSearch = this.createMiniSearch();
4565
4679
  this.miniSearchEntryIdsByEntity.clear();
4680
+ this.staleEntities.clear();
4681
+ this.staleEpochs.clear();
4566
4682
  }
4567
4683
  /**
4568
4684
  * Executes a keyword search against the active MiniSearch index.
@@ -4573,7 +4689,7 @@ var _SearchService = class _SearchService {
4573
4689
  filter: (r) => entityIdSet.has(r.entity_id),
4574
4690
  combineWith: "OR"
4575
4691
  });
4576
- return results.slice(0, limit);
4692
+ return results.sort((a, b) => this._compareSearchResults(a, b)).slice(0, limit);
4577
4693
  }
4578
4694
  /**
4579
4695
  * Pre-fetches MiniSearch scores for candidate hydration, used during hybrid weighting.
@@ -4583,7 +4699,7 @@ var _SearchService = class _SearchService {
4583
4699
  let results = this.miniSearch.search(query, {
4584
4700
  filter: (r) => entityIdSet.has(r.entity_id),
4585
4701
  combineWith: "OR"
4586
- });
4702
+ }).sort((a, b) => this._compareSearchResults(a, b));
4587
4703
  if (preFilterLimit !== void 0) {
4588
4704
  results = results.slice(0, preFilterLimit);
4589
4705
  }
@@ -4711,6 +4827,18 @@ var _SearchService = class _SearchService {
4711
4827
  if (updatedAtDiff !== 0) return updatedAtDiff;
4712
4828
  return a.id.localeCompare(b.id);
4713
4829
  }
4830
+ /**
4831
+ * MiniSearch breaks equal-score ties by internal insertion order, which
4832
+ * syncEntries changes: a discarded-and-re-added id moves to the end. Every
4833
+ * caller truncates these results to a limit, so which ids survive a tie at
4834
+ * the boundary must not depend on write history. Re-sort exact score ties
4835
+ * by id — the same final tie-break _compareScoredRows applies.
4836
+ */
4837
+ _compareSearchResults(a, b) {
4838
+ const scoreDiff = b.score - a.score;
4839
+ if (!Number.isNaN(scoreDiff) && scoreDiff !== 0) return scoreDiff;
4840
+ return a.id.localeCompare(b.id);
4841
+ }
4714
4842
  };
4715
4843
  /**
4716
4844
  * Maximum number of entities whose parsed embedding vectors are held in