@remnic/core 9.3.748 → 9.3.749

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.
@@ -114,6 +114,7 @@ import { RecallSearchPipelineCoordinator } from "./orchestration/recall-search-p
114
114
  import { TurnIngestionCoordinator } from "./orchestration/turn-ingestion.js";
115
115
  import { RecallIntrospectionCoordinator } from "./orchestration/recall-introspection.js";
116
116
  import { OrchestratorInitCoordinator } from "./orchestration/orchestrator-init.js";
117
+ import { PersistenceIndexCoordinator } from "./orchestration/persistence-index.js";
117
118
  export { hasIdentityRecoveryIntent, resolveEffectiveIdentityInjectionMode } from "./orchestration/recall-result-formatter.js";
118
119
  import {
119
120
  GraphRecallCoordinator,
@@ -2549,17 +2550,20 @@ export class Orchestrator {
2549
2550
  targetStorage: StorageManager,
2550
2551
  content: string,
2551
2552
  ): Promise<boolean> {
2552
- const index = await this.contentHashIndexForStorage(targetStorage);
2553
- return index ? index.has(content) : false;
2553
+ return this.persistenceIndexCoordinator.hasContentHashDedup(
2554
+ targetStorage,
2555
+ content,
2556
+ );
2554
2557
  }
2555
2558
 
2556
2559
  private async addContentHashDedup(
2557
2560
  targetStorage: StorageManager,
2558
2561
  content: string,
2559
2562
  ): Promise<void> {
2560
- const index = await this.contentHashIndexForStorage(targetStorage);
2561
- if (!index) return;
2562
- index.add(content);
2563
+ return this.persistenceIndexCoordinator.addContentHashDedup(
2564
+ targetStorage,
2565
+ content,
2566
+ );
2563
2567
  }
2564
2568
 
2565
2569
  private async removeContentHashForMemory(
@@ -2567,50 +2571,13 @@ export class Orchestrator {
2567
2571
  memory: MemoryFile,
2568
2572
  context: string,
2569
2573
  ): Promise<void> {
2570
- const index = await this.contentHashIndexForStorage(targetStorage);
2571
- if (!index) return;
2572
-
2573
- if (memory.frontmatter.contentHash) {
2574
- index.removeByHash(memory.frontmatter.contentHash);
2575
- return;
2576
- }
2577
-
2578
- log.warn(
2579
- `[${context}] removing hash for legacy memory ${memory.frontmatter.id ?? "(unknown)"} via content fallback - no contentHash in frontmatter`,
2574
+ return this.persistenceIndexCoordinator.removeContentHashForMemory(
2575
+ targetStorage,
2576
+ memory,
2577
+ context,
2580
2578
  );
2581
- index.remove(memory.content);
2582
2579
  }
2583
2580
 
2584
- /**
2585
- * Issue #1671 — backfill bi-temporal bounds onto an existing promoted/deduped
2586
- * copy that was written BEFORE the source fact carried a resolved
2587
- * `invalid_at`/`observedAt`/`eventTimeSource`.
2588
- *
2589
- * On re-extraction/backfill, a fact may now carry a resolved end bound (e.g.
2590
- * "until June 2025") that the existing copy lacks because it was promoted
2591
- * before bi-temporal wiring existed. Without this backfill, recall keeps
2592
- * surfacing an expired fact even though the source copy now expires correctly.
2593
- *
2594
- * Finds the active fact in `targetStorage` matching `dedupContent`, then
2595
- * patches the temporal frontmatter the existing copy is missing. Best-effort
2596
- * / fail-open — any I/O error is logged and swallowed so the dedup
2597
- * short-circuit is never blocked by a backfill failure.
2598
- *
2599
- * Matching: the stored `frontmatter.contentHash` is compared against
2600
- * `ContentHashIndex.computeHash(dedupContent)` first (the exact hash the
2601
- * content-hash index uses), then falls back to stripping citations and
2602
- * comparing normalized bodies. This handles inline-attribution deployments
2603
- * where the persisted body carries a citation marker the dedup key does not.
2604
- *
2605
- * I/O gate: only triggers when `invalidAt` is present (the end bound that
2606
- * actually changes recall behavior by expiring the fact). `observedAt` and
2607
- * `eventTimeSource` alone don'\''t expire a fact, so backfilling them without
2608
- * an end bound would cause a full readAllMemories scan on every dedup hit
2609
- * under biTemporal for no recall benefit.
2610
- *
2611
- * Only patches fields the existing copy LACKS — never overwrites a bound the
2612
- * copy already carries.
2613
- */
2614
2581
  private async backfillTemporalBoundsOnDedupHit(
2615
2582
  targetStorage: StorageManager,
2616
2583
  dedupContent: string,
@@ -2626,130 +2593,17 @@ export class Orchestrator {
2626
2593
  },
2627
2594
  entityRef?: string,
2628
2595
  ): Promise<void> {
2629
- // I/O gate: scan when there is a recall-relevant bound to backfill —
2630
- // either an end bound (invalidAt, which expires the fact) or a corrected
2631
- // EXTRACTED start bound. A start bound only changes recall when it is
2632
- // extracted (the as-of filter excludes facts whose valid_at is after the
2633
- // as-of instant); an "assumed" validFrom is just the ingestion anchor
2634
- // (resolveFactEventTime sets one for every fact), so scanning on it would
2635
- // run a full readAllMemories on every bi-temporal dedup hit for no benefit
2636
- // (review cursor PRRT_OvHk / codex PRRT_OvHxVH). observedAt and
2637
- // eventTimeSource alone never change recall.
2638
- const hasExtractedStart =
2639
- bounds.validFrom !== undefined && bounds.eventTimeSource === "extracted";
2640
- if (!bounds.invalidAt && !hasExtractedStart) return;
2641
- try {
2642
- const incomingHash = ContentHashIndex.computeHash(dedupContent);
2643
- const normalizedIncoming = ContentHashIndex.normalizeContent(dedupContent);
2644
- // Normalize the entity for same-entity scoping when provided — two
2645
- // entities can share identical fact text, and patching a different
2646
- // entity'\''s fact would corrupt its temporal bounds (cursor review).
2647
- const incomingEntityNorm = entityRef
2648
- ? normalizeSupersessionKey(entityRef)
2649
- : undefined;
2650
- const all = await targetStorage.readAllMemories();
2651
- const existing = all.find((m) => {
2652
- if (m.frontmatter.category !== "fact") return false;
2653
- if ((m.frontmatter.status ?? "active") !== "active") return false;
2654
- // Same-entity guard: reject only when the stored fact carries a
2655
- // DIFFERENT entity (two entities can share identical fact text, so
2656
- // patching the other entity's copy would corrupt its bounds — codex
2657
- // P2 PRRT_OvB4A). Legacy facts written before entity linkage (no
2658
- // entityRef) have no entity to conflict with, so they stay eligible
2659
- // for backfill (cursor PRRT_OvKnV: the guard must NOT silently
2660
- // no-op for older promoted copies that predate entity linkage).
2661
- if (
2662
- incomingEntityNorm &&
2663
- m.frontmatter.entityRef &&
2664
- normalizeSupersessionKey(m.frontmatter.entityRef) !== incomingEntityNorm
2665
- ) {
2666
- return false;
2667
- }
2668
- // Prefer the stored contentHash (what the hash index actually keys
2669
- // on) — it is computed from contentHashSource (the raw/enriched
2670
- // body before citation), matching the dedupContent the caller passes.
2671
- if (m.frontmatter.contentHash) {
2672
- return m.frontmatter.contentHash === incomingHash;
2673
- }
2674
- // Legacy facts without a stored hash: strip citations then compare
2675
- // normalized bodies so inline-attribution markers don'\''t prevent
2676
- // a match.
2677
- return (
2678
- ContentHashIndex.normalizeContent(
2679
- stripCitationForTemplate(m.content ?? "", this.config.inlineSourceAttributionFormat),
2680
- ) === normalizedIncoming
2681
- );
2682
- });
2683
- if (!existing) return;
2684
- // Build a patch containing ONLY the fields the existing copy lacks.
2685
- const patch: Partial<MemoryFrontmatter> = {};
2686
- const fm = existing.frontmatter;
2687
- if (bounds.invalidAt && (!fm.invalid_at || fm.invalid_at.length === 0)) {
2688
- patch.invalid_at = bounds.invalidAt;
2689
- }
2690
- if (bounds.observedAt && (!fm.observedAt || fm.observedAt.length === 0)) {
2691
- patch.observedAt = bounds.observedAt;
2692
- }
2693
- if (
2694
- bounds.eventTimeSource &&
2695
- (!fm.eventTimeSource || fm.eventTimeSource.length === 0)
2696
- ) {
2697
- patch.eventTimeSource = bounds.eventTimeSource;
2698
- }
2699
- // #1707 thread 2 — per-fact-anchored start bound. A re-extracted
2700
- // duplicate whose event time resolves a real start bound must carry
2701
- // that anchor onto the existing copy so as-of recall uses the corrected
2702
- // valid_at instead of a stale batch-anchored value. Only an EXTRACTED
2703
- // bound corrects; an "assumed" bound is just the ingestion anchor.
2704
- //
2705
- // No-clobber via equality, not provenance inference: exact-content dedup
2706
- // re-extracts the SAME event-time expression, which #1670 per-fact
2707
- // anchoring resolves deterministically to the same validFrom — so for
2708
- // stable content the incoming validFrom EQUALS the copy's valid_at and
2709
- // we skip the redundant write (the only no-clobber that holds without a
2710
- // fragile provenance heuristic — review codex PRRT_Ov7LKC). When they
2711
- // differ (a prior batch/assumed anchor, end-only assumed start, or a
2712
- // non-deterministic re-resolution), the extracted validFrom is the
2713
- // authoritative correction and overwrites. The eventTimeSource upgrade
2714
- // below records that the start is now extracted-anchored.
2715
- if (
2716
- bounds.validFrom &&
2717
- bounds.eventTimeSource === "extracted" &&
2718
- fm.valid_at !== bounds.validFrom
2719
- ) {
2720
- patch.valid_at = bounds.validFrom;
2721
- // Mark the copy extracted-anchored in the SAME patch so its provenance
2722
- // reflects the correction (review cursor PRRT_OvHM / codex PRRT_OvHxVD):
2723
- // without this, a copy upgraded from "assumed" would keep "assumed"
2724
- // provenance while carrying an extracted start. (The earlier
2725
- // eventTimeSource block only fills an EMPTY source.)
2726
- if (fm.eventTimeSource !== "extracted") {
2727
- patch.eventTimeSource = "extracted";
2728
- }
2729
- }
2730
- if (Object.keys(patch).length === 0) return;
2731
- const ok = await targetStorage.writeMemoryFrontmatter(existing, patch);
2732
- if (ok) {
2733
- log.debug(
2734
- `bitemporal-backfill: patched ${Object.keys(patch).join(",")} onto existing fact ${fm.id ?? "(unknown)"} in ${targetStorage.dir}`,
2735
- );
2736
- }
2737
- } catch (err) {
2738
- log.warn(
2739
- `bitemporal-backfill: failed open for ${targetStorage.dir}: ${err}`,
2740
- );
2741
- }
2596
+ return this.persistenceIndexCoordinator.backfillTemporalBoundsOnDedupHit(
2597
+ targetStorage,
2598
+ dedupContent,
2599
+ bounds,
2600
+ entityRef,
2601
+ );
2742
2602
  }
2743
2603
 
2744
2604
  private async saveContentHashIndexes(): Promise<void> {
2745
- const indexes = new Set<ContentHashIndex>();
2746
- if (this.contentHashIndex) indexes.add(this.contentHashIndex);
2747
- for (const index of this.contentHashIndexesByStorageDir.values()) {
2748
- indexes.add(index);
2749
- }
2750
- for (const index of indexes) {
2751
- await index.save();
2752
- }
2605
+ return this.persistenceIndexCoordinator.saveContentHashIndexes(
2606
+ );
2753
2607
  }
2754
2608
 
2755
2609
  constructor(config: PluginConfig) {
@@ -5146,6 +5000,31 @@ export class Orchestrator {
5146
5000
  return this._orchestratorInitCoordinator;
5147
5001
  }
5148
5002
 
5003
+ /**
5004
+ * Persistence-index coordinator (issue #1526 seam 23). Owns post-persist
5005
+ * bookkeeping (content-hash dedup, temporal indexes, graph edges,
5006
+ * semantic dedup lookup). Lazy + accessor-wired (late-binding rule).
5007
+ */
5008
+ private _persistenceIndexCoordinator: PersistenceIndexCoordinator | undefined;
5009
+
5010
+ private get persistenceIndexCoordinator(): PersistenceIndexCoordinator {
5011
+ if (!this._persistenceIndexCoordinator) {
5012
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
5013
+ const self = this;
5014
+ this._persistenceIndexCoordinator = new PersistenceIndexCoordinator({
5015
+ get config() { return self.config; },
5016
+ get contentHashIndex() { return self.contentHashIndex; },
5017
+ contentHashIndexForStorage: (targetStorage) => self.contentHashIndexForStorage(targetStorage),
5018
+ get contentHashIndexesByStorageDir() { return self.contentHashIndexesByStorageDir; },
5019
+ get embeddingFallback() { return self.embeddingFallback; },
5020
+ graphIndexFor: (storage) => self.graphIndexFor(storage),
5021
+ readAllMemoriesForNamespaces: (namespaces) => self.readAllMemoriesForNamespaces(namespaces),
5022
+ semanticDedupScopeFor: (targetStorage) => self.semanticDedupScopeFor(targetStorage),
5023
+ });
5024
+ }
5025
+ return this._persistenceIndexCoordinator;
5026
+ }
5027
+
5149
5028
  private async recallInternal(
5150
5029
  prompt: string,
5151
5030
  sessionKey?: string,
@@ -5593,22 +5472,12 @@ export class Orchestrator {
5593
5472
  storage: StorageManager,
5594
5473
  memoryId: string,
5595
5474
  ): Promise<void> {
5596
- if (!resolveMemoryLifecycleCapabilities(this.config).embeddingFallback) return;
5597
- if (!(await this.embeddingFallback.isAvailable())) return;
5598
- const memory = await storage.getMemoryById(memoryId);
5599
- if (!memory) return;
5600
- await this.embeddingFallback.indexFile(
5475
+ return this.persistenceIndexCoordinator.indexPersistedMemory(
5476
+ storage,
5601
5477
  memoryId,
5602
- memory.content,
5603
- memory.path,
5604
5478
  );
5605
5479
  }
5606
5480
 
5607
- /**
5608
- * Build a graph edge for a persisted memory (v8.2).
5609
- * Shared helper used by both the chunked and non-chunked write paths to avoid duplication.
5610
- * Fail-open: caller wraps in try/catch.
5611
- */
5612
5481
  private async buildGraphEdge(
5613
5482
  storage: StorageManager,
5614
5483
  memoryRelPath: string,
@@ -5622,65 +5491,19 @@ export class Orchestrator {
5622
5491
  fallbackCausalPredecessor: string | undefined,
5623
5492
  graphCaps: GraphConstructionCapabilitySet = resolveGraphConstructionCapabilities(this.config),
5624
5493
  ): Promise<void> {
5625
- // Entity siblings: other memories sharing the same entityRef
5626
- const entitySiblings: string[] = [];
5627
- if (entityRef) {
5628
- try {
5629
- const allMems = allMemsForGraph ?? [];
5630
- for (const m of allMems) {
5631
- if (m.frontmatter.entityRef === entityRef) {
5632
- const rel = path.relative(storage.dir, m.path);
5633
- if (rel !== memoryRelPath) entitySiblings.push(rel);
5634
- }
5635
- }
5636
- } catch {
5637
- /* fail-open */
5638
- }
5639
- }
5640
- // Recent thread memories for time graph
5641
- const recentInThread: string[] = [];
5642
- if (threadIdForEdge && threadEpisodeIdsForGraph?.length) {
5643
- try {
5644
- recentInThread.push(
5645
- ...resolveRecentThreadMemoryPaths({
5646
- threadEpisodeIds: threadEpisodeIdsForGraph,
5647
- currentMemoryId: memoryId,
5648
- allMemsForGraph,
5649
- pathById: memoryPathById,
5650
- storageDir: storage.dir,
5651
- maxRecent: 3,
5652
- }),
5653
- );
5654
- } catch {
5655
- /* fail-open */
5656
- }
5657
- }
5658
- if (
5659
- recentInThread.length === 0 &&
5660
- graphCaps.graphWriteSessionAdjacency &&
5661
- fallbackCausalPredecessor &&
5662
- fallbackCausalPredecessor !== memoryRelPath
5663
- ) {
5664
- recentInThread.push(fallbackCausalPredecessor);
5665
- }
5666
- const causalPredecessor =
5667
- recentInThread[recentInThread.length - 1] ?? fallbackCausalPredecessor;
5668
- await this.graphIndexFor(storage).onMemoryWritten({
5669
- memoryPath: memoryRelPath,
5494
+ return this.persistenceIndexCoordinator.buildGraphEdge(
5495
+ storage,
5496
+ memoryRelPath,
5670
5497
  entityRef,
5671
- content: factContent,
5672
- created: new Date().toISOString(),
5673
- threadId: threadIdForEdge,
5674
- recentInThread,
5675
- entitySiblings,
5676
- causalPredecessor,
5677
- graphCapsOverride: {
5678
- entityGraph: graphCaps.entityGraph,
5679
- timeGraph: graphCaps.timeGraph,
5680
- causalGraph: graphCaps.causalGraph,
5681
- multiGraphMemory: graphCaps.multiGraphMemory,
5682
- },
5683
- });
5498
+ memoryId,
5499
+ factContent,
5500
+ allMemsForGraph,
5501
+ memoryPathById,
5502
+ threadIdForEdge,
5503
+ threadEpisodeIdsForGraph,
5504
+ fallbackCausalPredecessor,
5505
+ graphCaps,
5506
+ );
5684
5507
  }
5685
5508
 
5686
5509
  private graphIndexFor(storage: StorageManager): GraphIndex {
@@ -5692,89 +5515,14 @@ export class Orchestrator {
5692
5515
  return created;
5693
5516
  }
5694
5517
 
5695
- /**
5696
- * Batch-update temporal and tag indexes after extraction (v8.1).
5697
- * Reads each persisted memory's path + frontmatter and adds them to
5698
- * state/index_time.json and state/index_tags.json.
5699
- * Fail-open: any error is logged but does not abort extraction.
5700
- */
5701
5518
  private async updateTemporalTagIndexes(
5702
5519
  storage: StorageManager,
5703
5520
  persistedIds: string[],
5704
5521
  ): Promise<void> {
5705
- const caps = resolveCapabilities(this.config); // #1566 Cluster C
5706
- // Build temporal/tag indexes whenever either consumer is enabled:
5707
- // - queryAwareIndexingEnabled: uses indexes for query-aware prefiltering in recall
5708
- // - parallelRetrievalEnabled: temporal agent reads index_time.json for date-range lookup
5709
- // Enabling only parallelRetrievalEnabled without queryAwareIndexingEnabled would silently
5710
- // produce an empty temporal index, leaving the temporal agent with no data to work from.
5711
- if (
5712
- !resolveIndexingCapabilities(this.config).queryAwareIndexing &&
5713
- !caps.parallelRetrieval
5714
- )
5715
- return;
5716
- // Check for missing indexes BEFORE the early-return so first-time enablement
5717
- // can bootstrap the full corpus even when this extraction turn persisted nothing.
5718
- const needsFullRebuild = !indexesExist(this.config.memoryDir);
5719
- if (!needsFullRebuild && persistedIds.length === 0) return;
5720
- try {
5721
- // Read the corpus once to avoid N separate full-corpus scans.
5722
- // On full rebuild with namespaces enabled, span all configured namespaces so
5723
- // memories written to other namespaces before the index existed are also captured.
5724
- const allMemories =
5725
- needsFullRebuild && resolveNamespaceCapabilities(this.config).namespaces
5726
- ? await this.readAllMemoriesForNamespaces(
5727
- Array.from(
5728
- new Set<string>([
5729
- this.config.defaultNamespace,
5730
- this.config.sharedNamespace,
5731
- ...this.config.namespacePolicies.map((p) => p.name),
5732
- ]),
5733
- ),
5734
- )
5735
- : await storage.readAllMemories();
5736
-
5737
- // Bootstrap: index only active (non-archived, non-superseded) memories.
5738
- // Incremental: index only the newly persisted IDs.
5739
- const pool = needsFullRebuild
5740
- ? allMemories.filter((m) => isActiveMemoryStatus(m.frontmatter.status))
5741
- : (() => {
5742
- const idSet = new Set(persistedIds);
5743
- return allMemories.filter((m) => idSet.has(m.frontmatter.id));
5744
- })();
5745
-
5746
- const entries: Array<{
5747
- path: string;
5748
- createdAt: string;
5749
- tags: string[];
5750
- }> = [];
5751
- for (const mem of pool) {
5752
- if (mem.path && mem.frontmatter?.created) {
5753
- entries.push({
5754
- path: mem.path,
5755
- createdAt: mem.frontmatter.created,
5756
- tags: mem.frontmatter.tags ?? [],
5757
- });
5758
- }
5759
- }
5760
- if (needsFullRebuild) {
5761
- // Always write empty indexes on full rebuild — even when the active pool
5762
- // is empty (e.g. store contains only archived/superseded entries).
5763
- // This marks bootstrap completion so indexesExist() returns true and
5764
- // subsequent extractions skip the full-corpus scan.
5765
- clearIndexes(this.config.memoryDir);
5766
- if (entries.length > 0) {
5767
- indexMemoriesBatch(this.config.memoryDir, entries);
5768
- }
5769
- log.info(
5770
- `temporal-index: bootstrapped from ${entries.length} active memories`,
5771
- );
5772
- } else if (entries.length > 0) {
5773
- indexMemoriesBatch(this.config.memoryDir, entries);
5774
- }
5775
- } catch (err) {
5776
- log.debug(`temporal-index update failed (non-fatal): ${err}`);
5777
- }
5522
+ return this.persistenceIndexCoordinator.updateTemporalTagIndexes(
5523
+ storage,
5524
+ persistedIds,
5525
+ );
5778
5526
  }
5779
5527
 
5780
5528
  /** IDs of facts persisted in the last extraction */
@@ -6198,60 +5946,16 @@ export class Orchestrator {
6198
5946
  );
6199
5947
  }
6200
5948
 
6201
- /**
6202
- * Issue #373 — nearest-neighbor lookup for the write-time semantic dedup
6203
- * guard. Returns the top-K embedding hits against the currently indexed
6204
- * memories, or an empty array when the embedding backend is unavailable.
6205
- * Intentionally does NOT throw; `decideSemanticDedup` treats both "empty"
6206
- * and "error" outcomes as fail-open (keep the candidate).
6207
- *
6208
- * PR #399 P1 fix: when namespaces are enabled the lookup must be scoped
6209
- * to the SAME namespace as the fact being written. Otherwise a
6210
- * high-similarity memory from another namespace can suppress a write in
6211
- * the target namespace — cross-tenant data loss. Callers pass the target
6212
- * storage so we can translate its root directory into the correct index
6213
- * path prefix (and, for the legacy default-namespace layout at
6214
- * `memoryDir` root, an exclusion list for `namespaces/*`).
6215
- */
6216
5949
  async semanticDedupLookup(
6217
5950
  content: string,
6218
5951
  limit: number,
6219
5952
  targetStorage: StorageManager,
6220
5953
  ): Promise<SemanticDedupHit[]> {
6221
- // Round 6 fix (Finding 3): backend-unavailable conditions must THROW so
6222
- // that `decideSemanticDedup`'s catch block can return
6223
- // reason="backend_unavailable". Previously all error/unavailable paths
6224
- // returned [] — causing decideSemanticDedup to always report
6225
- // reason="no_candidates" even when the provider was actually down.
6226
- //
6227
- // Contract after this fix:
6228
- // • embeddingFallbackEnabled=false → throw (feature not configured;
6229
- // caller treats this as backend_unavailable and fails open).
6230
- // • isAvailable() returns false → throw (provider is reachable but
6231
- // reports itself unavailable; distinct from empty index).
6232
- // • search() throws → re-throw (network/provider error).
6233
- // • search() returns [] → return [] (empty index, not a
6234
- // backend failure; decideSemanticDedup reports no_candidates).
6235
- if (!resolveMemoryLifecycleCapabilities(this.config).embeddingFallback) {
6236
- throw new Error("semantic dedup: embedding backend not configured");
6237
- }
6238
- if (!(await this.embeddingFallback.isAvailable())) {
6239
- log.debug("semantic dedup: embedding backend unavailable, skipping");
6240
- throw new Error("semantic dedup: embedding backend unavailable");
6241
- }
6242
- // search() may throw — let it propagate so decideSemanticDedup catches it
6243
- // and returns reason="backend_unavailable". Pass throwOnTimeout:true so
6244
- // EmbeddingTimeoutError is re-thrown here (Round 10 fix, Ui1J+Ui1L: the
6245
- // recall-path caller searchEmbeddingFallback does NOT pass this flag,
6246
- // keeping its fail-open [] contract on timeout).
6247
- const scope = this.semanticDedupScopeFor(targetStorage);
6248
- const hits = await this.embeddingFallback.search(content, limit, { ...scope, throwOnTimeout: true });
6249
- if (!Array.isArray(hits) || hits.length === 0) return [];
6250
- return hits.map((hit) => ({
6251
- id: hit.id,
6252
- score: hit.score,
6253
- path: hit.path,
6254
- }));
5954
+ return this.persistenceIndexCoordinator.semanticDedupLookup(
5955
+ content,
5956
+ limit,
5957
+ targetStorage,
5958
+ );
6255
5959
  }
6256
5960
 
6257
5961
  /**