@remnic/core 9.69.61 → 9.69.64

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 (263) hide show
  1. package/dist/access-admin-ops-surface.js +27 -27
  2. package/dist/access-authorization-probe.js +28 -28
  3. package/dist/access-boundary.js +27 -27
  4. package/dist/access-cli.js +68 -68
  5. package/dist/access-extraction-force-flush.js +27 -27
  6. package/dist/access-http-briefing.js +28 -28
  7. package/dist/access-http-query.js +29 -29
  8. package/dist/access-http.js +44 -44
  9. package/dist/access-identity-continuity-surface.js +27 -27
  10. package/dist/access-lcm-surface.js +27 -27
  11. package/dist/access-mcp-output-schemas.d.ts.map +1 -1
  12. package/dist/access-mcp-output-schemas.js +1 -1
  13. package/dist/access-mcp.js +37 -37
  14. package/dist/access-namespace-preflight.js +27 -27
  15. package/dist/access-observe-write-surface.js +27 -27
  16. package/dist/access-operations-batch.js +28 -28
  17. package/dist/access-operations.js +35 -35
  18. package/dist/access-recall-concurrency.js +27 -27
  19. package/dist/access-recall-response.d.ts.map +1 -1
  20. package/dist/access-recall-response.js +27 -27
  21. package/dist/access-recall-surface.js +27 -27
  22. package/dist/access-service.d.ts +4 -0
  23. package/dist/access-service.d.ts.map +1 -1
  24. package/dist/access-service.js +27 -27
  25. package/dist/access-surface-catalog.js +29 -29
  26. package/dist/active-recall.js +9 -9
  27. package/dist/briefing.js +10 -10
  28. package/dist/buffer.js +5 -5
  29. package/dist/causal-consolidation.js +11 -11
  30. package/dist/{chunk-52VL6LCY.js → chunk-2VH2JWYN.js} +2 -2
  31. package/dist/{chunk-WD32KTVI.js → chunk-2ZVSQPAA.js} +32 -16
  32. package/dist/chunk-2ZVSQPAA.js.map +1 -0
  33. package/dist/{chunk-G4WZDQ32.js → chunk-3NG5OOCX.js} +2 -2
  34. package/dist/{chunk-KG2OOAFF.js → chunk-3NTVCMDD.js} +2 -2
  35. package/dist/{chunk-7OC3Q5X4.js → chunk-3S4R2HA5.js} +2 -2
  36. package/dist/{chunk-NORFVW2H.js → chunk-4RWPQZYD.js} +2 -2
  37. package/dist/{chunk-B5GMOOY2.js → chunk-5AABWM72.js} +3 -3
  38. package/dist/{chunk-KRPMISWO.js → chunk-5C765XYU.js} +2 -2
  39. package/dist/{chunk-WLZYGLJ4.js → chunk-5MA7ZLPD.js} +5 -7
  40. package/dist/chunk-5MA7ZLPD.js.map +1 -0
  41. package/dist/{chunk-XOLVM3AT.js → chunk-5WXJE5TV.js} +2 -2
  42. package/dist/{chunk-2C5Z7CWB.js → chunk-6YTL3JYX.js} +2 -2
  43. package/dist/{chunk-F4PEETM4.js → chunk-755VRFBS.js} +2 -2
  44. package/dist/{chunk-HNOKNHFO.js → chunk-7L747CFR.js} +2 -2
  45. package/dist/{chunk-WPGR3RW4.js → chunk-7VO6E5UD.js} +2 -2
  46. package/dist/{chunk-LMLPLIT4.js → chunk-AQ6IBVFQ.js} +2 -2
  47. package/dist/{chunk-XN4CPSCN.js → chunk-B2JE56NB.js} +2 -2
  48. package/dist/{chunk-L4WBADKG.js → chunk-B5EZWPSM.js} +2 -2
  49. package/dist/{chunk-EIU642TN.js → chunk-BE4GMH45.js} +2 -2
  50. package/dist/{chunk-NLE6TSLG.js → chunk-BLVHDY2U.js} +5 -7
  51. package/dist/chunk-BLVHDY2U.js.map +1 -0
  52. package/dist/{chunk-JTTLH646.js → chunk-C2W6JKQQ.js} +2 -2
  53. package/dist/{chunk-FG5QU7A2.js → chunk-CFCAFAJC.js} +2 -2
  54. package/dist/{chunk-ZJ6MEVTX.js → chunk-CZM4MHZF.js} +37 -32
  55. package/dist/chunk-CZM4MHZF.js.map +1 -0
  56. package/dist/{chunk-M4PHCEPU.js → chunk-DDY7LJMP.js} +2 -2
  57. package/dist/{chunk-2L5DIVDU.js → chunk-DT33WNG2.js} +4 -4
  58. package/dist/{chunk-SZAWZPY6.js → chunk-E7SKAUEW.js} +7 -7
  59. package/dist/{chunk-NRVGODSE.js → chunk-EK4MOTHM.js} +6 -6
  60. package/dist/{chunk-QEGKMZNQ.js → chunk-FO5NDQZH.js} +3 -3
  61. package/dist/chunk-G26YNEOC.js +37 -0
  62. package/dist/chunk-G26YNEOC.js.map +1 -0
  63. package/dist/{chunk-GMHWCPLP.js → chunk-GZLSIPYU.js} +2 -2
  64. package/dist/{chunk-ZFUPRODV.js → chunk-HPWJ5B7D.js} +39 -3
  65. package/dist/chunk-HPWJ5B7D.js.map +1 -0
  66. package/dist/{chunk-SEHKTE4F.js → chunk-I4IKTNZP.js} +3 -3
  67. package/dist/{chunk-W65KK3X6.js → chunk-II4TIQCU.js} +2 -2
  68. package/dist/{chunk-FWVDTLRK.js → chunk-IJQEVNEF.js} +1 -1
  69. package/dist/{chunk-KXJ7XYUT.js → chunk-IOUQUXHO.js} +2 -2
  70. package/dist/{chunk-V3JM4XEI.js → chunk-ITLFUILH.js} +2 -2
  71. package/dist/{chunk-ZNFHWNDM.js → chunk-IULYVB5X.js} +5 -5
  72. package/dist/{chunk-Z3YQOV45.js → chunk-JLU7WK22.js} +2 -2
  73. package/dist/{chunk-NRTVZSPO.js → chunk-L4HT5QZ5.js} +2 -2
  74. package/dist/{chunk-5QZHDS4X.js → chunk-LI4IWBLN.js} +2 -2
  75. package/dist/{chunk-LY3SDHMI.js → chunk-LQ4OSM5V.js} +3 -3
  76. package/dist/{chunk-2OUSWDY6.js → chunk-M3TMVHQ2.js} +2 -2
  77. package/dist/{chunk-IDRSTHVR.js → chunk-MB3HMRXM.js} +2 -2
  78. package/dist/{chunk-4P3ZBL5E.js → chunk-MUGLJAZ3.js} +2 -2
  79. package/dist/{chunk-ZOASYXBS.js → chunk-MY2PLXSM.js} +6 -2
  80. package/dist/chunk-MY2PLXSM.js.map +1 -0
  81. package/dist/{chunk-4IFSMCRS.js → chunk-NYN4ZMPL.js} +2 -2
  82. package/dist/{chunk-K7S2IMGJ.js → chunk-O7QF3TCV.js} +322 -25
  83. package/dist/{chunk-K7S2IMGJ.js.map → chunk-O7QF3TCV.js.map} +1 -1
  84. package/dist/{chunk-KV44HTMJ.js → chunk-OBD77TYC.js} +2 -2
  85. package/dist/{chunk-GYNCJSQ7.js → chunk-OCEIHKH5.js} +3 -3
  86. package/dist/{chunk-DC5CTLCZ.js → chunk-PSGRZFBH.js} +10 -10
  87. package/dist/{chunk-FQGY7XB2.js → chunk-S52VKUUW.js} +2 -2
  88. package/dist/{chunk-D7MAYFQP.js → chunk-S7LWX2PQ.js} +2 -2
  89. package/dist/{chunk-TYP7GYRI.js → chunk-SB4ZWVKI.js} +2 -2
  90. package/dist/{chunk-KVO2BTA4.js → chunk-T2YX56QI.js} +2 -2
  91. package/dist/{chunk-G6NQ3L4T.js → chunk-T3FMRLWQ.js} +2 -2
  92. package/dist/{chunk-CDQO5DCD.js → chunk-TO3G56GM.js} +2 -2
  93. package/dist/{chunk-WEQZ5OYK.js → chunk-U6GKPSZH.js} +2 -2
  94. package/dist/{chunk-FM5HN7LL.js → chunk-WQC54W25.js} +9 -17
  95. package/dist/chunk-WQC54W25.js.map +1 -0
  96. package/dist/{chunk-XDH5MO6L.js → chunk-WZ33AIOP.js} +2 -2
  97. package/dist/{chunk-TM7S5UHD.js → chunk-XDIFADVN.js} +2 -2
  98. package/dist/{chunk-SVWYZXIL.js → chunk-XDOXR3U6.js} +2 -2
  99. package/dist/{chunk-VKDWYDFQ.js → chunk-XGLBVYEA.js} +5 -5
  100. package/dist/{chunk-5Y7VCE3A.js → chunk-XJNSOI23.js} +2 -2
  101. package/dist/{chunk-LDP6LBDS.js → chunk-Y3AFTYK4.js} +2 -2
  102. package/dist/{chunk-YL2W6NUG.js → chunk-YGJMMMPN.js} +4 -4
  103. package/dist/{chunk-KIOXEHWF.js → chunk-YIDBKTZJ.js} +2 -2
  104. package/dist/{chunk-F5ZCNJ3V.js → chunk-ZPPWW576.js} +2 -2
  105. package/dist/{chunk-TQAXUYUE.js → chunk-ZSXSMVW6.js} +2 -2
  106. package/dist/{chunk-6RD7MSIO.js → chunk-ZUT4FK5W.js} +2 -2
  107. package/dist/cli.js +54 -54
  108. package/dist/coding/export-okf-codegraph.js +9 -9
  109. package/dist/compounding/engine.js +10 -10
  110. package/dist/config.js +9 -9
  111. package/dist/connectors/codex-materialize-runner.js +10 -10
  112. package/dist/connectors/index.js +10 -10
  113. package/dist/convergence-refresh.js +12 -12
  114. package/dist/corpus-watermark.js +12 -12
  115. package/dist/entity-retrieval.js +10 -10
  116. package/dist/external-wiki-access.js +28 -28
  117. package/dist/extraction.js +16 -16
  118. package/dist/harmonic-retrieval.js +1 -1
  119. package/dist/index.d.ts +1 -1
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/index.js +70 -68
  122. package/dist/location/index.js +12 -12
  123. package/dist/maintenance/memory-governance.js +10 -10
  124. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -10
  125. package/dist/maintenance/rebuild-memory-projection.js +11 -11
  126. package/dist/memory-browse-operations.js +28 -28
  127. package/dist/memory-browse.js +5 -5
  128. package/dist/model-registry.d.ts.map +1 -1
  129. package/dist/model-registry.js +1 -1
  130. package/dist/namespaces/migrate.js +11 -11
  131. package/dist/namespaces/search.js +8 -8
  132. package/dist/namespaces/storage.js +10 -10
  133. package/dist/operator-doctor-corpus.js +13 -13
  134. package/dist/operator-toolkit.js +18 -18
  135. package/dist/orchestration/maintenance.js +18 -18
  136. package/dist/orchestration/recall-internal.d.ts.map +1 -1
  137. package/dist/orchestrator.js +68 -68
  138. package/dist/profiling.d.ts.map +1 -1
  139. package/dist/profiling.js +1 -1
  140. package/dist/qmd.js +2 -2
  141. package/dist/recall-composition-decision.d.ts +9 -0
  142. package/dist/recall-composition-decision.d.ts.map +1 -1
  143. package/dist/recall-composition-decision.js +2 -1
  144. package/dist/recall-navigation-operations.js +28 -28
  145. package/dist/recall-navigation.js +27 -27
  146. package/dist/recall-state-view-admission.js +5 -5
  147. package/dist/recall-why-service.d.ts.map +1 -1
  148. package/dist/recall-why-service.js +5 -5
  149. package/dist/resume-bundles.js +9 -9
  150. package/dist/search/factory.js +7 -7
  151. package/dist/search/index.js +7 -7
  152. package/dist/search/lancedb-backend.js +2 -2
  153. package/dist/search/meilisearch-backend.js +2 -2
  154. package/dist/search/orama-backend.js +2 -2
  155. package/dist/search/port.d.ts +9 -0
  156. package/dist/search/port.d.ts.map +1 -1
  157. package/dist/search/port.js +7 -1
  158. package/dist/search/remote-backend.js +2 -2
  159. package/dist/security/audit-memory-cli.d.ts.map +1 -1
  160. package/dist/security/audit-memory.d.ts +10 -0
  161. package/dist/security/audit-memory.d.ts.map +1 -1
  162. package/dist/security/injection-screen.d.ts +1 -2
  163. package/dist/security/injection-screen.d.ts.map +1 -1
  164. package/dist/semantic-consolidation.js +11 -11
  165. package/dist/semantic-rule-promotion.js +10 -10
  166. package/dist/semantic-rule-verifier.js +10 -10
  167. package/dist/source-agent-qualifier.js +13 -13
  168. package/dist/standup/build.js +10 -10
  169. package/dist/storage.js +9 -9
  170. package/dist/summarizer.js +2 -2
  171. package/dist/support-passport/index.js +30 -30
  172. package/dist/transfer/export-okf.js +10 -10
  173. package/dist/verified-recall.js +10 -10
  174. package/dist/who-knows-cli.js +11 -11
  175. package/dist/who-knows.js +10 -10
  176. package/package.json +12 -2
  177. package/src/access-mcp-output-schemas.ts +4 -0
  178. package/src/access-recall-response.ts +26 -1
  179. package/src/access-service.ts +1 -0
  180. package/src/index.ts +1 -1
  181. package/src/model-registry.test.ts +22 -1
  182. package/src/model-registry.ts +7 -6
  183. package/src/orchestration/recall-internal.ts +8 -5
  184. package/src/profiling.test.ts +20 -1
  185. package/src/profiling.ts +7 -7
  186. package/src/recall-composition-decision.test.ts +75 -0
  187. package/src/recall-composition-decision.ts +52 -3
  188. package/src/recall-retrieval-failure.test.ts +245 -0
  189. package/src/recall-why-service.ts +8 -24
  190. package/src/search/port.ts +32 -0
  191. package/src/security/audit-memory-cli.test.ts +90 -0
  192. package/src/security/audit-memory-cli.ts +7 -0
  193. package/src/security/audit-memory.ts +14 -0
  194. package/src/security/injection-screen.test.ts +245 -0
  195. package/src/security/injection-screen.ts +341 -11
  196. package/dist/chunk-FM5HN7LL.js.map +0 -1
  197. package/dist/chunk-NLE6TSLG.js.map +0 -1
  198. package/dist/chunk-U2VDCFXW.js +0 -19
  199. package/dist/chunk-U2VDCFXW.js.map +0 -1
  200. package/dist/chunk-WD32KTVI.js.map +0 -1
  201. package/dist/chunk-WLZYGLJ4.js.map +0 -1
  202. package/dist/chunk-ZFUPRODV.js.map +0 -1
  203. package/dist/chunk-ZJ6MEVTX.js.map +0 -1
  204. package/dist/chunk-ZOASYXBS.js.map +0 -1
  205. /package/dist/{chunk-52VL6LCY.js.map → chunk-2VH2JWYN.js.map} +0 -0
  206. /package/dist/{chunk-G4WZDQ32.js.map → chunk-3NG5OOCX.js.map} +0 -0
  207. /package/dist/{chunk-KG2OOAFF.js.map → chunk-3NTVCMDD.js.map} +0 -0
  208. /package/dist/{chunk-7OC3Q5X4.js.map → chunk-3S4R2HA5.js.map} +0 -0
  209. /package/dist/{chunk-NORFVW2H.js.map → chunk-4RWPQZYD.js.map} +0 -0
  210. /package/dist/{chunk-B5GMOOY2.js.map → chunk-5AABWM72.js.map} +0 -0
  211. /package/dist/{chunk-KRPMISWO.js.map → chunk-5C765XYU.js.map} +0 -0
  212. /package/dist/{chunk-XOLVM3AT.js.map → chunk-5WXJE5TV.js.map} +0 -0
  213. /package/dist/{chunk-2C5Z7CWB.js.map → chunk-6YTL3JYX.js.map} +0 -0
  214. /package/dist/{chunk-F4PEETM4.js.map → chunk-755VRFBS.js.map} +0 -0
  215. /package/dist/{chunk-HNOKNHFO.js.map → chunk-7L747CFR.js.map} +0 -0
  216. /package/dist/{chunk-WPGR3RW4.js.map → chunk-7VO6E5UD.js.map} +0 -0
  217. /package/dist/{chunk-LMLPLIT4.js.map → chunk-AQ6IBVFQ.js.map} +0 -0
  218. /package/dist/{chunk-XN4CPSCN.js.map → chunk-B2JE56NB.js.map} +0 -0
  219. /package/dist/{chunk-L4WBADKG.js.map → chunk-B5EZWPSM.js.map} +0 -0
  220. /package/dist/{chunk-EIU642TN.js.map → chunk-BE4GMH45.js.map} +0 -0
  221. /package/dist/{chunk-JTTLH646.js.map → chunk-C2W6JKQQ.js.map} +0 -0
  222. /package/dist/{chunk-FG5QU7A2.js.map → chunk-CFCAFAJC.js.map} +0 -0
  223. /package/dist/{chunk-M4PHCEPU.js.map → chunk-DDY7LJMP.js.map} +0 -0
  224. /package/dist/{chunk-2L5DIVDU.js.map → chunk-DT33WNG2.js.map} +0 -0
  225. /package/dist/{chunk-SZAWZPY6.js.map → chunk-E7SKAUEW.js.map} +0 -0
  226. /package/dist/{chunk-NRVGODSE.js.map → chunk-EK4MOTHM.js.map} +0 -0
  227. /package/dist/{chunk-QEGKMZNQ.js.map → chunk-FO5NDQZH.js.map} +0 -0
  228. /package/dist/{chunk-GMHWCPLP.js.map → chunk-GZLSIPYU.js.map} +0 -0
  229. /package/dist/{chunk-SEHKTE4F.js.map → chunk-I4IKTNZP.js.map} +0 -0
  230. /package/dist/{chunk-W65KK3X6.js.map → chunk-II4TIQCU.js.map} +0 -0
  231. /package/dist/{chunk-FWVDTLRK.js.map → chunk-IJQEVNEF.js.map} +0 -0
  232. /package/dist/{chunk-KXJ7XYUT.js.map → chunk-IOUQUXHO.js.map} +0 -0
  233. /package/dist/{chunk-V3JM4XEI.js.map → chunk-ITLFUILH.js.map} +0 -0
  234. /package/dist/{chunk-ZNFHWNDM.js.map → chunk-IULYVB5X.js.map} +0 -0
  235. /package/dist/{chunk-Z3YQOV45.js.map → chunk-JLU7WK22.js.map} +0 -0
  236. /package/dist/{chunk-NRTVZSPO.js.map → chunk-L4HT5QZ5.js.map} +0 -0
  237. /package/dist/{chunk-5QZHDS4X.js.map → chunk-LI4IWBLN.js.map} +0 -0
  238. /package/dist/{chunk-LY3SDHMI.js.map → chunk-LQ4OSM5V.js.map} +0 -0
  239. /package/dist/{chunk-2OUSWDY6.js.map → chunk-M3TMVHQ2.js.map} +0 -0
  240. /package/dist/{chunk-IDRSTHVR.js.map → chunk-MB3HMRXM.js.map} +0 -0
  241. /package/dist/{chunk-4P3ZBL5E.js.map → chunk-MUGLJAZ3.js.map} +0 -0
  242. /package/dist/{chunk-4IFSMCRS.js.map → chunk-NYN4ZMPL.js.map} +0 -0
  243. /package/dist/{chunk-KV44HTMJ.js.map → chunk-OBD77TYC.js.map} +0 -0
  244. /package/dist/{chunk-GYNCJSQ7.js.map → chunk-OCEIHKH5.js.map} +0 -0
  245. /package/dist/{chunk-DC5CTLCZ.js.map → chunk-PSGRZFBH.js.map} +0 -0
  246. /package/dist/{chunk-FQGY7XB2.js.map → chunk-S52VKUUW.js.map} +0 -0
  247. /package/dist/{chunk-D7MAYFQP.js.map → chunk-S7LWX2PQ.js.map} +0 -0
  248. /package/dist/{chunk-TYP7GYRI.js.map → chunk-SB4ZWVKI.js.map} +0 -0
  249. /package/dist/{chunk-KVO2BTA4.js.map → chunk-T2YX56QI.js.map} +0 -0
  250. /package/dist/{chunk-G6NQ3L4T.js.map → chunk-T3FMRLWQ.js.map} +0 -0
  251. /package/dist/{chunk-CDQO5DCD.js.map → chunk-TO3G56GM.js.map} +0 -0
  252. /package/dist/{chunk-WEQZ5OYK.js.map → chunk-U6GKPSZH.js.map} +0 -0
  253. /package/dist/{chunk-XDH5MO6L.js.map → chunk-WZ33AIOP.js.map} +0 -0
  254. /package/dist/{chunk-TM7S5UHD.js.map → chunk-XDIFADVN.js.map} +0 -0
  255. /package/dist/{chunk-SVWYZXIL.js.map → chunk-XDOXR3U6.js.map} +0 -0
  256. /package/dist/{chunk-VKDWYDFQ.js.map → chunk-XGLBVYEA.js.map} +0 -0
  257. /package/dist/{chunk-5Y7VCE3A.js.map → chunk-XJNSOI23.js.map} +0 -0
  258. /package/dist/{chunk-LDP6LBDS.js.map → chunk-Y3AFTYK4.js.map} +0 -0
  259. /package/dist/{chunk-YL2W6NUG.js.map → chunk-YGJMMMPN.js.map} +0 -0
  260. /package/dist/{chunk-KIOXEHWF.js.map → chunk-YIDBKTZJ.js.map} +0 -0
  261. /package/dist/{chunk-F5ZCNJ3V.js.map → chunk-ZPPWW576.js.map} +0 -0
  262. /package/dist/{chunk-TQAXUYUE.js.map → chunk-ZSXSMVW6.js.map} +0 -0
  263. /package/dist/{chunk-6RD7MSIO.js.map → chunk-ZUT4FK5W.js.map} +0 -0
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { log } from "./logger.js";
7
7
  import fs from "node:fs";
