@remnic/core 9.3.660 → 9.3.662

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 (180) 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-7H7J3ZWN.js → chunk-2KDQI363.js} +2 -2
  22. package/dist/{chunk-R2EBP6CM.js → chunk-35HP3TGR.js} +5 -5
  23. package/dist/{chunk-FWIROLS6.js → chunk-44VFF3BB.js} +18 -16
  24. package/dist/chunk-44VFF3BB.js.map +1 -0
  25. package/dist/{chunk-OYXVENIS.js → chunk-4KDLCMLK.js} +3 -3
  26. package/dist/{chunk-MO77TWPS.js → chunk-5AYAZN45.js} +2 -2
  27. package/dist/{chunk-7PCZGNG2.js → chunk-6RHNCKHG.js} +113 -24
  28. package/dist/chunk-6RHNCKHG.js.map +1 -0
  29. package/dist/{chunk-RP2U54GG.js → chunk-DFAXGZKI.js} +2 -2
  30. package/dist/{chunk-6G5JEN55.js → chunk-FZC2WSDB.js} +2 -2
  31. package/dist/{chunk-2EVZ5EN6.js → chunk-HSCJYHYV.js} +6 -6
  32. package/dist/{chunk-B57QYSWN.js → chunk-TGOOJCGA.js} +109 -16
  33. package/dist/chunk-TGOOJCGA.js.map +1 -0
  34. package/dist/{chunk-UNLHHTKN.js → chunk-WOQIHC67.js} +10 -2
  35. package/dist/chunk-WOQIHC67.js.map +1 -0
  36. package/dist/{chunk-5PLUC5OB.js → chunk-WSQG37DV.js} +2 -2
  37. package/dist/{chunk-M3VYPE2H.js → chunk-YNQ6DFSV.js} +1 -1
  38. package/dist/chunk-YNQ6DFSV.js.map +1 -0
  39. package/dist/{chunk-256W7AXC.js → chunk-YYQRVNSV.js} +2 -2
  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 +50 -2
  109. package/dist/qmd.js +8 -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 +3 -2
  160. package/scripts/build-with-heap.mjs +25 -0
  161. package/src/namespaces/search.ts +16 -0
  162. package/src/orchestrator.ts +144 -3
  163. package/src/qmd.ts +137 -18
  164. package/src/recall-state.ts +47 -21
  165. package/src/search/port.ts +25 -0
  166. package/dist/chunk-7PCZGNG2.js.map +0 -1
  167. package/dist/chunk-B57QYSWN.js.map +0 -1
  168. package/dist/chunk-FWIROLS6.js.map +0 -1
  169. package/dist/chunk-M3VYPE2H.js.map +0 -1
  170. package/dist/chunk-UNLHHTKN.js.map +0 -1
  171. /package/dist/{chunk-7H7J3ZWN.js.map → chunk-2KDQI363.js.map} +0 -0
  172. /package/dist/{chunk-R2EBP6CM.js.map → chunk-35HP3TGR.js.map} +0 -0
  173. /package/dist/{chunk-OYXVENIS.js.map → chunk-4KDLCMLK.js.map} +0 -0
  174. /package/dist/{chunk-MO77TWPS.js.map → chunk-5AYAZN45.js.map} +0 -0
  175. /package/dist/{chunk-RP2U54GG.js.map → chunk-DFAXGZKI.js.map} +0 -0
  176. /package/dist/{chunk-6G5JEN55.js.map → chunk-FZC2WSDB.js.map} +0 -0
  177. /package/dist/{chunk-2EVZ5EN6.js.map → chunk-HSCJYHYV.js.map} +0 -0
  178. /package/dist/{chunk-5PLUC5OB.js.map → chunk-WSQG37DV.js.map} +0 -0
  179. /package/dist/{chunk-256W7AXC.js.map → chunk-YYQRVNSV.js.map} +0 -0
  180. /package/dist/{chunk-GRYAECRV.js.map → chunk-ZJH723NM.js.map} +0 -0
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";
@@ -1211,6 +1212,12 @@ async function releaseSharedDaemonSession(session: QmdDaemonSession | null): Pro
1211
1212
  }
