@remnic/core 9.3.660 → 9.3.661

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.
Files changed (179) hide show
  1. package/dist/access-cli.js +10 -10
  2. package/dist/access-http.d.ts +5 -5
  3. package/dist/access-http.js +10 -10
  4. package/dist/access-mcp.d.ts +5 -5
  5. package/dist/access-mcp.js +9 -9
  6. package/dist/{access-service-D_nbpexW.d.ts → access-service-D0SLB4MH.d.ts} +2 -2
  7. package/dist/access-service.d.ts +5 -5
  8. package/dist/access-service.js +8 -8
  9. package/dist/action-confidence.d.ts +1 -1
  10. package/dist/active-memory-bridge.d.ts +1 -1
  11. package/dist/active-recall.d.ts +1 -1
  12. package/dist/behavior-learner.d.ts +1 -1
  13. package/dist/behavior-signals.d.ts +1 -1
  14. package/dist/bootstrap.d.ts +3 -3
  15. package/dist/briefing.d.ts +1 -1
  16. package/dist/buffer-surprise-report.d.ts +1 -1
  17. package/dist/buffer.d.ts +1 -1
  18. package/dist/calibration.d.ts +1 -1
  19. package/dist/causal-behavior.d.ts +1 -1
  20. package/dist/causal-consolidation.d.ts +1 -1
  21. package/dist/{chunk-7PCZGNG2.js → chunk-34NSUPWS.js} +109 -24
  22. package/dist/chunk-34NSUPWS.js.map +1 -0
  23. package/dist/{chunk-FWIROLS6.js → chunk-44VFF3BB.js} +18 -16
  24. package/dist/chunk-44VFF3BB.js.map +1 -0
  25. package/dist/{chunk-2EVZ5EN6.js → chunk-4SYURHI6.js} +6 -6
  26. package/dist/{chunk-7H7J3ZWN.js → chunk-5G2DNO54.js} +2 -2
  27. package/dist/{chunk-OYXVENIS.js → chunk-7F7LC6HW.js} +3 -3
  28. package/dist/{chunk-256W7AXC.js → chunk-BP5O3GYD.js} +2 -2
  29. package/dist/{chunk-RP2U54GG.js → chunk-D44FQVCU.js} +2 -2
  30. package/dist/{chunk-R2EBP6CM.js → chunk-EMSC4P66.js} +5 -5
  31. package/dist/{chunk-6G5JEN55.js → chunk-FZC2WSDB.js} +2 -2
  32. package/dist/{chunk-UNLHHTKN.js → chunk-LFZUFZQR.js} +10 -2
  33. package/dist/chunk-LFZUFZQR.js.map +1 -0
  34. package/dist/{chunk-B57QYSWN.js → chunk-MHYRRV43.js} +109 -16
  35. package/dist/chunk-MHYRRV43.js.map +1 -0
  36. package/dist/{chunk-MO77TWPS.js → chunk-RQRKQJYM.js} +2 -2
  37. package/dist/{chunk-5PLUC5OB.js → chunk-WSQG37DV.js} +2 -2
  38. package/dist/{chunk-M3VYPE2H.js → chunk-YNQ6DFSV.js} +1 -1
  39. package/dist/chunk-YNQ6DFSV.js.map +1 -0
  40. package/dist/{chunk-GRYAECRV.js → chunk-ZJH723NM.js} +2 -2
  41. package/dist/{cli-aYxSuPvP.d.ts → cli-C6twwe84.d.ts} +3 -3
  42. package/dist/cli.d.ts +5 -5
  43. package/dist/cli.js +13 -13
  44. package/dist/compounding/engine.d.ts +1 -1
  45. package/dist/compounding/preference-consolidator.d.ts +1 -1
  46. package/dist/compression-optimizer.d.ts +1 -1
  47. package/dist/config.d.ts +1 -1
  48. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  49. package/dist/connectors/codex-materialize.d.ts +1 -1
  50. package/dist/connectors/index.d.ts +1 -1
  51. package/dist/consolidation-provenance-check.d.ts +1 -1
  52. package/dist/consolidation-undo.d.ts +1 -1
  53. package/dist/contradiction/index.d.ts +1 -1
  54. package/dist/conversation-index/backend.d.ts +1 -1
  55. package/dist/conversation-index/chunker.d.ts +1 -1
  56. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  57. package/dist/conversation-index/indexer.d.ts +1 -1
  58. package/dist/conversation-index/search.d.ts +1 -1
  59. package/dist/day-summary.d.ts +1 -1
  60. package/dist/delinearize.d.ts +1 -1
  61. package/dist/direct-answer-wiring.d.ts +1 -1
  62. package/dist/direct-answer.d.ts +1 -1
  63. package/dist/embedding-fallback.d.ts +1 -1
  64. package/dist/enrichment/index.d.ts +1 -1
  65. package/dist/entity-retrieval.d.ts +1 -1
  66. package/dist/entity-schema.d.ts +1 -1
  67. package/dist/explicit-capture.d.ts +3 -3
  68. package/dist/extraction-judge-telemetry.d.ts +1 -1
  69. package/dist/extraction-judge-training.d.ts +1 -1
  70. package/dist/extraction-judge.d.ts +1 -1
  71. package/dist/extraction.d.ts +1 -1
  72. package/dist/fallback-llm.d.ts +1 -1
  73. package/dist/identity-continuity.d.ts +1 -1
  74. package/dist/importance.d.ts +1 -1
  75. package/dist/index.d.ts +8 -8
  76. package/dist/index.js +15 -15
  77. package/dist/intent.d.ts +1 -1
  78. package/dist/lcm/engine.d.ts +1 -1
  79. package/dist/lcm/index.d.ts +1 -1
  80. package/dist/lcm/tools.d.ts +1 -1
  81. package/dist/lifecycle.d.ts +1 -1
  82. package/dist/live-connectors-runner.d.ts +1 -1
  83. package/dist/local-llm.d.ts +1 -1
  84. package/dist/maintenance/memory-governance.d.ts +1 -1
  85. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  86. package/dist/memory-action-policy.d.ts +1 -1
  87. package/dist/memory-cache.d.ts +1 -1
  88. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  89. package/dist/memory-projection-store.d.ts +1 -1
  90. package/dist/memory-provenance.d.ts +1 -1
  91. package/dist/memory-worth-outcomes.d.ts +1 -1
  92. package/dist/models-json.d.ts +1 -1
  93. package/dist/namespaces/migrate.d.ts +1 -1
  94. package/dist/namespaces/migrate.js +8 -8
  95. package/dist/namespaces/principal.d.ts +1 -1
  96. package/dist/namespaces/search.d.ts +1 -1
  97. package/dist/namespaces/search.js +7 -7
  98. package/dist/namespaces/storage.d.ts +1 -1
  99. package/dist/native-knowledge.d.ts +1 -1
  100. package/dist/operator-toolkit.d.ts +1 -1
  101. package/dist/operator-toolkit.js +9 -9
  102. package/dist/{orchestrator-D1wcmPNj.d.ts → orchestrator-Cg1UkvmO.d.ts} +2 -2
  103. package/dist/orchestrator.d.ts +3 -3
  104. package/dist/orchestrator.js +9 -9
  105. package/dist/patterns-cli.d.ts +1 -1
  106. package/dist/policy-runtime.d.ts +1 -1
  107. package/dist/qmd-recall-cache.d.ts +1 -1
  108. package/dist/qmd.d.ts +17 -1
  109. package/dist/qmd.js +2 -2
  110. package/dist/recall-disclosure-escalation.d.ts +1 -1
  111. package/dist/recall-explain-renderer.d.ts +2 -1
  112. package/dist/recall-planner-llm.d.ts +1 -1
  113. package/dist/recall-state.d.ts +17 -1
  114. package/dist/recall-state.js +1 -1
  115. package/dist/recall-tag-filter.d.ts +1 -1
  116. package/dist/recall-xray-cli.d.ts +1 -1
  117. package/dist/recall-xray-renderer.d.ts +1 -1
  118. package/dist/recall-xray.d.ts +1 -1
  119. package/dist/resolve-auth-token.d.ts +1 -1
  120. package/dist/retrieval-agents.d.ts +1 -1
  121. package/dist/retrieval-tiers.d.ts +1 -1
  122. package/dist/routing/engine.d.ts +1 -1
  123. package/dist/routing/store.d.ts +1 -1
  124. package/dist/search/embed-helper.d.ts +1 -1
  125. package/dist/search/factory.d.ts +1 -1
  126. package/dist/search/factory.js +6 -6
  127. package/dist/search/index.d.ts +1 -1
  128. package/dist/search/index.js +6 -6
  129. package/dist/search/lancedb-backend.d.ts +1 -1
  130. package/dist/search/lancedb-backend.js +2 -2
  131. package/dist/search/meilisearch-backend.d.ts +1 -1
  132. package/dist/search/meilisearch-backend.js +2 -2
  133. package/dist/search/noop-backend.d.ts +1 -1
  134. package/dist/search/orama-backend.d.ts +1 -1
  135. package/dist/search/orama-backend.js +2 -2
  136. package/dist/search/port.d.ts +21 -2
  137. package/dist/search/port.js +1 -1
  138. package/dist/search/remote-backend.d.ts +1 -1
  139. package/dist/{semantic-consolidation-MWOdNtSE.d.ts → semantic-consolidation-BICZvQ3C.d.ts} +1 -1
  140. package/dist/semantic-consolidation.d.ts +2 -2
  141. package/dist/semantic-rule-verifier.d.ts +1 -1
  142. package/dist/session-observer-bands.d.ts +1 -1
  143. package/dist/session-observer-state.d.ts +1 -1
  144. package/dist/shared-context/manager.d.ts +1 -1
  145. package/dist/signal.d.ts +1 -1
  146. package/dist/storage.d.ts +1 -1
  147. package/dist/summarizer.d.ts +1 -1
  148. package/dist/summary-snapshot.d.ts +1 -1
  149. package/dist/temporal-supersession.d.ts +1 -1
  150. package/dist/temporal-validity.d.ts +1 -1
  151. package/dist/threading.d.ts +1 -1
  152. package/dist/tier-migration.d.ts +1 -1
  153. package/dist/tier-routing.d.ts +1 -1
  154. package/dist/topics.d.ts +1 -1
  155. package/dist/transcript.d.ts +1 -1
  156. package/dist/{types-CgcCpUrf.d.ts → types-D96bCB3C.d.ts} +1 -1
  157. package/dist/types.d.ts +1 -1
  158. package/dist/utility-runtime.d.ts +1 -1
  159. package/package.json +1 -1
  160. package/src/namespaces/search.ts +16 -0
  161. package/src/orchestrator.ts +144 -3
  162. package/src/qmd.ts +131 -18
  163. package/src/recall-state.ts +47 -21
  164. package/src/search/port.ts +25 -0
  165. package/dist/chunk-7PCZGNG2.js.map +0 -1
  166. package/dist/chunk-B57QYSWN.js.map +0 -1
  167. package/dist/chunk-FWIROLS6.js.map +0 -1
  168. package/dist/chunk-M3VYPE2H.js.map +0 -1
  169. package/dist/chunk-UNLHHTKN.js.map +0 -1
  170. /package/dist/{chunk-2EVZ5EN6.js.map → chunk-4SYURHI6.js.map} +0 -0
  171. /package/dist/{chunk-7H7J3ZWN.js.map → chunk-5G2DNO54.js.map} +0 -0
  172. /package/dist/{chunk-OYXVENIS.js.map → chunk-7F7LC6HW.js.map} +0 -0
  173. /package/dist/{chunk-256W7AXC.js.map → chunk-BP5O3GYD.js.map} +0 -0
  174. /package/dist/{chunk-RP2U54GG.js.map → chunk-D44FQVCU.js.map} +0 -0
  175. /package/dist/{chunk-R2EBP6CM.js.map → chunk-EMSC4P66.js.map} +0 -0
  176. /package/dist/{chunk-6G5JEN55.js.map → chunk-FZC2WSDB.js.map} +0 -0
  177. /package/dist/{chunk-MO77TWPS.js.map → chunk-RQRKQJYM.js.map} +0 -0
  178. /package/dist/{chunk-5PLUC5OB.js.map → chunk-WSQG37DV.js.map} +0 -0
  179. /package/dist/{chunk-GRYAECRV.js.map → chunk-ZJH723NM.js.map} +0 -0