8
- import { join } from "node:path";
8
+ import { dirname, join } from "node:path";
9
9
 
10
10
  export interface ModelCapabilities {
11
11
  modelId: string;
@@ -116,11 +116,7 @@ export class ModelRegistry {
116
116
  private readonly CACHE_TTL_DAYS = 7;
117
117
 
118
118
  constructor(memoryDir: string) {
119
- const registryDir = join(memoryDir, ".registry");
120
- if (!fs.existsSync(registryDir)) {
121
- fs.mkdirSync(registryDir, { recursive: true });
122
- }
123
- this.registryPath = join(registryDir, "model-capabilities.json");
119
+ this.registryPath = join(memoryDir, ".registry", "model-capabilities.json");
124
120
  this.data = this.loadRegistry();
125
121
  }
126
122
 
@@ -140,6 +136,11 @@ export class ModelRegistry {
140
136
 
141
137
  private saveRegistry(): void {
142
138
  try {
139
+ // Created on first save, never at construction: building a registry
140
+ // happens on every plugin registration, including read-only discovery
141
+ // passes, and must not touch the operator's memory dir.
142
+ const registryDir = dirname(this.registryPath);
143
+ if (!fs.existsSync(registryDir)) fs.mkdirSync(registryDir, { recursive: true });
143
144
  fs.writeFileSync(this.registryPath, JSON.stringify(this.data, null, 2));
144
145
  } catch (err) {
145
146
  log.warn(`ModelRegistry: failed to save registry: ${err}`);
@@ -130,11 +130,9 @@ export class RecallInternalCoordinator {
130
130
  lifecycleCaps: MemoryLifecycleCapabilitySet = resolveMemoryLifecycleCapabilities(this.deps.config),
131
131
  ): Promise<string> {
132
132
  const recallStart = Date.now();
133
- // Backend degradations observed by this recall's QMD searches (#1536):
134
- // collected via the execution-options observer and attached to the
135
- // LastRecallSnapshot after it is recorded, so surfaces can distinguish
136
- // "no matches" from "backend could not answer" (CLAUDE.md rule 34).
137
- const backendDegradations: SearchDegradation[] = options.degradationSink ?? [];
133
+ // Per-recall QMD degradations (#1536/#3082). Local array so overlapping
134
+ // recalls that share a sink cannot mark each other failed.
135
+ const backendDegradations: SearchDegradation[] = [];
138
136
  // Issue #680 — historical recall. Parse `options.asOf` once at the
139
137
  // top of the recall so each boost-pass uses identical filter logic.
140
138
  // Invalid values are rejected at input boundaries (CLI / HTTP / MCP)
@@ -4964,7 +4962,12 @@ export class RecallInternalCoordinator {
4964
4962
  compactContext: compactRecallContextFromBuckets(sectionBuckets),
4965
4963
  footer: curiosityFooter,
4966
4964
  maxChars: recallBudgetChars,
4965
+ backendDegradations,
4966
+ qmdExpected: this.deps.config.searchBackend !== "noop" && (this.deps.config.searchBackend !== "qmd" || resolveQmdCapabilities(this.deps.config).qmd),
4967
4967
  });
4968
+ if (options.degradationSink) {
4969
+ for (const d of backendDegradations) options.degradationSink.push(d);
4970
+ }
4968
4971
  notifyContextComposition(options.onContextComposition, composition, (err) => {
4969
4972
  log.warn("recall: context composition observer failed open", err);
4970
4973
  });
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, readdirSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import test, { describe } from "node:test";
@@ -283,4 +283,23 @@ describe("ProfilingCollector", () => {
283
283
  cleanup(dir);
284
284
  }
285
285
  });
286
+
287
+ test("construction never creates the storage dir; the first persist does", () => {
288
+ const dir = tempDir();
289
+ const storageDir = join(dir, "profiling");
290
+ try {
291
+ const collector = new ProfilingCollector({ enabled: true, storageDir, maxTraces: 10 });
292
+ // Constructing a collector happens on every plugin registration, including
293
+ // read-only discovery passes, so it must not touch the memory dir.
294
+ assert.equal(existsSync(storageDir), false, "no dir before a trace is written");
295
+ collector.startTrace("recall", "test-session");
296
+ collector.startSpan("qmd-search");
297
+ collector.endSpan("qmd-search");
298
+ collector.endTrace();
299
+ assert.equal(existsSync(storageDir), true, "the first persisted trace creates it");
300
+ assert.equal(readdirSync(storageDir).length, 1);
301
+ } finally {
302
+ cleanup(dir);
303
+ }
304
+ });
286
305
  });
package/src/profiling.ts CHANGED
@@ -118,13 +118,6 @@ export class ProfilingCollector {
118
118
  this.enabled = config.enabled;
119
119
  this.storageDir = config.storageDir;
120
120
  this.maxTraces = Math.max(0, config.maxTraces);
121
-
122
- if (this.enabled) {
123
- if (!existsSync(this.storageDir)) {
124
- mkdirSync(this.storageDir, { recursive: true });
125
- log.debug(`profiling: created storage dir ${this.storageDir}`);
126
- }
127
- }
128
121
  }
129
122
 
130
123
  get isEnabled(): boolean {
@@ -347,6 +340,13 @@ export class ProfilingCollector {
347
340
  const filename = `${trace.kind}-${trace.traceId}.jsonl`;
348
341
  const filepath = join(this.storageDir, filename);
349
342
  try {
343
+ // Created on first persist, never at construction: constructing a
344
+ // collector (plugin registration, a discovery pass, CLI metadata) must
345
+ // not touch the operator's memory dir when no trace is ever written.
346
+ if (!existsSync(this.storageDir)) {
347
+ mkdirSync(this.storageDir, { recursive: true });
348
+ log.debug(`profiling: created storage dir ${this.storageDir}`);
349
+ }
350
350
  writeFileSync(filepath, JSON.stringify(trace) + "\n", "utf-8");
351
351
  log.debug(`profiling: persisted ${filename}`);
352
352
  } catch (err) {
@@ -30,6 +30,81 @@ test("#2972 empty recall stays marker-free", () => {
30
30
  assert.equal("degradation" in composition, false);
31
31
  });
32
32
 
33
+ test("#3082 empty context plus daemon_timeout is missing, not a genuine empty", () => {
34
+ const { composition, context } = decideRecallContextComposition({
35
+ context: "",
36
+ maxChars: 512,
37
+ backendDegradations: [{ backend: "qmd", code: "daemon_timeout", detail: "no response in 8000ms" }],
38
+ });
39
+
40
+ assert.equal(composition.degradation?.state, "missing");
41
+ assert.equal(composition.degradation?.reason, "backend-unavailable");
42
+ assert.match(composition.degradation?.detail ?? "", /qmd:daemon_timeout/);
43
+ assert.match(context, /memory context unavailable/i);
44
+ });
45
+
46
+ test("#3082 vector_tier_unavailable on empty context is not an outage", () => {
47
+ const { composition, context } = decideRecallContextComposition({
48
+ context: "",
49
+ maxChars: 512,
50
+ backendDegradations: [{ backend: "qmd", code: "vector_tier_unavailable" }],
51
+ });
52
+
53
+ assert.equal(context, "");
54
+ assert.equal("degradation" in composition, false);
55
+ });
56
+
57
+ test("#3082 daemon_timeout with recalled context keeps results and marks degraded", () => {
58
+ const { composition, truncated } = decideRecallContextComposition({
59
+ context: "A remembered deployment decision.",
60
+ maxChars: 512,
61
+ backendDegradations: [{ backend: "qmd", code: "daemon_timeout", detail: "cold-tier timed out" }],
62
+ });
63
+
64
+ assert.equal(composition.context, "A remembered deployment decision.");
65
+ assert.equal(composition.degradation?.state, "degraded");
66
+ assert.equal(composition.degradation?.reason, "backend-unavailable");
67
+ assert.equal(truncated, false);
68
+ });
69
+
70
+ test("#3082 QMD-disabled backend_unavailable is not an outage", () => {
71
+ const { composition, context } = decideRecallContextComposition({
72
+ context: "",
73
+ maxChars: 512,
74
+ qmdExpected: false,
75
+ backendDegradations: [{ backend: "qmd", code: "backend_unavailable", detail: "qmd disabled" }],
76
+ });
77
+
78
+ assert.equal(context, "");
79
+ assert.equal("degradation" in composition, false);
80
+ });
81
+
82
+ test("#3082 non-QMD backend_unavailable is still an outage when QMD is off", () => {
83
+ const { composition, context } = decideRecallContextComposition({
84
+ context: "",
85
+ maxChars: 512,
86
+ qmdExpected: false,
87
+ backendDegradations: [{ backend: "remote", code: "backend_unavailable" }],
88
+ });
89
+
90
+ assert.equal(composition.degradation?.state, "missing");
91
+ assert.equal(composition.degradation?.reason, "backend-unavailable");
92
+ assert.match(context, /memory context unavailable/i);
93
+ });
94
+ test("#3082 timeout plus budget clip still reports backend-unavailable", () => {
95
+ const longContext = "A remembered deployment decision. ".repeat(40).trim();
96
+ const { composition, truncated } = decideRecallContextComposition({
97
+ context: longContext,
98
+ maxChars: 80,
99
+ backendDegradations: [{ backend: "qmd", code: "daemon_timeout", detail: "no response in 8000ms" }],
100
+ });
101
+
102
+ assert.equal(composition.degradation?.reason, "backend-unavailable");
103
+ assert.match(composition.degradation?.detail ?? "", /qmd:daemon_timeout/);
104
+ assert.ok(composition.degradation?.budget, "budget accounting stays on the overlay");
105
+ assert.equal(truncated, true);
106
+ });
107
+
33
108
  test("#2972 compact form from section buckets is preferred over clipping", () => {
34
109
  const longOne = "full-form entry one with a long body ".repeat(6).trim();
35
110
  const longTwo = "full-form entry two with a long body ".repeat(6).trim();
@@ -15,6 +15,11 @@ import {
15
15
  RECALL_CONTEXT_SEPARATOR,
16
16
  type RecallContextComposition,
17
17
  } from "./recall-context-composition.js";
18
+ import {
19
+ describeSearchDegradations,
20
+ fatalSearchDegradations,
21
+ type SearchDegradation,
22
+ } from "./search/port.js";
18
23
 
19
24
  export type RecallCompositionChunk = string | { readonly content: string };
20
25
 
@@ -23,6 +28,14 @@ export interface DecideRecallContextCompositionInput {
23
28
  compactContext?: string;
24
29
  footer?: string;
25
30
  maxChars: number;
31
+ /** Mid-recall backend degradations (#3082). Absent on the healthy path. */
32
+ backendDegradations?: readonly SearchDegradation[];
33
+ /**
34
+ * When false, `{ backend: "qmd", code: "backend_unavailable" }` is the
35
+ * QMD-disabled/noop skip marker, not an outage. Other backends still count.
36
+ * Default true so unit tests that pass degradations stay honest.
37
+ */
38
+ qmdExpected?: boolean;
26
39
  }
27
40
 
28
41
  export interface DecidedRecallContextComposition {
@@ -65,18 +78,54 @@ export function compactRecallContextFromBuckets(
65
78
  export function decideRecallContextComposition(
66
79
  input: DecideRecallContextCompositionInput,
67
80
  ): DecidedRecallContextComposition {
68
- const composition = boundRecallContextComposition({
81
+ const observed = input.backendDegradations ?? [];
82
+ const qmdExpected = input.qmdExpected !== false;
83
+ const fatal = fatalSearchDegradations(observed).filter(
84
+ (d) => qmdExpected || d.backend !== "qmd" || d.code !== "backend_unavailable",
85
+ );
86
+ if (fatal.length > 0 && input.context.trim().length === 0) {
87
+ const missing = composeMissingMemoryContext({
88
+ detail: describeSearchDegradations(fatal),
89
+ });
90
+ const composition = input.footer
91
+ ? { ...missing, footer: input.footer }
92
+ : missing;
93
+ return {
94
+ composition,
95
+ context: composeRecallContext(composition),
96
+ truncated: false,
97
+ };
98
+ }
99
+ const bounded = boundRecallContextComposition({
69
100
  context: input.context,
70
101
  footer: input.footer,
71
102
  maxChars: input.maxChars,
72
103
  compactContext: input.compactContext,
73
104
  });
105
+ if (fatal.length === 0) {
106
+ return {
107
+ composition: bounded,
108
+ context: composeRecallContext(bounded),
109
+ truncated:
110
+ bounded.degradation?.reason === "budget-clipped" ||
111
+ bounded.degradation?.reason === "budget-compacted",
112
+ };
113
+ }
114
+ const composition = {
115
+ ...bounded,
116
+ degradation: {
117
+ state: "degraded" as const,
118
+ reason: "backend-unavailable" as const,
119
+ detail: describeSearchDegradations(fatal),
120
+ ...(bounded.degradation?.budget ? { budget: bounded.degradation.budget } : {}),
121
+ },
122
+ };
74
123
  return {
75
124
  composition,
76
125
  context: composeRecallContext(composition),
77
126
  truncated:
78
- composition.degradation?.reason === "budget-clipped" ||
79
- composition.degradation?.reason === "budget-compacted",
127
+ bounded.degradation?.reason === "budget-clipped" ||
128
+ bounded.degradation?.reason === "budget-compacted",
80
129
  };
81
130
  }
82
131
 
@@ -0,0 +1,245 @@
1
+ /**
2
+ * #3082: recall must distinguish a QMD daemon timeout from a genuine
3
+ * empty retrieval. Internally the pipeline already collected the
4
+ * degradation; this file asserts it is no longer flattened away by
5
+ * the time it reaches orchestrator.recall / EngramAccessService.recall
6
+ * / the MCP tool payload.
7
+ *
8
+ * Fixture data is synthetic and the store is an empty temp directory.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { mkdtemp, rm } from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import test from "node:test";
16
+
17
+ import { EngramMcpServer } from "./access-mcp.js";
18
+ import { EngramAccessService } from "./access-service.js";
19
+ import type { EngramAccessRecallRequest, EngramAccessRecallResponse } from "./access-service.js";
20
+ import { parseConfig } from "./config.js";
21
+ import { Orchestrator } from "./orchestrator.js";
22
+ import {
23
+ MEMORY_CONTEXT_UNAVAILABLE_NOTE,
24
+ type RecallContextComposition,
25
+ } from "./recall-context-composition.js";
26
+ import type { SearchBackend, SearchDegradation, SearchExecutionOptions } from "./search/port.js";
27
+ import { DEFAULT_RECALL_DISCLOSURE } from "./types.js";
28
+
29
+ const TIMEOUT_DEGRADATION: SearchDegradation = {
30
+ backend: "qmd",
31
+ code: "daemon_timeout",
32
+ detail: "no response within the deadline",
33
+ };
34
+
35
+ const QUERY = "what writing rules apply here?";
36
+
37
+ function searchBackend(observed: { calls: number }, degrade: boolean): SearchBackend {
38
+ const search = (execution?: SearchExecutionOptions) => {
39
+ observed.calls += 1;
40
+ if (degrade) execution?.onDegradation?.(TIMEOUT_DEGRADATION);
41
+ return [];
42
+ };
43
+ const backend: Partial<SearchBackend> = {
44
+ async probe() {
45
+ return true;
46
+ },
47
+ isAvailable() {
48
+ return true;
49
+ },
50
+ debugStatus() {
51
+ return degrade ? "backend=timeout-stub" : "backend=empty-stub";
52
+ },
53
+ async search(_query, _collection, _maxResults, _options, execution) {
54
+ return search(execution);
55
+ },
56
+ async searchGlobal(_query, _maxResults, execution) {
57
+ return search(execution);
58
+ },
59
+ async bm25Search(_query, _collection, _maxResults, execution) {
60
+ return search(execution);
61
+ },
62
+ async vectorSearch(_query, _collection, _maxResults, execution) {
63
+ return search(execution);
64
+ },
65
+ async hybridSearch(_query, _collection, _maxResults, execution) {
66
+ return search(execution);
67
+ },
68
+ async update() {},
69
+ async updateCollection() {},
70
+ async embed() {},
71
+ async embedCollection() {},
72
+ async ensureCollection() {
73
+ return "skipped";
74
+ },
75
+ };
76
+ return backend as SearchBackend;
77
+ }
78
+
79
+ async function withOrchestrator(
80
+ prefix: string,
81
+ degrade: boolean,
82
+ run: (orchestrator: Orchestrator, observed: { calls: number }) => Promise<void>,
83
+ ): Promise<void> {
84
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), prefix));
85
+ const orchestrator = new Orchestrator(
86
+ parseConfig({
87
+ memoryDir,
88
+ workspaceDir: memoryDir,
89
+ qmdEnabled: true,
90
+ embeddingFallbackEnabled: false,
91
+ }),
92
+ );
93
+ const observed = { calls: 0 };
94
+ // Unchecked cast with reason: `qmd` is assigned post-construction because the
95
+ // backend is built by the search factory, which would reach for a real daemon.
96
+ const withBackend = orchestrator as unknown as { qmd: SearchBackend };
97
+ withBackend.qmd = searchBackend(observed, degrade);
98
+ try {
99
+ await orchestrator.initialize();
100
+ await orchestrator.deferredReady;
101
+ await run(orchestrator, observed);
102
+ } finally {
103
+ await orchestrator.destroy();
104
+ await rm(memoryDir, { recursive: true, force: true });
105
+ }
106
+ }
107
+
108
+ test("QMD daemon_timeout mid-recall is distinguishable from genuine empty at the orchestrator", async () => {
109
+ await withOrchestrator("remnic-recall-timeout-", true, async (orchestrator, observed) => {
110
+ let composition: RecallContextComposition | undefined;
111
+ const context = await orchestrator.recall(QUERY, "timeout-session", {
112
+ onContextComposition: (value) => {
113
+ composition = value;
114
+ },
115
+ });
116
+
117
+ assert.ok(observed.calls > 0, "recall must consult the backend");
118
+ assert.equal(composition?.degradation?.state, "missing");
119
+ assert.equal(composition?.degradation?.reason, "backend-unavailable");
120
+ assert.match(composition?.degradation?.detail ?? "", /qmd:daemon_timeout/);
121
+ assert.equal(context, MEMORY_CONTEXT_UNAVAILABLE_NOTE);
122
+ });
123
+ });
124
+
125
+ test("genuine empty retrieval stays marker-free at the orchestrator", async () => {
126
+ await withOrchestrator("remnic-recall-empty-", false, async (orchestrator, observed) => {
127
+ let composition: RecallContextComposition | undefined;
128
+ const context = await orchestrator.recall(QUERY, "empty-session", {
129
+ onContextComposition: (value) => {
130
+ composition = value;
131
+ },
132
+ });
133
+
134
+ assert.ok(observed.calls > 0, "recall must consult the backend");
135
+ assert.equal(composition?.degradation, undefined);
136
+ assert.equal(context.includes("Memory context unavailable"), false);
137
+ });
138
+ });
139
+
140
+ test("access recall surfaces retrievalFailure on daemon timeout and omits it on genuine empty", async () => {
141
+ await withOrchestrator("remnic-recall-access-timeout-", true, async (timedOut, observed) => {
142
+ const failed = await new EngramAccessService(timedOut).recall({
143
+ query: QUERY,
144
+ sessionKey: "access-timeout",
145
+ });
146
+ assert.ok(observed.calls > 0, "timeout recall must consult the backend");
147
+ assert.equal(failed.retrievalFailure?.reason, "backend_unavailable");
148
+ assert.match(failed.retrievalFailure?.detail ?? "", /qmd:daemon_timeout/);
149
+ assert.equal(failed.contextComposition?.degradation?.state, "missing");
150
+ assert.match(failed.context, /memory context unavailable/i);
151
+ });
152
+
153
+ await withOrchestrator("remnic-recall-access-empty-", false, async (empty, observed) => {
154
+ const okEmpty = await new EngramAccessService(empty).recall({
155
+ query: QUERY,
156
+ sessionKey: "access-empty",
157
+ });
158
+ assert.ok(observed.calls > 0, "empty recall must consult the backend");
159
+ assert.equal(okEmpty.retrievalFailure, undefined);
160
+ assert.equal(okEmpty.contextComposition?.degradation, undefined);
161
+ assert.equal(okEmpty.context.includes("Memory context unavailable"), false);
162
+ });
163
+ });
164
+
165
+ test("a reused degradationSink cannot mark a later healthy empty recall as failed", async () => {
166
+ await withOrchestrator("remnic-recall-reused-sink-", true, async (orchestrator, observed) => {
167
+ const sink: SearchDegradation[] = [];
168
+ await orchestrator.recall(QUERY, "first-timeout", { degradationSink: sink });
169
+ assert.ok(sink.length > 0, "the first recall must record the timeout");
170
+
171
+ const withBackend = orchestrator as unknown as { qmd: SearchBackend };
172
+ withBackend.qmd = searchBackend(observed, false);
173
+ let composition: RecallContextComposition | undefined;
174
+ await orchestrator.recall(QUERY, "second-empty", {
175
+ degradationSink: sink,
176
+ onContextComposition: (value) => {
177
+ composition = value;
178
+ },
179
+ });
180
+ assert.equal(composition?.degradation, undefined);
181
+ });
182
+ });
183
+
184
+ test("MCP recall payload keeps retrievalFailure so a tool caller can branch on it", async () => {
185
+ const timeoutResponse: EngramAccessRecallResponse = {
186
+ query: QUERY,
187
+ namespace: "default",
188
+ context: MEMORY_CONTEXT_UNAVAILABLE_NOTE,
189
+ count: 0,
190
+ memoryIds: [],
191
+ results: [],
192
+ fallbackUsed: false,
193
+ sourcesUsed: [],
194
+ disclosure: DEFAULT_RECALL_DISCLOSURE,
195
+ retrievalFailure: {
196
+ reason: "backend_unavailable",
197
+ detail: "qmd:daemon_timeout (no response within the deadline)",
198
+ },
199
+ };
200
+ const emptyResponse: EngramAccessRecallResponse = {
201
+ query: QUERY,
202
+ namespace: "default",
203
+ context: "",
204
+ count: 0,
205
+ memoryIds: [],
206
+ results: [],
207
+ fallbackUsed: false,
208
+ sourcesUsed: [],
209
+ disclosure: DEFAULT_RECALL_DISCLOSURE,
210
+ };
211
+
212
+ async function callRecall(response: EngramAccessRecallResponse): Promise<EngramAccessRecallResponse> {
213
+ const service = {
214
+ briefingEnabled: false,
215
+ recall: (_req: EngramAccessRecallRequest) => Promise.resolve(response),
216
+ } as unknown as EngramAccessService;
217
+ const server = new EngramMcpServer(service);
218
+ // Unchecked cast with reason: handleRequest is a JSON-RPC envelope; this
219
+ // test owns both the spy recall result and the tool name, so the payload
220
+ // shape is the MCP recall structuredContent, not untrusted input.
221
+ const rpc = (await server.handleRequest({
222
+ jsonrpc: "2.0",
223
+ id: 1,
224
+ method: "tools/call",
225
+ params: { name: "engram.recall", arguments: { query: QUERY } },
226
+ })) as {
227
+ result?: { structuredContent?: EngramAccessRecallResponse; content?: Array<{ text?: string }> };
228
+ };
229
+ const structured = rpc.result?.structuredContent;
230
+ assert.ok(structured, "MCP recall must return structuredContent");
231
+ const text = rpc.result?.content?.[0]?.text ?? "{}";
232
+ const parsed: unknown = JSON.parse(text);
233
+ assert.ok(parsed && typeof parsed === "object");
234
+ const parsedFailure = "retrievalFailure" in parsed ? parsed.retrievalFailure : undefined;
235
+ assert.deepEqual(parsedFailure, structured.retrievalFailure);
236
+ return structured;
237
+ }
238
+
239
+ const timedOut = await callRecall(timeoutResponse);
240
+ const genuineEmpty = await callRecall(emptyResponse);
241
+ assert.equal(timedOut.count, genuineEmpty.count);
242
+ assert.deepEqual(timedOut.results, genuineEmpty.results);
243
+ assert.equal(timedOut.retrievalFailure?.reason, "backend_unavailable");
244
+ assert.equal(genuineEmpty.retrievalFailure, undefined);
245
+ });
@@ -27,29 +27,13 @@ import {
27
27
  type RecallWhyReport,
28
28
  } from "./recall-why.js";
29
29
  import { summarizeRecallWhy } from "./recall-why-renderer.js";
30
- import type { SearchDegradation } from "./search/port.js";
30
+ import {
31
+ describeSearchDegradations,
32
+ fatalSearchDegradations,
33
+ type SearchDegradation,
34
+ } from "./search/port.js";
31
35
  import type { MemoryFile, MemoryStatus } from "./types.js";
32
36
 
33
- /**
34
- * Degradation codes that mean the search backend could NOT answer, so an
35
- * empty pipeline carries no information. `vector_tier_unavailable` is
36
- * excluded on purpose: the lexical tier still answered, so the result set
37
- * is degraded but real.
38
- */
39
- const FATAL_DEGRADATION_CODES: Partial<Record<SearchDegradation["code"], true>> = {
40
- backend_unavailable: true,
41
- backend_error: true,
42
- daemon_timeout: true,
43
- daemon_loading: true,
44
- subprocess_error: true,
45
- deadline_exceeded: true,
46
- remote_error: true,
47
- };
48
-
49
- function describeDegradations(degradations: readonly SearchDegradation[]): string {
50
- return degradations.map((d) => `${d.backend}:${d.code}${d.detail !== undefined ? ` (${d.detail})` : ""}`).join("; ");
51
- }
52
-
53
37
  export interface RecallWhyRequest {
54
38
  query: string;
55
39
  /** Memory id or substring to trace through every stage. */
@@ -139,15 +123,15 @@ export async function runRecallWhy(deps: RecallWhyServiceDeps, request: RecallWh
139
123
  ...(options.abortSignal !== undefined ? { abortSignal: options.abortSignal } : {}),
140
124
  degradationSink: degradations,
141
125
  });
142
- const fatal = degradations.filter((d) => FATAL_DEGRADATION_CODES[d.code] === true);
126
+ const fatal = fatalSearchDegradations(degradations);
143
127
  if (fatal.length > 0) {
144
- return { ok: false, reason: "backend_unavailable", detail: describeDegradations(fatal) };
128
+ return { ok: false, reason: "backend_unavailable", detail: describeSearchDegradations(fatal) };
145
129
  }
146
130
  return { ok: true, snapshot: captured.snapshot };
147
131
  } catch (err) {
148
132
  // Cancellation is the caller's decision, not a backend outage.
149
133
  if (err instanceof Error && err.name === "AbortError") throw err;
150
- const observed = degradations.length > 0 ? `${describeDegradations(degradations)}; ` : "";
134
+ const observed = degradations.length > 0 ? `${describeSearchDegradations(degradations)}; ` : "";
151
135
  return {
152
136
  ok: false,
153
137
  reason: "backend_unavailable",
@@ -55,6 +55,38 @@ export function reportSearchDegradation(
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * Codes that mean the search backend could NOT answer, so an empty
60
+ * result set carries no information (issue #3082 / recall_why #3033).
61
+ * `vector_tier_unavailable` is excluded on purpose: the lexical tier
62
+ * still answered, so the result set is degraded but real.
63
+ */
64
+ export const FATAL_SEARCH_DEGRADATION_CODES: Readonly<
65
+ Partial<Record<SearchDegradation["code"], true>>
66
+ > = Object.freeze({
67
+ backend_unavailable: true,
68
+ backend_error: true,
69
+ daemon_timeout: true,
70
+ daemon_loading: true,
71
+ subprocess_error: true,
72
+ deadline_exceeded: true,
73
+ remote_error: true,
74
+ });
75
+
76
+ export function fatalSearchDegradations(
77
+ degradations: readonly SearchDegradation[],
78
+ ): SearchDegradation[] {
79
+ return degradations.filter((d) => FATAL_SEARCH_DEGRADATION_CODES[d.code] === true);
80
+ }
81
+
82
+ export function describeSearchDegradations(
83
+ degradations: readonly SearchDegradation[],
84
+ ): string {
85
+ return degradations
86
+ .map((d) => `${d.backend}:${d.code}${d.detail !== undefined ? ` (${d.detail})` : ""}`)
87
+ .join("; ");
88
+ }
89
+
58
90
  export function resolveEnsureCollectionArgs(
59
91
  collectionOrExecution?: string | SearchExecutionOptions,
60
92
  execution?: SearchExecutionOptions