1212
1213
  }
1213
1214
 
1215
+ // Test-only seams (#1537): lifecycle tests assert the pool reaches zero after
1216
+ // every holder disposes — the property the stop()-teardown fix restores.
1217
+ export const sharedDaemonSessionCountForTest = (): number => SHARED_DAEMON_SESSIONS.size;
1218
+ export { retainSharedDaemonSession as retainSharedDaemonSessionForTest };
1219
+ export { releaseSharedDaemonSession as releaseSharedDaemonSessionForTest };
1220
+
1214
1221
  // ---------------------------------------------------------------------------
1215
1222
  // QmdClient
1216
1223
  // ---------------------------------------------------------------------------
@@ -1941,7 +1948,10 @@ export class QmdClient implements SearchBackend {
1941
1948
  const trimmed = query.trim();
1942
1949
  if (!trimmed) return [];
1943
1950
  await this.maybeProbeDaemon();
1944
- if (!this.isAvailable()) return [];
1951
+ if (!this.isAvailable()) {
1952
+ this.notifyDegradation(execution?.onDegradation, "backend_unavailable");
1953
+ return [];
1954
+ }
1945
1955
 
1946
1956
  const col = collection ?? this.collection;
1947
1957
  const n = maxResults ?? this.maxResults;
@@ -1962,6 +1972,12 @@ export class QmdClient implements SearchBackend {
1962
1972
  .digest("hex");
1963
1973
  const cached = getCachedQmdSearch(cacheKey);
1964
1974
  if (cached) {
1975
+ // No degradation fires on a cache hit BY DESIGN (#1536): only
1976
+ // trustworthy results are ever cached (degraded empties are excluded
1977
+ // at the write below), so a TTL hit is a healthy serve of a recently
1978
+ // valid answer — the backend's live state is irrelevant because no
1979
+ // live call is attempted. Reporting unavailability here would fabricate
1980
+ // a degradation for a recall that was not degraded.
1965
1981
  log.debug(`QMD search cache hit (${cached.length} results)`);
1966
1982
  return cached as QmdSearchResult[];
1967
1983
  }
@@ -1993,7 +2009,8 @@ export class QmdClient implements SearchBackend {
1993
2009
  }
1994
2010
  // Daemon timed out or had a transient error — skip subprocess for large
1995
2011
  // collections. Return empty rather than hanging the caller.
1996
- log.debug("QMD daemon search timed out/failed; skipping subprocess (daemon-only mode)");
2012
+ log.warn("QMD daemon search timed out/failed; skipping subprocess (daemon-only mode)");
2013
+ this.notifyDegradation(execution?.onDegradation, "daemon_timeout");
1997
2014
  return [];
1998
2015
  }
1999
2016
 
@@ -2002,12 +2019,29 @@ export class QmdClient implements SearchBackend {
2002
2019
  // Return empty and let the next recheck cycle pick up the daemon once ready.
2003
2020
  if (this.daemonSession?.isLoading()) {
2004
2021
  log.debug("QMD search: daemon loading, skipping subprocess");
2022
+ this.notifyDegradation(execution?.onDegradation, "daemon_loading");
2005
2023
  return [];
2006
2024
  }
2007
2025
 
2008
2026
  // 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);
2027
+ let subprocessDegraded = false;
2028
+ const subprocessResults = await this.searchViaSubprocess(
2029
+ trimmed,
2030
+ col,
2031
+ n,
2032
+ searchOptions,
2033
+ execution?.signal,
2034
+ (degradation) => {
2035
+ subprocessDegraded = true;
2036
+ this.notifyDegradation(execution?.onDegradation, degradation.code, degradation.detail);
2037
+ },
2038
+ );
2039
+ // Never cache a degraded empty result: a 60s TTL hit would serve the
2040
+ // failure as a genuine no-matches WITHOUT re-reporting the degradation
2041
+ // (codex review on #1544). Only trustworthy results are cacheable.
2042
+ if (!subprocessDegraded) {
2043
+ setCachedQmdSearch(cacheKey, subprocessResults);
2044
+ }
2011
2045
  return subprocessResults;
2012
2046
  }
2013
2047
 
@@ -2019,7 +2053,10 @@ export class QmdClient implements SearchBackend {
2019
2053
  const trimmed = query.trim();
2020
2054
  if (!trimmed) return [];
2021
2055
  await this.maybeProbeDaemon();
2022
- if (!this.isAvailable()) return [];
2056
+ if (!this.isAvailable()) {
2057
+ this.notifyDegradation(execution?.onDegradation, "backend_unavailable");
2058
+ return [];
2059
+ }
2023
2060
 
2024
2061
  const n = maxResults ?? 6;
2025
2062
  const searchOptions = this.resolveSearchOptions();
@@ -2043,18 +2080,26 @@ export class QmdClient implements SearchBackend {
2043
2080
  }
2044
2081
  return results;
2045
2082
  }
2046
- log.debug("QMD daemon global search timed out/failed; skipping subprocess (daemon-only mode)");
2083
+ log.warn("QMD daemon global search timed out/failed; skipping subprocess (daemon-only mode)");
2084
+ this.notifyDegradation(execution?.onDegradation, "daemon_timeout");
2047
2085
  return [];
2048
2086
  }