@@ -52,6 +52,7 @@ import {
52
52
  import { findUnresolvedEntityRefs } from "./reconstruct.js";
53
53
  import type {
54
54
  SearchBackend,
55
+ SearchDegradation,
55
56
  SearchExecutionOptions,
56
57
  SearchQueryOptions,
57
58
  } from "./search/port.js";
@@ -6392,6 +6393,8 @@ export class Orchestrator {
6392
6393
  queryAwarePrefilter?: QueryAwarePrefilter;
6393
6394
  searchOptions?: SearchQueryOptions;
6394
6395
  onDebugSnapshot?: (snapshot: QmdRecallSnapshot) => Promise<void>;
6396
+ /** Backend degradation observer, threaded into every QMD call (#1536). */
6397
+ onDegradation?: (degradation: SearchDegradation) => void;
6395
6398
  abortSignal?: AbortSignal;
6396
6399
  },
6397
6400
  ): Promise<QmdSearchResult[]> {
@@ -6497,6 +6500,7 @@ export class Orchestrator {
6497
6500
  primarySearchOptions,
6498
6501
  {
6499
6502
  signal: options.abortSignal,
6503
+ onDegradation: options.onDegradation,
6500
6504
  },
6501
6505
  )
6502
6506
  : await this.qmd.search(
@@ -6504,6 +6508,7 @@ export class Orchestrator {
6504
6508
  options.collection,
6505
6509
  fetchLimit,
6506
6510
  primarySearchOptions,
6511
+ { onDegradation: options.onDegradation },
6507
6512
  )
6508
6513
  : await this.searchAcrossNamespaces({
6509
6514
  query: prompt,
@@ -6513,7 +6518,10 @@ export class Orchestrator {
6513
6518
  maxResults: fetchLimit,
6514
6519
  mode: "search",
6515
6520
  searchOptions: primarySearchOptions,
6516
- execution: { signal: options.abortSignal },
6521
+ execution: {
6522
+ signal: options.abortSignal,
6523
+ onDegradation: options.onDegradation,
6524
+ },
6517
6525
  });
6518
6526
  lastPrimaryResultCount = primaryResults.length;
6519
6527
  lastHybridResultCount = 0;
@@ -6543,6 +6551,7 @@ export class Orchestrator {
6543
6551
  fetchLimit,
6544
6552
  {
6545
6553
  signal: options.abortSignal,
6554
+ onDegradation: options.onDegradation,
6546
6555
  },
6547
6556
  )
6548
6557
  : await this.searchAcrossNamespaces({
@@ -6552,7 +6561,10 @@ export class Orchestrator {
6552
6561
  : undefined,
6553
6562
  maxResults: fetchLimit,
6554
6563
  mode: "hybrid",
6555
- execution: { signal: options.abortSignal },
6564
+ execution: {
6565
+ signal: options.abortSignal,
6566
+ onDegradation: options.onDegradation,
6567
+ },
6556
6568
  });
6557
6569
  lastHybridResultCount = hybridResults.length;
6558
6570
  lastHybridTopUpUsed = hybridResults.length > 0;
@@ -7241,6 +7253,11 @@ export class Orchestrator {
7241
7253
  options: RecallInvocationOptions = {},
7242
7254
  ): Promise<string> {
7243
7255
  const recallStart = Date.now();
7256
+ // Backend degradations observed by this recall's QMD searches (#1536):
7257
+ // collected via the execution-options observer and attached to the
7258
+ // LastRecallSnapshot after it is recorded, so surfaces can distinguish
7259
+ // "no matches" from "backend could not answer" (CLAUDE.md rule 34).
7260
+ const backendDegradations: SearchDegradation[] = [];
7244
7261
  // Issue #680 — historical recall. Parse `options.asOf` once at the
7245
7262
  // top of the recall so each boost-pass uses identical filter logic.
7246
7263
  // Invalid values are rejected at input boundaries (CLI / HTTP / MCP)
@@ -9230,12 +9247,39 @@ export class Orchestrator {
9230
9247
  * an exact entity-name match) are not discarded just because the QMD
9231
9248
  * contextual pass returned a weak result. */
9232
9249
  maxSpecializedScore: number;
9250
+ /**
9251
+ * Degradations observed while producing this phase result (#1536).
9252
+ * Cached WITH the result so cache hits replay them — a served-from-
9253
+ * cache partial result must still explain why it is partial (codex
9254
+ * round-4 review on #1544).
9255
+ */
9256
+ degradations?: SearchDegradation[];
9233
9257
  } | null;
9234
9258
 
9235
9259
  const qmdEnrichmentAbort = createEnrichmentAbortHandle(options.abortSignal);
9236
9260
  const qmdPromise = observeEnrichmentPromise(
9237
9261
  (async (): Promise<QmdPhaseResult> => {
9238
9262
  const t0 = Date.now();
9263
+ // Degradation accounting for this phase (#1536): everything pushed
9264
+ // after this mark belongs to this phase and is cached with its
9265
+ // result; cache hits and stale fallbacks replay stored degradations
9266
+ // so served-from-cache results still explain their gaps, and every
9267
+ // path that skips QMD entirely reports backend_unavailable.
9268
+ const phaseDegradationsStart = backendDegradations.length;
9269
+ const replayCachedDegradations = (value: {
9270
+ degradations?: SearchDegradation[];
9271
+ }) => {
9272
+ for (const degradation of value.degradations ?? []) {
9273
+ backendDegradations.push(degradation);
9274
+ }
9275
+ };
9276
+ const reportRecallQmdUnavailable = (detail: string) => {
9277
+ backendDegradations.push({
9278
+ backend: "qmd",
9279
+ code: "backend_unavailable",
9280
+ detail,
9281
+ });
9282
+ };
9239
9283
  if (recallResultLimit <= 0) {
9240
9284
  recordRecallSectionMetric({
9241
9285
  section: "qmd",
@@ -9287,6 +9331,7 @@ export class Orchestrator {
9287
9331
  success: true,
9288
9332
  timing: `${Math.max(0, Math.round(cachedQmd.ageMs))}ms-cache`,
9289
9333
  });
9334
+ replayCachedDegradations(cachedQmd.value);
9290
9335
  if (queryAwarePrefilterIsEmpty) {
9291
9336
  return emptyQueryAwareQmdResult;
9292
9337
  }
@@ -9310,6 +9355,8 @@ export class Orchestrator {
9310
9355
  success: true,
9311
9356
  timing: `stale-cache(reprobe-cooldown:${Math.max(0, Math.round(staleQmdFallback.ageMs))}ms)`,
9312
9357
  });
9358
+ reportRecallQmdUnavailable("served stale recall cache (reprobe cooldown)");
9359
+ replayCachedDegradations(staleQmdFallback.value);
9313
9360
  if (queryAwarePrefilterIsEmpty) {
9314
9361
  return emptyQueryAwareQmdResult;
9315
9362
  }
@@ -9324,6 +9371,7 @@ export class Orchestrator {
9324
9371
  success: true,
9325
9372
  timing: "skip(reprobe-cooldown)",
9326
9373
  });
9374
+ reportRecallQmdUnavailable("recall skipped QMD (reprobe cooldown)");
9327
9375
  return null;
9328
9376
  }
9329
9377
  this.lastQmdReprobeAtMs = now;
@@ -9339,6 +9387,8 @@ export class Orchestrator {
9339
9387
  success: true,
9340
9388
  timing: `stale-cache(reprobe-failed:${Math.max(0, Math.round(staleQmdFallback.ageMs))}ms)`,
9341
9389
  });
9390
+ reportRecallQmdUnavailable("served stale recall cache (reprobe failed)");
9391
+ replayCachedDegradations(staleQmdFallback.value);
9342
9392
  if (queryAwarePrefilterIsEmpty) {
9343
9393
  return emptyQueryAwareQmdResult;
9344
9394
  }
@@ -9356,6 +9406,7 @@ export class Orchestrator {
9356
9406
  log.debug(
9357
9407
  `Search skip (re-probe failed): ${this.qmd.debugStatus()}`,
9358
9408
  );
9409
+ reportRecallQmdUnavailable("recall skipped QMD (reprobe failed)");
9359
9410
  return null;
9360
9411
  }
9361
9412
  log.info(`QMD re-probe succeeded: ${this.qmd.debugStatus()}`);
@@ -9437,6 +9488,9 @@ export class Orchestrator {
9437
9488
  queryAwarePrefilter,
9438
9489
  searchOptions: qmdSearchOptions,
9439
9490
  abortSignal: qmdEnrichmentAbort.signal,
9491
+ onDegradation: (degradation) => {
9492
+ backendDegradations.push(degradation);
9493
+ },
9440
9494
  onDebugSnapshot: async (snapshot) => {
9441
9495
  await this.recordLastQmdRecallSnapshot({
9442
9496
  storage: profileStorage,
@@ -9487,11 +9541,17 @@ export class Orchestrator {
9487
9541
  }
9488
9542
  }
9489
9543
 
9544
+ const phaseDegradations = backendDegradations.slice(
9545
+ phaseDegradationsStart,
9546
+ );
9490
9547
  const result = {
9491
9548
  memoryResultsLists: [augmentedResults],
9492
9549
  globalResults: [],
9493
9550
  preAugmentTopScore,
9494
9551
  maxSpecializedScore,
9552
+ ...(phaseDegradations.length > 0
9553
+ ? { degradations: phaseDegradations }
9554
+ : {}),
9495
9555
  };
9496
9556
  if (
9497
9557
  augmentedResults.length > 0 ||
@@ -9521,6 +9581,8 @@ export class Orchestrator {
9521
9581
  success: true,
9522
9582
  timing: `stale-cache(${err instanceof Error ? err.message : String(err)})`,
9523
9583
  });
9584
+ reportRecallQmdUnavailable("served stale recall cache (qmd phase error)");
9585
+ replayCachedDegradations(staleQmdFallback.value);
9524
9586
  if (queryAwarePrefilterIsEmpty) {
9525
9587
  return emptyQueryAwareQmdResult;
9526
9588
  }
@@ -9542,7 +9604,23 @@ export class Orchestrator {
9542
9604
  return null;
9543
9605
  })
9544
9606
  .finally(() => qmdEnrichmentAbort.dispose()),
9545
- () => qmdEnrichmentAbort.cancel(),
9607
+ () => {
9608
+ // The enrichment budget abandoned the hot QMD phase mid-flight
9609
+ // (#1536, codex round-7 on #1544): QmdClient treats this abort as
9610
+ // caller cancellation and never reports, so report the abandonment
9611
+ // deterministically here — the exact mirror of the cold-tier
9612
+ // deadline gate. Guarded: a CALLER abort also routes through this
9613
+ // cancel callback, and an aborted recall is not a backend
9614
+ // degradation (no snapshot is recorded for it anyway).
9615
+ if (!options.abortSignal?.aborted) {
9616
+ backendDegradations.push({
9617
+ backend: "qmd",
9618
+ code: "deadline_exceeded",
9619
+ detail: "hot qmd enrichment abandoned (enrichment deadline)",
9620
+ });
9621
+ }
9622
+ qmdEnrichmentAbort.cancel();
9623
+ },
9546
9624
  );
9547
9625
 
9548
9626
  const transcriptPromise = (async (): Promise<string | null> => {
@@ -11329,6 +11407,9 @@ export class Orchestrator {
11329
11407
  recallMode,
11330
11408
  queryAwarePrefilter,
11331
11409
  abortSignal: options.abortSignal,
11410
+ onDegradation: (degradation) => {
11411
+ backendDegradations.push(degradation);
11412
+ },
11332
11413
  xrayPoolSizeSink: xrayColdPoolSink,
11333
11414
  deadlineAtMs: enrichmentAssemblyDeadlineAtMs,
11334
11415
  asOfMs,
@@ -11551,6 +11632,9 @@ export class Orchestrator {
11551
11632
  recallMode,
11552
11633
  queryAwarePrefilter,
11553
11634
  abortSignal: options.abortSignal,
11635
+ onDegradation: (degradation) => {
11636
+ backendDegradations.push(degradation);
11637
+ },
11554
11638
  xrayPoolSizeSink: xrayColdPoolSink,
11555
11639
  deadlineAtMs: enrichmentAssemblyDeadlineAtMs,
11556
11640
  asOfMs,
@@ -11660,6 +11744,9 @@ export class Orchestrator {
11660
11744
  recallMode,
11661
11745
  queryAwarePrefilter,
11662
11746
  abortSignal: options.abortSignal,
11747
+ onDegradation: (degradation) => {
11748
+ backendDegradations.push(degradation);
11749
+ },
11663
11750
  xrayPoolSizeSink: xrayColdPoolSink,
11664
11751
  deadlineAtMs: enrichmentAssemblyDeadlineAtMs,
11665
11752
  asOfMs,
@@ -11704,6 +11791,9 @@ export class Orchestrator {
11704
11791
  recallMode,
11705
11792
  queryAwarePrefilter,
11706
11793
  abortSignal: options.abortSignal,
11794
+ onDegradation: (degradation) => {
11795
+ backendDegradations.push(degradation);
11796
+ },
11707
11797
  xrayPoolSizeSink: xrayColdPoolSink,
11708
11798
  deadlineAtMs: enrichmentAssemblyDeadlineAtMs,
11709
11799
  asOfMs,
@@ -12105,6 +12195,13 @@ export class Orchestrator {
12105
12195
  injectedChars: identityInjectedChars,
12106
12196
  truncated: identityInjectionTruncated,
12107
12197
  },
12198
+ // Included at record time so the published snapshot is born
12199
+ // annotated — a post-record annotation leaves a window where
12200
+ // readers see the snapshot without degradations, and a concurrent
12201
+ // same-session recall could drop them entirely (codex + cursor
12202
+ // reviews on #1544).
12203
+ backendDegradations:
12204
+ backendDegradations.length > 0 ? backendDegradations : undefined,
12108
12205
  })
12109
12206
  .catch((err) => log.debug(`last recall record failed: ${err}`));
12110
12207
  }
@@ -18577,6 +18674,8 @@ export class Orchestrator {
18577
18674
  recallMode: RecallPlanMode;
18578
18675
  queryAwarePrefilter?: QueryAwarePrefilter;
18579
18676
  abortSignal?: AbortSignal;
18677
+ /** Backend degradation observer — cold-tier QMD must report like hot (#1536). */
18678
+ onDegradation?: (degradation: SearchDegradation) => void;
18580
18679
  /** Issue #680 — historical recall point in ms-since-epoch. */
18581
18680
  asOfMs?: number;
18582
18681
  /**
@@ -18604,10 +18703,19 @@ export class Orchestrator {
18604
18703
  label: string,
18605
18704
  fallback: T,
18606
18705
  task: () => Promise<T>,
18706
+ // Invoked when the deadline abandons this step (before it started or
18707
+ // while it runs), so callers can report the abandonment and gate off
18708
+ // late observer callbacks (#1536, cursor round-6 on #1544).
18709
+ onDeadline?: () => void,
18607
18710
  ): Promise<T> => {
18608
18711
  throwIfRecallAborted(options.abortSignal);
18609
18712
  const remainingMs = deadlineRemainingMs();
18610
18713
  if (remainingMs === 0) {
18714
+ try {
18715
+ onDeadline?.();
18716
+ } catch {
18717
+ // Observers must never break recall.
18718
+ }
18611
18719
  log.debug(`cold-tier recall ${label} skipped: shared assembly deadline expired`);
18612
18720
  return fallback;
18613
18721
  }
@@ -18629,6 +18737,11 @@ export class Orchestrator {
18629
18737
  new Promise<T>((resolve) => {
18630
18738
  timeoutHandle = setTimeout(() => {
18631
18739
  timedOut = true;
18740
+ try {
18741
+ onDeadline?.();
18742
+ } catch {
18743
+ // Observers must never break recall.
18744
+ }
18632
18745
  log.debug(
18633
18746
  `cold-tier recall ${label} skipped: shared assembly deadline expired`,
18634
18747
  );
@@ -18659,6 +18772,24 @@ export class Orchestrator {
18659
18772
  false,
18660
18773
  0,
18661
18774
  );
18775
+ // Deadline-gated observer (#1536, cursor round-6 on #1544): when the
18776
+ // shared assembly deadline abandons this lookup, the still-running
18777
+ // fetch's LATE reports must not land after the recall snapshot has
18778
+ // been recorded — gate them off and report the abandonment itself
18779
+ // deterministically at resolution time instead.
18780
+ let coldQmdObserverActive = true;
18781
+ const reportColdQmdDeadline = () => {
18782
+ coldQmdObserverActive = false;
18783
+ try {
18784
+ options.onDegradation?.({
18785
+ backend: "qmd",
18786
+ code: "deadline_exceeded",
18787
+ detail: "cold-tier qmd lookup abandoned (assembly deadline)",
18788
+ });
18789
+ } catch {
18790
+ // Observers must never break recall.
18791
+ }
18792
+ };
18662
18793
  longTerm = await runColdStepWithinDeadline(
18663
18794
  "qmd lookup",
18664
18795
  [],
@@ -18675,9 +18806,19 @@ export class Orchestrator {
18675
18806
  queryAwarePrefilter: options.queryAwarePrefilter,
18676
18807
  searchOptions: this.buildConfiguredQmdSearchOptions(options.prompt),
18677
18808
  abortSignal: options.abortSignal,
18809
+ onDegradation: (degradation) => {
18810
+ if (coldQmdObserverActive) {
18811
+ options.onDegradation?.(degradation);
18812
+ }
18813
+ },
18678
18814
  },
18679
18815
  ),
18816
+ reportColdQmdDeadline,
18680
18817
  );
18818
+ // Normal completion also closes the gate: a deadline that fires
18819
+ // after this await has nothing left to suppress, and a fetch that
18820
+ // limps home later cannot mutate a recorded recall's collector.
18821
+ coldQmdObserverActive = false;
18681
18822
  if (longTerm.length > 0) {
18682
18823
  log.debug(
18683
18824
  `cold-tier recall source=cold-qmd collection=${coldCollection} hits=${longTerm.length}`,
package/src/qmd.ts CHANGED
@@ -12,6 +12,7 @@ import type { QmdSearchExplain, QmdSearchResult } from "./types.js";
12
12
  import {
13
13
  resolveEnsureCollectionArgs,
14
14
  type SearchBackend,
15
+ type SearchDegradation,
15
16
  type SearchExecutionOptions,
16
17
  type SearchQueryOptions,
17
18
  } from "./search/port.js";
@@ -1941,7 +1942,10 @@ export class QmdClient implements SearchBackend {
1941
1942
  const trimmed = query.trim();
1942
1943
  if (!trimmed) return [];
1943
1944
  await this.maybeProbeDaemon();
1944
- if (!this.isAvailable()) return [];
1945
+ if (!this.isAvailable()) {
1946
+ this.notifyDegradation(execution?.onDegradation, "backend_unavailable");
1947
+ return [];
1948
+ }
1945
1949
 
1946
1950
  const col = collection ?? this.collection;
1947
1951
  const n = maxResults ?? this.maxResults;
@@ -1962,6 +1966,12 @@ export class QmdClient implements SearchBackend {
1962
1966
  .digest("hex");
1963
1967
  const cached = getCachedQmdSearch(cacheKey);
1964
1968
  if (cached) {
1969
+ // No degradation fires on a cache hit BY DESIGN (#1536): only
1970
+ // trustworthy results are ever cached (degraded empties are excluded
1971
+ // at the write below), so a TTL hit is a healthy serve of a recently
1972
+ // valid answer — the backend's live state is irrelevant because no
1973
+ // live call is attempted. Reporting unavailability here would fabricate
1974
+ // a degradation for a recall that was not degraded.
1965
1975
  log.debug(`QMD search cache hit (${cached.length} results)`);
1966
1976
  return cached as QmdSearchResult[];
1967
1977
  }
@@ -1993,7 +2003,8 @@ export class QmdClient implements SearchBackend {
1993
2003
  }
1994
2004
  // Daemon timed out or had a transient error — skip subprocess for large
1995
2005
  // collections. Return empty rather than hanging the caller.
1996
- log.debug("QMD daemon search timed out/failed; skipping subprocess (daemon-only mode)");
2006
+ log.warn("QMD daemon search timed out/failed; skipping subprocess (daemon-only mode)");
2007
+ this.notifyDegradation(execution?.onDegradation, "daemon_timeout");
1997
2008
  return [];
1998
2009
  }
1999
2010
 
@@ -2002,12 +2013,29 @@ export class QmdClient implements SearchBackend {
2002
2013
  // Return empty and let the next recheck cycle pick up the daemon once ready.
2003
2014
  if (this.daemonSession?.isLoading()) {
2004
2015
  log.debug("QMD search: daemon loading, skipping subprocess");
2016
+ this.notifyDegradation(execution?.onDegradation, "daemon_loading");
2005
2017
  return [];
2006
2018
  }
2007
2019
 
2008
2020
  // Subprocess fallback (only reached when daemon is unavailable and not loading)
2009
- const subprocessResults = await this.searchViaSubprocess(trimmed, col, n, searchOptions, execution?.signal);
2010
- setCachedQmdSearch(cacheKey, subprocessResults);
2021
+ let subprocessDegraded = false;
2022
+ const subprocessResults = await this.searchViaSubprocess(
2023
+ trimmed,
2024
+ col,
2025
+ n,
2026
+ searchOptions,
2027
+ execution?.signal,
2028
+ (degradation) => {
2029
+ subprocessDegraded = true;
2030
+ this.notifyDegradation(execution?.onDegradation, degradation.code, degradation.detail);
2031
+ },
2032
+ );
2033
+ // Never cache a degraded empty result: a 60s TTL hit would serve the
2034
+ // failure as a genuine no-matches WITHOUT re-reporting the degradation
2035
+ // (codex review on #1544). Only trustworthy results are cacheable.
2036
+ if (!subprocessDegraded) {
2037
+ setCachedQmdSearch(cacheKey, subprocessResults);
2038
+ }
2011
2039
  return subprocessResults;
2012
2040
  }
2013
2041
 
@@ -2019,7 +2047,10 @@ export class QmdClient implements SearchBackend {
2019
2047
  const trimmed = query.trim();
2020
2048
  if (!trimmed) return [];
2021
2049
  await this.maybeProbeDaemon();
2022
- if (!this.isAvailable()) return [];
2050
+ if (!this.isAvailable()) {
2051
+ this.notifyDegradation(execution?.onDegradation, "backend_unavailable");
2052
+ return [];
2053
+ }
2023
2054
 
2024
2055
  const n = maxResults ?? 6;
2025
2056
  const searchOptions = this.resolveSearchOptions();
@@ -2043,18 +2074,26 @@ export class QmdClient implements SearchBackend {
2043
2074
  }
2044
2075
  return results;
2045
2076
  }
2046
- log.debug("QMD daemon global search timed out/failed; skipping subprocess (daemon-only mode)");
2077
+ log.warn("QMD daemon global search timed out/failed; skipping subprocess (daemon-only mode)");
2078
+ this.notifyDegradation(execution?.onDegradation, "daemon_timeout");
2047
2079
  return [];
2048
2080
  }
2049
2081
 
2050
2082
  // If the daemon is spawned but still loading, skip subprocess — same as search().
2051
2083
  if (this.daemonSession?.isLoading()) {
2052
2084
  log.debug("QMD searchGlobal: daemon loading, skipping subprocess");
2085
+ this.notifyDegradation(execution?.onDegradation, "daemon_loading");
2053
2086
  return [];
2054
2087
  }
2055
2088
 
2056
2089
  // Subprocess fallback (only reached when daemon is unavailable and not loading)
2057
- return this.searchGlobalViaSubprocess(trimmed, n, searchOptions, execution?.signal);
2090
+ return this.searchGlobalViaSubprocess(
2091
+ trimmed,
2092
+ n,
2093
+ searchOptions,
2094
+ execution?.signal,
2095
+ execution?.onDegradation,
2096
+ );
2058
2097
  }
2059
2098
 
2060
2099
  /**
@@ -2069,7 +2108,10 @@ export class QmdClient implements SearchBackend {
2069
2108
  const trimmed = query.trim();
2070
2109
  if (!trimmed) return [];
2071
2110
  await this.maybeProbeDaemon();
2072
- if (!this.isAvailable()) return [];
2111
+ if (!this.isAvailable()) {
2112
+ this.notifyDegradation(execution?.onDegradation, "backend_unavailable");
2113
+ return [];
2114
+ }
2073
2115
  const col = collection ?? this.collection;
2074
2116
  const n = maxResults ?? this.maxResults;
2075
2117
 
@@ -2092,14 +2134,16 @@ export class QmdClient implements SearchBackend {
2092
2134
  }
2093
2135
  return results;
2094
2136
  }
2095
- log.debug("QMD daemon bm25 timed out/failed; skipping subprocess (daemon-only mode)");
2137
+ log.warn("QMD daemon bm25 timed out/failed; skipping subprocess (daemon-only mode)");
2138
+ this.notifyDegradation(execution?.onDegradation, "daemon_timeout");
2096
2139
  return [];
2097
2140
  }
2098
2141
  if (this.daemonSession?.isLoading()) {
2099
2142
  log.debug("QMD bm25: daemon loading, skipping subprocess");
2143
+ this.notifyDegradation(execution?.onDegradation, "daemon_loading");
2100
2144
  return [];
2101
2145
  }
2102
- return this.bm25SearchViaSubprocess(trimmed, col, n, execution?.signal);
2146
+ return this.bm25SearchViaSubprocess(trimmed, col, n, execution?.signal, execution?.onDegradation);
2103
2147
  }
2104
2148
 
2105
2149
  /**
@@ -2114,7 +2158,10 @@ export class QmdClient implements SearchBackend {
2114
2158
  const trimmed = query.trim();
2115
2159
  if (!trimmed) return [];
2116
2160
  await this.maybeProbeDaemon();
2117
- if (!this.isAvailable()) return [];
2161
+ if (!this.isAvailable()) {
2162
+ this.notifyDegradation(execution?.onDegradation, "backend_unavailable");
2163
+ return [];
2164
+ }
2118
2165
  const col = collection ?? this.collection;
2119
2166
  const n = maxResults ?? this.maxResults;
2120
2167
 
@@ -2137,14 +2184,16 @@ export class QmdClient implements SearchBackend {
2137
2184
  }
2138
2185
  return results;
2139
2186
  }
2140
- log.debug("QMD daemon vsearch timed out/failed; skipping subprocess (daemon-only mode)");
2187
+ log.warn("QMD daemon vsearch timed out/failed; skipping subprocess (daemon-only mode)");
2188
+ this.notifyDegradation(execution?.onDegradation, "daemon_timeout");
2141
2189
  return [];
2142
2190
  }
2143
2191
  if (this.daemonSession?.isLoading()) {
2144
2192
  log.debug("QMD vsearch: daemon loading, skipping subprocess");
2193
+ this.notifyDegradation(execution?.onDegradation, "daemon_loading");
2145
2194
  return [];
2146
2195
  }
2147
- return this.vsearchViaSubprocess(trimmed, col, n, execution?.signal);
2196
+ return this.vsearchViaSubprocess(trimmed, col, n, execution?.signal, execution?.onDegradation);
2148
2197
  }
2149
2198
 
2150
2199
  /**
@@ -2356,12 +2405,63 @@ export class QmdClient implements SearchBackend {
2356
2405
  }
2357
2406
  }
2358
2407
 
2408
+ /**
2409
+ * Report a backend degradation to the caller's observer (#1536): an empty
2410
+ * result caused by unavailability/loading/timeout is otherwise
2411
+ * indistinguishable from "no matches" (CLAUDE.md rule 34). Observer
2412
+ * failures are swallowed — observability must never break search.
2413
+ */
2414
+ private notifyDegradation(
2415
+ onDegradation: SearchExecutionOptions["onDegradation"],
2416
+ code: SearchDegradation["code"],
2417
+ detail?: string,
2418
+ ): void {
2419
+ if (!onDegradation) return;
2420
+ try {
2421
+ onDegradation({ backend: "qmd", code, ...(detail !== undefined ? { detail } : {}) });
2422
+ } catch (err) {
2423
+ log.debug(`QMD degradation observer threw: ${err}`);
2424
+ }
2425
+ }
2426
+
2427
+ /**
2428
+ * Condense an error into a degradation `detail` string that is safe to
2429
+ * serialize on LastRecallSnapshot and expose via last-recall MCP/HTTP
2430
+ * surfaces (cursor review on #1544): first line only, path-like tokens
2431
+ * redacted (absolute, home-rooted, and Windows drive paths can leak
2432
+ * usernames and filesystem layout), capped at 160 chars. The unredacted
2433
+ * error still reaches operators via the warn log at the failure site.
2434
+ */
2435
+ private degradationDetail(err: unknown, sensitive?: string[]): string {
2436
+ let message = String(err instanceof Error ? err.message : err);
2437
+ // Strip known-sensitive strings BEFORE any line-splitting: runQmdCommand
2438
+ // error labels embed the full command line including the raw recall
2439
+ // query, and a multi-line query would otherwise leave its first-line
2440
+ // prefix behind after the first-line cut (codex round-6 P1 on #1544).
2441
+ // Whole values first, then their individual line fragments so truncated
2442
+ // embeddings cannot leak partial prompt text either.
2443
+ for (const value of sensitive ?? []) {
2444
+ if (typeof value !== "string" || value.length === 0) continue;
2445
+ message = message.split(value).join("<query>");
2446
+ for (const fragment of value.split("\n")) {
2447
+ const trimmed = fragment.trim();
2448
+ if (trimmed.length >= 4) {
2449
+ message = message.split(trimmed).join("<query>");
2450
+ }
2451
+ }
2452
+ }
2453
+ const firstLine = message.split("\n")[0] ?? "";
2454
+ const redacted = firstLine.replace(/(?:~|\/|[A-Za-z]:\\)[^\s'"`]+/g, "<path>");
2455
+ return redacted.length > 160 ? `${redacted.slice(0, 159)}…` : redacted;
2456
+ }
2457
+
2359
2458
  private async searchViaSubprocess(
2360
2459
  query: string,
2361
2460
  collection: string,
2362
2461
  maxResults: number,
2363
2462
  options?: SearchQueryOptions,
2364
2463
  signal?: AbortSignal,
2464
+ onDegradation?: SearchExecutionOptions["onDegradation"],
2365
2465
  ): Promise<QmdSearchResult[]> {
2366
2466
  if (this.available === false) return [];
2367
2467
 
@@ -2373,7 +2473,7 @@ export class QmdClient implements SearchBackend {
2373
2473
  // `qmdSubprocessStrategy: "search"` — but that trades away expansion + rerank,
2374
2474
  // so it stays opt-in and the default remains `query`.
2375
2475
  if (this.qmdSubprocessStrategy === "search") {
2376
- return this.bm25SearchViaSubprocess(query, collection, maxResults, signal);
2476
+ return this.bm25SearchViaSubprocess(query, collection, maxResults, signal, onDegradation);
2377
2477
  }
2378
2478
 
2379
2479
  const startedAtMs = Date.now();
@@ -2395,7 +2495,11 @@ export class QmdClient implements SearchBackend {
2395
2495
  if (isCallerCancellation(err, signal)) {
2396
2496
  throw isAbortError(err) ? err : abortError("QMD subprocess search aborted");
2397
2497
  }
2398
- log.debug(`QMD search failed: ${err}`);
2498
+ // Sanitized in the LOG too: the raw error label embeds the argv,
2499
+ // which includes the user's recall query (codex round-5 P1 on #1544).
2500
+ const detail = this.degradationDetail(err, [query]);
2501
+ log.warn(`QMD subprocess search failed (returning empty): ${detail}`);
2502
+ this.notifyDegradation(onDegradation, "subprocess_error", detail);
2399
2503
  return [];
2400
2504
  }
2401
2505
  }
@@ -2405,6 +2509,7 @@ export class QmdClient implements SearchBackend {
2405
2509
  collection: string,
2406
2510
  maxResults: number,
2407
2511
  signal?: AbortSignal,
2512
+ onDegradation?: SearchExecutionOptions["onDegradation"],
2408
2513
  ): Promise<QmdSearchResult[]> {
2409
2514
  if (this.available === false) return [];
2410
2515
  const startedAtMs = Date.now();
@@ -2419,7 +2524,9 @@ export class QmdClient implements SearchBackend {
2419
2524
  if (isCallerCancellation(err, signal)) {
2420
2525
  throw isAbortError(err) ? err : abortError("QMD subprocess bm25 aborted");
2421
2526
  }
2422
- log.debug(`QMD bm25 search failed: ${err}`);
2527
+ const detail = this.degradationDetail(err, [query]);
2528
+ log.warn(`QMD bm25 subprocess search failed (returning empty): ${detail}`);
2529
+ this.notifyDegradation(onDegradation, "subprocess_error", detail);
2423
2530
  return [];
2424
2531
  }
2425
2532
  }
@@ -2429,6 +2536,7 @@ export class QmdClient implements SearchBackend {
2429
2536
  collection: string,
2430
2537
  maxResults: number,
2431
2538
  signal?: AbortSignal,
2539
+ onDegradation?: SearchExecutionOptions["onDegradation"],
2432
2540
  ): Promise<QmdSearchResult[]> {
2433
2541
  if (this.available === false) return [];
2434
2542
  const startedAtMs = Date.now();
@@ -2444,7 +2552,9 @@ export class QmdClient implements SearchBackend {
2444
2552
  if (isCallerCancellation(err, signal)) {
2445
2553
  throw isAbortError(err) ? err : abortError("QMD subprocess vsearch aborted");
2446
2554
  }
2447
- log.debug(`QMD vsearch failed: ${err}`);
2555
+ const detail = this.degradationDetail(err, [query]);
2556
+ log.warn(`QMD vsearch subprocess failed (returning empty): ${detail}`);
2557
+ this.notifyDegradation(onDegradation, "subprocess_error", detail);
2448
2558
  return [];
2449
2559
  }
2450
2560
  }
@@ -2454,6 +2564,7 @@ export class QmdClient implements SearchBackend {
2454
2564
  maxResults: number,
2455
2565
  options?: SearchQueryOptions,
2456
2566
  signal?: AbortSignal,
2567
+ onDegradation?: SearchExecutionOptions["onDegradation"],
2457
2568
  ): Promise<QmdSearchResult[]> {
2458
2569
  if (this.available === false) return [];
2459
2570
 
@@ -2485,7 +2596,9 @@ export class QmdClient implements SearchBackend {
2485
2596
  if (isCallerCancellation(err, signal)) {
2486
2597
  throw isAbortError(err) ? err : abortError("QMD subprocess global search aborted");
2487
2598
  }
2488
- log.debug(`QMD global search failed: ${err}`);
2599
+ const detail = this.degradationDetail(err, [query]);
2600
+ log.warn(`QMD global subprocess search failed (returning empty): ${detail}`);
2601
+ this.notifyDegradation(onDegradation, "subprocess_error", detail);
2489
2602
  return [];
2490
2603
  }
2491
2604
  }