2049
2087
 
2050
2088
  // If the daemon is spawned but still loading, skip subprocess — same as search().
2051
2089
  if (this.daemonSession?.isLoading()) {
2052
2090
  log.debug("QMD searchGlobal: daemon loading, skipping subprocess");
2091
+ this.notifyDegradation(execution?.onDegradation, "daemon_loading");
2053
2092
  return [];
2054
2093
  }
2055
2094
 
2056
2095
  // Subprocess fallback (only reached when daemon is unavailable and not loading)
2057
- return this.searchGlobalViaSubprocess(trimmed, n, searchOptions, execution?.signal);
2096
+ return this.searchGlobalViaSubprocess(
2097
+ trimmed,
2098
+ n,
2099
+ searchOptions,
2100
+ execution?.signal,
2101
+ execution?.onDegradation,
2102
+ );
2058
2103
  }
2059
2104
 
2060
2105
  /**
@@ -2069,7 +2114,10 @@ export class QmdClient implements SearchBackend {
2069
2114
  const trimmed = query.trim();
2070
2115
  if (!trimmed) return [];
2071
2116
  await this.maybeProbeDaemon();
2072
- if (!this.isAvailable()) return [];
2117
+ if (!this.isAvailable()) {
2118
+ this.notifyDegradation(execution?.onDegradation, "backend_unavailable");
2119
+ return [];
2120
+ }
2073
2121
  const col = collection ?? this.collection;
2074
2122
  const n = maxResults ?? this.maxResults;
2075
2123
 
@@ -2092,14 +2140,16 @@ export class QmdClient implements SearchBackend {
2092
2140
  }
2093
2141
  return results;
2094
2142
  }
2095
- log.debug("QMD daemon bm25 timed out/failed; skipping subprocess (daemon-only mode)");
2143
+ log.warn("QMD daemon bm25 timed out/failed; skipping subprocess (daemon-only mode)");
2144
+ this.notifyDegradation(execution?.onDegradation, "daemon_timeout");
2096
2145
  return [];
2097
2146
  }
2098
2147
  if (this.daemonSession?.isLoading()) {
2099
2148
  log.debug("QMD bm25: daemon loading, skipping subprocess");
2149
+ this.notifyDegradation(execution?.onDegradation, "daemon_loading");
2100
2150
  return [];
2101
2151
  }
2102
- return this.bm25SearchViaSubprocess(trimmed, col, n, execution?.signal);
2152
+ return this.bm25SearchViaSubprocess(trimmed, col, n, execution?.signal, execution?.onDegradation);
2103
2153
  }
2104
2154
 
2105
2155
  /**
@@ -2114,7 +2164,10 @@ export class QmdClient implements SearchBackend {
2114
2164
  const trimmed = query.trim();
2115
2165
  if (!trimmed) return [];
2116
2166
  await this.maybeProbeDaemon();
2117
- if (!this.isAvailable()) return [];
2167
+ if (!this.isAvailable()) {
2168
+ this.notifyDegradation(execution?.onDegradation, "backend_unavailable");
2169
+ return [];
2170
+ }
2118
2171
  const col = collection ?? this.collection;
2119
2172
  const n = maxResults ?? this.maxResults;
2120
2173
 
@@ -2137,14 +2190,16 @@ export class QmdClient implements SearchBackend {
2137
2190
  }
2138
2191
  return results;
2139
2192
  }
2140
- log.debug("QMD daemon vsearch timed out/failed; skipping subprocess (daemon-only mode)");
2193
+ log.warn("QMD daemon vsearch timed out/failed; skipping subprocess (daemon-only mode)");
2194
+ this.notifyDegradation(execution?.onDegradation, "daemon_timeout");
2141
2195
  return [];
2142
2196
  }
2143
2197
  if (this.daemonSession?.isLoading()) {
2144
2198
  log.debug("QMD vsearch: daemon loading, skipping subprocess");
2199
+ this.notifyDegradation(execution?.onDegradation, "daemon_loading");
2145
2200
  return [];
2146
2201
  }
2147
- return this.vsearchViaSubprocess(trimmed, col, n, execution?.signal);
2202
+ return this.vsearchViaSubprocess(trimmed, col, n, execution?.signal, execution?.onDegradation);
2148
2203
  }
2149
2204
 
2150
2205
  /**
@@ -2356,12 +2411,63 @@ export class QmdClient implements SearchBackend {
2356
2411
  }
2357
2412
  }
2358
2413
 
2414
+ /**
2415
+ * Report a backend degradation to the caller's observer (#1536): an empty
2416
+ * result caused by unavailability/loading/timeout is otherwise
2417
+ * indistinguishable from "no matches" (CLAUDE.md rule 34). Observer
2418
+ * failures are swallowed — observability must never break search.
2419
+ */
2420
+ private notifyDegradation(
2421
+ onDegradation: SearchExecutionOptions["onDegradation"],
2422
+ code: SearchDegradation["code"],
2423
+ detail?: string,
2424
+ ): void {
2425
+ if (!onDegradation) return;
2426
+ try {
2427
+ onDegradation({ backend: "qmd", code, ...(detail !== undefined ? { detail } : {}) });
2428
+ } catch (err) {
2429
+ log.debug(`QMD degradation observer threw: ${err}`);
2430
+ }
2431
+ }
2432
+
2433
+ /**
2434
+ * Condense an error into a degradation `detail` string that is safe to
2435
+ * serialize on LastRecallSnapshot and expose via last-recall MCP/HTTP
2436
+ * surfaces (cursor review on #1544): first line only, path-like tokens
2437
+ * redacted (absolute, home-rooted, and Windows drive paths can leak
2438
+ * usernames and filesystem layout), capped at 160 chars. The unredacted
2439
+ * error still reaches operators via the warn log at the failure site.
2440
+ */
2441
+ private degradationDetail(err: unknown, sensitive?: string[]): string {
2442
+ let message = String(err instanceof Error ? err.message : err);
2443
+ // Strip known-sensitive strings BEFORE any line-splitting: runQmdCommand
2444
+ // error labels embed the full command line including the raw recall
2445
+ // query, and a multi-line query would otherwise leave its first-line
2446
+ // prefix behind after the first-line cut (codex round-6 P1 on #1544).
2447
+ // Whole values first, then their individual line fragments so truncated
2448
+ // embeddings cannot leak partial prompt text either.
2449
+ for (const value of sensitive ?? []) {
2450
+ if (typeof value !== "string" || value.length === 0) continue;
2451
+ message = message.split(value).join("<query>");
2452
+ for (const fragment of value.split("\n")) {
2453
+ const trimmed = fragment.trim();
2454
+ if (trimmed.length >= 4) {
2455
+ message = message.split(trimmed).join("<query>");
2456
+ }
2457
+ }
2458
+ }
2459
+ const firstLine = message.split("\n")[0] ?? "";
2460
+ const redacted = firstLine.replace(/(?:~|\/|[A-Za-z]:\\)[^\s'"`]+/g, "<path>");
2461
+ return redacted.length > 160 ? `${redacted.slice(0, 159)}…` : redacted;
2462
+ }
2463
+
2359
2464
  private async searchViaSubprocess(
2360
2465
  query: string,
2361
2466
  collection: string,
2362
2467
  maxResults: number,
2363
2468
  options?: SearchQueryOptions,
2364
2469
  signal?: AbortSignal,
2470
+ onDegradation?: SearchExecutionOptions["onDegradation"],
2365
2471
  ): Promise<QmdSearchResult[]> {
2366
2472
  if (this.available === false) return [];
2367
2473
 
@@ -2373,7 +2479,7 @@ export class QmdClient implements SearchBackend {
2373
2479
  // `qmdSubprocessStrategy: "search"` — but that trades away expansion + rerank,
2374
2480
  // so it stays opt-in and the default remains `query`.
2375
2481
  if (this.qmdSubprocessStrategy === "search") {
2376
- return this.bm25SearchViaSubprocess(query, collection, maxResults, signal);
2482
+ return this.bm25SearchViaSubprocess(query, collection, maxResults, signal, onDegradation);
2377
2483
  }
2378
2484
 
2379
2485
  const startedAtMs = Date.now();
@@ -2395,7 +2501,11 @@ export class QmdClient implements SearchBackend {
2395
2501
  if (isCallerCancellation(err, signal)) {
2396
2502
  throw isAbortError(err) ? err : abortError("QMD subprocess search aborted");
2397
2503
  }
2398
- log.debug(`QMD search failed: ${err}`);
2504
+ // Sanitized in the LOG too: the raw error label embeds the argv,
2505
+ // which includes the user's recall query (codex round-5 P1 on #1544).
2506
+ const detail = this.degradationDetail(err, [query]);
2507
+ log.warn(`QMD subprocess search failed (returning empty): ${detail}`);
2508
+ this.notifyDegradation(onDegradation, "subprocess_error", detail);
2399
2509
  return [];
2400
2510
  }
2401
2511
  }
@@ -2405,6 +2515,7 @@ export class QmdClient implements SearchBackend {
2405
2515
  collection: string,
2406
2516
  maxResults: number,
2407
2517
  signal?: AbortSignal,
2518
+ onDegradation?: SearchExecutionOptions["onDegradation"],
2408
2519
  ): Promise<QmdSearchResult[]> {
2409
2520
  if (this.available === false) return [];
2410
2521
  const startedAtMs = Date.now();
@@ -2419,7 +2530,9 @@ export class QmdClient implements SearchBackend {
2419
2530
  if (isCallerCancellation(err, signal)) {
2420
2531
  throw isAbortError(err) ? err : abortError("QMD subprocess bm25 aborted");
2421
2532
  }
2422
- log.debug(`QMD bm25 search failed: ${err}`);
2533
+ const detail = this.degradationDetail(err, [query]);
2534
+ log.warn(`QMD bm25 subprocess search failed (returning empty): ${detail}`);
2535
+ this.notifyDegradation(onDegradation, "subprocess_error", detail);
2423
2536
  return [];
2424
2537
  }
2425
2538
  }
@@ -2429,6 +2542,7 @@ export class QmdClient implements SearchBackend {
2429
2542
  collection: string,
2430
2543
  maxResults: number,
2431
2544
  signal?: AbortSignal,
2545
+ onDegradation?: SearchExecutionOptions["onDegradation"],
2432
2546
  ): Promise<QmdSearchResult[]> {
2433
2547
  if (this.available === false) return [];
2434
2548
  const startedAtMs = Date.now();
@@ -2444,7 +2558,9 @@ export class QmdClient implements SearchBackend {
2444
2558
  if (isCallerCancellation(err, signal)) {
2445
2559
  throw isAbortError(err) ? err : abortError("QMD subprocess vsearch aborted");
2446
2560
  }
2447
- log.debug(`QMD vsearch failed: ${err}`);
2561
+ const detail = this.degradationDetail(err, [query]);
2562
+ log.warn(`QMD vsearch subprocess failed (returning empty): ${detail}`);
2563
+ this.notifyDegradation(onDegradation, "subprocess_error", detail);
2448
2564
  return [];
2449
2565
  }
2450
2566
  }
@@ -2454,6 +2570,7 @@ export class QmdClient implements SearchBackend {
2454
2570
  maxResults: number,
2455
2571
  options?: SearchQueryOptions,
2456
2572
  signal?: AbortSignal,
2573
+ onDegradation?: SearchExecutionOptions["onDegradation"],
2457
2574
  ): Promise<QmdSearchResult[]> {
2458
2575
  if (this.available === false) return [];
2459
2576
 
@@ -2485,7 +2602,9 @@ export class QmdClient implements SearchBackend {
2485
2602
  if (isCallerCancellation(err, signal)) {
2486
2603
  throw isAbortError(err) ? err : abortError("QMD subprocess global search aborted");
2487
2604
  }
2488
- log.debug(`QMD global search failed: ${err}`);
2605
+ const detail = this.degradationDetail(err, [query]);
2606
+ log.warn(`QMD global subprocess search failed (returning empty): ${detail}`);
2607
+ this.notifyDegradation(onDegradation, "subprocess_error", detail);
2489
2608
  return [];
2490
2609
  }
2491
2610
  }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { log } from "./logger.js";
5
5
  import { writeFileAtomically } from "./maintenance/atomic-file.js";
6
+ import type { SearchDegradation } from "./search/port.js";
6
7
  import type {
7
8
  IdentityInjectionMode,
8
9
  RecallPlanMode,
@@ -59,6 +60,14 @@ export interface LastRecallSnapshot {
59
60
  * graph-path `recallExplain` operation.
60
61
  */
61
62
  tierExplain?: RecallTierExplain;
63
+ /**
64
+ * Backend degradations observed while serving this recall (issue #1536).
65
+ * Present only when a search backend reported unavailable/loading/timeout
66
+ * during the recall — distinguishing "no matches" from "backend could not
67
+ * answer" (CLAUDE.md rule 34). Deliberately independent of `tierExplain`,
68
+ * which is gated behind `recallDirectAnswerEnabled`.
69
+ */
70
+ backendDegradations?: SearchDegradation[];
62
71
  }
63
72
 
64
73
  export interface GraphRecallExpandedEntry {
@@ -136,6 +145,31 @@ type StateFileWriter = (filePath: string, content: string) => Promise<void>;
136
145
  * qmd-recall-cache.ts). The payload is pure JSON-shaped data, so
137
146
  * structuredClone is both safe and complete here.
138
147
  */
148
+ /**
149
+ * Stale-snapshot identity guard shared by the annotate methods (issue #518):
150
+ * a writeNonce match wins outright; otherwise traceId (when expected) and
151
+ * recordedAt are compared. Extracted so every annotation surface applies
152
+ * identical guards (CLAUDE.md rule 22).
153
+ */
154
+ function snapshotMatchesExpectedIdentity(
155
+ current: LastRecallSnapshot,
156
+ expected?: { writeNonce?: string; traceId?: string; recordedAt?: string },
157
+ ): boolean {
158
+ if (!expected) return true;
159
+ if (typeof expected.writeNonce === "string" && expected.writeNonce.length > 0) {
160
+ return current.writeNonce === expected.writeNonce;
161
+ }
162
+ const hasExpectedTraceId =
163
+ typeof expected.traceId === "string" && expected.traceId.length > 0;
164
+ if (hasExpectedTraceId) {
165
+ return current.traceId === expected.traceId;
166
+ }
167
+ if (expected.recordedAt !== undefined) {
168
+ return current.recordedAt === expected.recordedAt;
169
+ }
170
+ return true;
171
+ }
172
+
139
173
  function cloneTierExplain(
140
174
  tierExplain: RecallTierExplain | undefined,
141
175
  ): RecallTierExplain | undefined {
@@ -271,6 +305,13 @@ export class LastRecallStore {
271
305
  * can render which retrieval tier served the query.
272
306
  */
273
307
  tierExplain?: RecallTierExplain;
308
+ /**
309
+ * Backend degradations observed while serving this recall (issue #1536).
310
+ * Passed at record time so the published snapshot is born annotated —
311
+ * a post-record annotation would leave a window where readers see the
312
+ * snapshot without them (codex review on #1544).
313
+ */
314
+ backendDegradations?: SearchDegradation[];
274
315
  }): Promise<void> {
275
316
  const now = new Date().toISOString();
276
317
  const queryHash = createHash("sha256").update(opts.query).digest("hex");
@@ -302,6 +343,10 @@ export class LastRecallStore {
302
343
  identityInjectedChars: opts.identityInjection?.injectedChars,
303
344
  identityInjectionTruncated: opts.identityInjection?.truncated,
304
345
  tierExplain: opts.tierExplain,
346
+ backendDegradations:
347
+ opts.backendDegradations && opts.backendDegradations.length > 0
348
+ ? opts.backendDegradations
349
+ : undefined,
305
350
  };
306
351
  // `cloneLastRecallSnapshot` handles `null` but that never applies
307
352
  // at this call site — the non-null assertion keeps the type
@@ -355,27 +400,7 @@ export class LastRecallStore {
355
400
  ): Promise<void> {
356
401
  const current = this.state[sessionKey];
357
402
  if (!current) return;
358
- if (expected) {
359
- if (
360
- typeof expected.writeNonce === "string" &&
361
- expected.writeNonce.length > 0
362
- ) {
363
- if (current.writeNonce !== expected.writeNonce) return;
364
- } else {
365
- const hasExpectedTraceId =
366
- typeof expected.traceId === "string" && expected.traceId.length > 0;
367
- const traceIdMatches =
368
- hasExpectedTraceId && current.traceId === expected.traceId;
369
- const recordedAtMatches =
370
- expected.recordedAt !== undefined &&
371
- current.recordedAt === expected.recordedAt;
372
- if (hasExpectedTraceId) {
373
- if (!traceIdMatches) return;
374
- } else if (expected.recordedAt !== undefined && !recordedAtMatches) {
375
- return;
376
- }
377
- }
378
- }
403
+ if (!snapshotMatchesExpectedIdentity(current, expected)) return;
379
404
  this.state[sessionKey] = {
380
405
  ...current,
381
406
  tierExplain: cloneTierExplain(tierExplain),
@@ -387,6 +412,7 @@ export class LastRecallStore {
387
412
  }
388
413
  }
389
414
 
415
+
390
416
  private flushState(): Promise<void> {
391
417
  const run = this.stateWriteChain.catch(() => undefined).then(async () => {
392
418
  await mkdir(path.dirname(this.statePath), { recursive: true });
@@ -12,8 +12,33 @@ export interface SearchQueryOptions {
12
12
  structuredSearches?: Array<{ type: "lex" | "vec" | "hyde"; query: string }>;
13
13
  }
14
14
 
15
+ /**
16
+ * A search that returned empty (or partial) results because the backend was
17
+ * unavailable, still loading, or timed out — cases that are otherwise
18
+ * indistinguishable from a genuine "no matches" (issue #1536, CLAUDE.md
19
+ * rule 34).
20
+ */
21
+ export interface SearchDegradation {
22
+ backend: "qmd";
23
+ code:
24
+ | "backend_unavailable"
25
+ | "daemon_timeout"
26
+ | "daemon_loading"
27
+ | "subprocess_error"
28
+ | "deadline_exceeded";
29
+ detail?: string;
30
+ }
31
+
15
32
  export interface SearchExecutionOptions {
16
33
  signal?: AbortSignal;
34
+ /**
35
+ * Observer invoked when the backend degrades during this call (#1536).
36
+ * Callers that need to distinguish empty-because-degraded from
37
+ * empty-because-no-matches (recall x-ray, fallback decisions) pass a
38
+ * collector here. Observer failures are swallowed by the notifier —
39
+ * observability must never break search.
40
+ */
41
+ onDegradation?: (degradation: SearchDegradation) => void;
17
42
  }
18
43
 
19
44
  export function resolveEnsureCollectionArgs(