@remnic/core 9.3.719 → 9.3.721

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 (387) hide show
  1. package/dist/access-boundary.d.ts +10 -7
  2. package/dist/access-boundary.js +37 -34
  3. package/dist/access-cli.js +71 -66
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +11 -8
  6. package/dist/access-http.js +44 -41
  7. package/dist/access-mcp.d.ts +11 -8
  8. package/dist/access-mcp.js +40 -37
  9. package/dist/access-operations-batch.js +38 -35
  10. package/dist/access-operations.d.ts +11 -8
  11. package/dist/access-operations.js +39 -36
  12. package/dist/access-schema.d.ts +19 -19
  13. package/dist/access-schema.js +3 -3
  14. package/dist/{access-service-CZbnt0t2.d.ts → access-service-Dj3I-VBS.d.ts} +3 -3
  15. package/dist/access-service.d.ts +11 -8
  16. package/dist/access-service.js +36 -33
  17. package/dist/access-surface-catalog.d.ts +10 -7
  18. package/dist/action-confidence.d.ts +1 -1
  19. package/dist/active-memory-bridge.d.ts +1 -1
  20. package/dist/active-recall.d.ts +1 -1
  21. package/dist/active-recall.js +4 -2
  22. package/dist/active-recall.js.map +1 -1
  23. package/dist/adapters/index.js +7 -7
  24. package/dist/adapters/registry.js +3 -3
  25. package/dist/{auto-sync-24NNS3GU.js → auto-sync-F3KCSQFJ.js} +5 -5
  26. package/dist/behavior-learner.d.ts +1 -1
  27. package/dist/behavior-signals.d.ts +1 -1
  28. package/dist/bootstrap.d.ts +11 -8
  29. package/dist/briefing.d.ts +1 -1
  30. package/dist/briefing.js +12 -10
  31. package/dist/buffer-surprise-report.d.ts +1 -1
  32. package/dist/buffer.d.ts +1 -1
  33. package/dist/calibration.d.ts +1 -1
  34. package/dist/calibration.js +2 -2
  35. package/dist/capabilities.d.ts +30 -2
  36. package/dist/capabilities.js +3 -1
  37. package/dist/{catalog-CEYlMs9X.d.ts → catalog-F-OEVbRQ.d.ts} +1 -1
  38. package/dist/causal-behavior.d.ts +1 -1
  39. package/dist/causal-behavior.js +2 -2
  40. package/dist/causal-chain.js +2 -2
  41. package/dist/causal-consolidation.d.ts +1 -1
  42. package/dist/causal-consolidation.js +15 -13
  43. package/dist/causal-consolidation.js.map +1 -1
  44. package/dist/causal-retrieval.js +2 -2
  45. package/dist/causal-trajectory-graph.d.ts +4 -0
  46. package/dist/causal-trajectory-graph.js +2 -2
  47. package/dist/causal-trajectory.js +1 -1
  48. package/dist/{chunk-TH7WMHVK.js → chunk-3NN73CM7.js} +2 -2
  49. package/dist/{chunk-A5TUPZ3N.js → chunk-4SF232E6.js} +60 -30
  50. package/dist/chunk-4SF232E6.js.map +1 -0
  51. package/dist/{chunk-DGTDRIUO.js → chunk-4T3BMHKD.js} +2 -2
  52. package/dist/{chunk-EH27LHML.js → chunk-5O42UKXI.js} +2 -2
  53. package/dist/{chunk-KRGC4EDX.js → chunk-6FAMMBQ3.js} +2 -2
  54. package/dist/{chunk-FEB7W5JG.js → chunk-6OIDDKAA.js} +27 -6
  55. package/dist/chunk-6OIDDKAA.js.map +1 -0
  56. package/dist/{chunk-67HG7KFE.js → chunk-6YKIYQU7.js} +2 -2
  57. package/dist/{chunk-LXSG4L55.js → chunk-7CTNUVHZ.js} +2 -2
  58. package/dist/{chunk-UCJ5CDTK.js → chunk-A5XNBK2D.js} +22 -14
  59. package/dist/chunk-A5XNBK2D.js.map +1 -0
  60. package/dist/{chunk-7EEVXW7X.js → chunk-A7TJZLPK.js} +2 -2
  61. package/dist/{chunk-P5IPFZVH.js → chunk-B7WISIAJ.js} +5 -5
  62. package/dist/chunk-BCDKBDSY.js +173 -0
  63. package/dist/chunk-BCDKBDSY.js.map +1 -0
  64. package/dist/{chunk-JAKYZGV4.js → chunk-BXEOHV7K.js} +3 -3
  65. package/dist/chunk-C4X4QGIO.js +60 -0
  66. package/dist/chunk-C4X4QGIO.js.map +1 -0
  67. package/dist/{chunk-S3OPGGZB.js → chunk-D7EBTQ24.js} +2 -2
  68. package/dist/{chunk-IO5NQEGZ.js → chunk-DKT3X5C3.js} +2 -2
  69. package/dist/{chunk-PBH4JUAB.js → chunk-DVRHLN2O.js} +18 -3
  70. package/dist/chunk-DVRHLN2O.js.map +1 -0
  71. package/dist/{chunk-S35FX6MU.js → chunk-EBDA5TYA.js} +2 -2
  72. package/dist/{chunk-2PTBLS2S.js → chunk-EDOQM4E4.js} +2 -2
  73. package/dist/{chunk-YTZLBBEB.js → chunk-EEJMD2L2.js} +3 -3
  74. package/dist/{chunk-YGKUAX2B.js → chunk-EHQ5HO5T.js} +11 -11
  75. package/dist/chunk-EHQ5HO5T.js.map +1 -0
  76. package/dist/{chunk-JIZGYQYH.js → chunk-EM2LDXJ2.js} +4 -4
  77. package/dist/{chunk-YYOK4GVF.js → chunk-EVPNWN7Z.js} +10 -10
  78. package/dist/{chunk-HLIR3RJ7.js → chunk-EXUCUPV2.js} +2 -2
  79. package/dist/{chunk-F3PVXKHP.js → chunk-GDUOCHCD.js} +27 -21
  80. package/dist/chunk-GDUOCHCD.js.map +1 -0
  81. package/dist/{chunk-L5ASZA2F.js → chunk-GJSL4WG2.js} +17 -2
  82. package/dist/chunk-GJSL4WG2.js.map +1 -0
  83. package/dist/{chunk-2SXMCQEQ.js → chunk-GZQXOQQ2.js} +3 -3
  84. package/dist/{chunk-2KAYTPPT.js → chunk-HCVWF657.js} +12 -12
  85. package/dist/{chunk-N3DUH6OO.js → chunk-ILFSEIRT.js} +1 -1
  86. package/dist/chunk-ILFSEIRT.js.map +1 -0
  87. package/dist/{chunk-4KZGLDJJ.js → chunk-J3JPB7ZI.js} +2 -2
  88. package/dist/{chunk-3JJWNZTT.js → chunk-L2B3EE7B.js} +102 -42
  89. package/dist/chunk-L2B3EE7B.js.map +1 -0
  90. package/dist/{chunk-NFRBADNR.js → chunk-LA3HZKES.js} +610 -369
  91. package/dist/chunk-LA3HZKES.js.map +1 -0
  92. package/dist/{chunk-LQ6JI4VH.js → chunk-LD53WPMU.js} +4 -4
  93. package/dist/{chunk-URM3HW2R.js → chunk-LLUGAZ3B.js} +2 -2
  94. package/dist/{chunk-RVLB4E6N.js → chunk-MDBZTH7N.js} +5 -32
  95. package/dist/chunk-MDBZTH7N.js.map +1 -0
  96. package/dist/{chunk-U5H7WHTS.js → chunk-N77ELJ2D.js} +4 -4
  97. package/dist/{chunk-2VHMVWPU.js → chunk-NXRUUDUF.js} +2 -2
  98. package/dist/{chunk-DIFU33GU.js → chunk-OHTOPFRF.js} +2 -2
  99. package/dist/{chunk-74MYJSKB.js → chunk-ORAK3LNZ.js} +2 -2
  100. package/dist/{chunk-REWQAFSA.js → chunk-PD42EYQN.js} +2 -2
  101. package/dist/chunk-PHK3HARR.js +32 -0
  102. package/dist/chunk-PHK3HARR.js.map +1 -0
  103. package/dist/{chunk-773DPI6T.js → chunk-PKMQK2WY.js} +4 -4
  104. package/dist/{chunk-72VDBALG.js → chunk-QD6QP5P6.js} +63 -54
  105. package/dist/chunk-QD6QP5P6.js.map +1 -0
  106. package/dist/{chunk-CTOQEZSN.js → chunk-QR67FU5G.js} +2 -2
  107. package/dist/{chunk-AU7Q3LSC.js → chunk-RKNJBZ55.js} +7 -7
  108. package/dist/{chunk-YI7RZO7Y.js → chunk-SQNED75R.js} +1 -1
  109. package/dist/{chunk-5CEJH5ZN.js → chunk-TAPSNIMA.js} +4 -4
  110. package/dist/{chunk-IF362TF6.js → chunk-TQT3USCG.js} +2 -2
  111. package/dist/chunk-TQT3USCG.js.map +1 -0
  112. package/dist/{chunk-5RHM43BJ.js → chunk-TXQCG6SE.js} +3 -3
  113. package/dist/{chunk-ANI5DUAE.js → chunk-UIXXE6NI.js} +7 -7
  114. package/dist/{chunk-2COL35PK.js → chunk-UO5ZHIIH.js} +2 -2
  115. package/dist/{chunk-OJU73BUX.js → chunk-VCLGUIFM.js} +2 -2
  116. package/dist/{chunk-5J4OL7TJ.js → chunk-VUA4S7P6.js} +4 -4
  117. package/dist/{chunk-A66YRSOS.js → chunk-WQYSDPMA.js} +2 -2
  118. package/dist/chunk-XGTBNVIU.js +79 -0
  119. package/dist/chunk-XGTBNVIU.js.map +1 -0
  120. package/dist/{chunk-OZ2KQ7HF.js → chunk-YEE5KJQQ.js} +2 -2
  121. package/dist/chunk-YN2UN7N3.js +217 -0
  122. package/dist/chunk-YN2UN7N3.js.map +1 -0
  123. package/dist/{chunk-WRFKZEO6.js → chunk-YQGHW5ML.js} +4 -4
  124. package/dist/{chunk-APUJIMDM.js → chunk-Z4RU5VPM.js} +2 -2
  125. package/dist/{chunk-DCWIQFNA.js → chunk-ZUDM75KG.js} +4 -4
  126. package/dist/{cli-1orcdXOY.d.ts → cli-P_GaYcCY.d.ts} +3 -3
  127. package/dist/cli.d.ts +9 -6
  128. package/dist/cli.js +67 -63
  129. package/dist/compounding/engine.d.ts +1 -1
  130. package/dist/compounding/engine.js +12 -10
  131. package/dist/compounding/preference-consolidator.d.ts +1 -1
  132. package/dist/compression-optimizer.d.ts +1 -1
  133. package/dist/config.d.ts +1 -1
  134. package/dist/config.js +4 -2
  135. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  136. package/dist/connectors/codex-materialize-runner.js +12 -10
  137. package/dist/connectors/codex-materialize.d.ts +1 -1
  138. package/dist/connectors/index.d.ts +1 -1
  139. package/dist/connectors/index.js +13 -11
  140. package/dist/consolidation-provenance-check.d.ts +1 -1
  141. package/dist/consolidation-undo.d.ts +1 -1
  142. package/dist/contradiction/index.d.ts +1 -1
  143. package/dist/conversation-index/backend.d.ts +1 -1
  144. package/dist/conversation-index/backend.js +2 -2
  145. package/dist/conversation-index/chunker.d.ts +1 -1
  146. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  147. package/dist/conversation-index/indexer.d.ts +1 -1
  148. package/dist/conversation-index/search.d.ts +1 -1
  149. package/dist/day-summary.d.ts +1 -1
  150. package/dist/delinearize.d.ts +1 -1
  151. package/dist/direct-answer-wiring.d.ts +1 -1
  152. package/dist/direct-answer.d.ts +1 -1
  153. package/dist/embedding-fallback.d.ts +1 -1
  154. package/dist/enrichment/index.d.ts +1 -1
  155. package/dist/entity-retrieval.d.ts +1 -1
  156. package/dist/entity-retrieval.js +12 -10
  157. package/dist/entity-schema.d.ts +1 -1
  158. package/dist/explicit-capture.d.ts +11 -8
  159. package/dist/extraction-faithfulness.d.ts +8 -2
  160. package/dist/extraction-faithfulness.js +4 -3
  161. package/dist/extraction-judge-telemetry.d.ts +1 -1
  162. package/dist/extraction-judge-training.d.ts +1 -1
  163. package/dist/extraction-judge.d.ts +1 -1
  164. package/dist/extraction-judge.js +3 -3
  165. package/dist/extraction.d.ts +1 -1
  166. package/dist/extraction.js +5 -4
  167. package/dist/faithfulness-config.d.ts +55 -0
  168. package/dist/faithfulness-config.js +11 -0
  169. package/dist/faithfulness-config.js.map +1 -0
  170. package/dist/fallback-llm.d.ts +1 -1
  171. package/dist/fallback-llm.js +2 -2
  172. package/dist/{forget-U66KPDPY.js → forget-OEE5OELK.js} +2 -2
  173. package/dist/graph-dashboard-diff.d.ts +4 -0
  174. package/dist/graph-dashboard-key.d.ts +4 -0
  175. package/dist/graph-dashboard-parser.d.ts +4 -0
  176. package/dist/{graph-edge-decay-JLOF3YW4.js → graph-edge-decay-QPRJQ7DL.js} +3 -3
  177. package/dist/graph-edge-reinforcement.d.ts +4 -0
  178. package/dist/graph-snapshot.d.ts +7 -3
  179. package/dist/graph-snapshot.js +3 -3
  180. package/dist/graph.d.ts +7 -1
  181. package/dist/graph.js +2 -2
  182. package/dist/identity-continuity.d.ts +1 -1
  183. package/dist/importance.d.ts +1 -1
  184. package/dist/index.d.ts +571 -570
  185. package/dist/index.js +104 -99
  186. package/dist/index.js.map +1 -1
  187. package/dist/intent.d.ts +1 -1
  188. package/dist/lcm/engine.d.ts +1 -1
  189. package/dist/lcm/engine.js +2 -2
  190. package/dist/lcm/index.d.ts +1 -1
  191. package/dist/lcm/index.js +4 -4
  192. package/dist/lcm/tools.d.ts +1 -1
  193. package/dist/lifecycle.d.ts +1 -1
  194. package/dist/live-connectors-runner.d.ts +1 -1
  195. package/dist/local-llm.d.ts +1 -1
  196. package/dist/local-model-endpoint.d.ts +96 -0
  197. package/dist/local-model-endpoint.js +12 -0
  198. package/dist/local-model-endpoint.js.map +1 -0
  199. package/dist/maintenance/memory-governance.d.ts +1 -1
  200. package/dist/maintenance/memory-governance.js +12 -10
  201. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +12 -10
  202. package/dist/maintenance/rebuild-memory-projection.js +13 -11
  203. package/dist/mcp-memory-inspector-app.d.ts +11 -8
  204. package/dist/memory-action-policy.d.ts +1 -1
  205. package/dist/memory-cache.d.ts +1 -1
  206. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  207. package/dist/memory-projection-store.d.ts +1 -1
  208. package/dist/memory-provenance.d.ts +1 -1
  209. package/dist/memory-worth-outcomes.d.ts +1 -1
  210. package/dist/models-json.d.ts +1 -1
  211. package/dist/namespaces/migrate.d.ts +2 -2
  212. package/dist/namespaces/migrate.js +22 -20
  213. package/dist/namespaces/principal.d.ts +1 -1
  214. package/dist/namespaces/search.d.ts +1 -1
  215. package/dist/namespaces/search.js +9 -9
  216. package/dist/namespaces/storage.d.ts +2 -2
  217. package/dist/namespaces/storage.js +12 -10
  218. package/dist/native-knowledge.d.ts +1 -1
  219. package/dist/operator-toolkit.d.ts +3 -2
  220. package/dist/operator-toolkit.js +32 -28
  221. package/dist/orchestration/maintenance.d.ts +2 -2
  222. package/dist/orchestration/maintenance.js +14 -12
  223. package/dist/{orchestrator-kOxredSr.d.ts → orchestrator-f5xv19kp.d.ts} +178 -81
  224. package/dist/orchestrator.d.ts +8 -5
  225. package/dist/orchestrator.js +56 -52
  226. package/dist/patterns-cli.d.ts +1 -1
  227. package/dist/policy-runtime.d.ts +1 -1
  228. package/dist/provenance.d.ts +1 -1
  229. package/dist/provenance.js +2 -1
  230. package/dist/qmd-recall-cache.d.ts +1 -1
  231. package/dist/qmd.d.ts +1 -1
  232. package/dist/recall-disclosure-escalation.d.ts +1 -1
  233. package/dist/recall-explain-renderer.d.ts +1 -1
  234. package/dist/recall-explain-renderer.js +3 -3
  235. package/dist/recall-planner-llm.d.ts +1 -1
  236. package/dist/recall-planner-llm.js +2 -2
  237. package/dist/recall-state.d.ts +1 -1
  238. package/dist/recall-tag-filter.d.ts +1 -1
  239. package/dist/recall-xray-cli.d.ts +1 -1
  240. package/dist/recall-xray-cli.js +4 -4
  241. package/dist/recall-xray-renderer.d.ts +1 -1
  242. package/dist/recall-xray-renderer.js +3 -3
  243. package/dist/recall-xray.d.ts +18 -1
  244. package/dist/recall-xray.js +2 -2
  245. package/dist/resolve-auth-token.d.ts +1 -1
  246. package/dist/resume-bundles.js +5 -3
  247. package/dist/retrieval-agents.d.ts +1 -1
  248. package/dist/retrieval-tiers.d.ts +1 -1
  249. package/dist/routing/engine.d.ts +1 -1
  250. package/dist/routing/store.d.ts +1 -1
  251. package/dist/search/embed-helper.d.ts +1 -1
  252. package/dist/search/factory.d.ts +1 -1
  253. package/dist/search/factory.js +8 -8
  254. package/dist/search/index.d.ts +1 -1
  255. package/dist/search/index.js +14 -14
  256. package/dist/search/lancedb-backend.d.ts +1 -1
  257. package/dist/search/lancedb-backend.js +2 -2
  258. package/dist/search/meilisearch-backend.d.ts +1 -1
  259. package/dist/search/meilisearch-backend.js +2 -2
  260. package/dist/search/noop-backend.d.ts +1 -1
  261. package/dist/search/orama-backend.d.ts +1 -1
  262. package/dist/search/orama-backend.js +2 -2
  263. package/dist/search/port.d.ts +1 -1
  264. package/dist/search/remote-backend.d.ts +1 -1
  265. package/dist/{semantic-consolidation-B58v3S-a.d.ts → semantic-consolidation-BQm1e1Pn.d.ts} +1 -1
  266. package/dist/semantic-consolidation.d.ts +2 -2
  267. package/dist/semantic-consolidation.js +13 -11
  268. package/dist/semantic-rule-promotion.js +12 -10
  269. package/dist/semantic-rule-verifier.d.ts +1 -1
  270. package/dist/semantic-rule-verifier.js +12 -10
  271. package/dist/session-observer-bands.d.ts +1 -1
  272. package/dist/session-observer-state.d.ts +1 -1
  273. package/dist/shared-context/manager.d.ts +1 -1
  274. package/dist/signal.d.ts +1 -1
  275. package/dist/storage.d.ts +45 -1
  276. package/dist/storage.js +11 -9
  277. package/dist/summarizer.d.ts +1 -1
  278. package/dist/summarizer.js +3 -3
  279. package/dist/summary-snapshot.d.ts +1 -1
  280. package/dist/temporal-supersession.d.ts +1 -1
  281. package/dist/temporal-validity.d.ts +1 -1
  282. package/dist/threading.d.ts +1 -1
  283. package/dist/tier-migration.d.ts +1 -1
  284. package/dist/tier-routing.d.ts +1 -1
  285. package/dist/topics.d.ts +1 -1
  286. package/dist/transcript.d.ts +1 -1
  287. package/dist/transfer/backup.js +2 -2
  288. package/dist/transfer/capsule-export.js +2 -2
  289. package/dist/transfer/capsule-import.js +1 -1
  290. package/dist/trust-score-stage.d.ts +178 -0
  291. package/dist/trust-score-stage.js +18 -0
  292. package/dist/trust-score-stage.js.map +1 -0
  293. package/dist/trust-score.d.ts +164 -0
  294. package/dist/trust-score.js +22 -0
  295. package/dist/trust-score.js.map +1 -0
  296. package/dist/{types-ACmDOScJ.d.ts → types-DwO1Ot4n.d.ts} +56 -2
  297. package/dist/types.d.ts +1 -1
  298. package/dist/types.js +1 -1
  299. package/dist/utility-runtime.d.ts +1 -1
  300. package/dist/verified-recall.js +12 -10
  301. package/package.json +2 -2
  302. package/src/access-service.ts +8 -5
  303. package/src/capabilities-cluster-b.test.ts +78 -0
  304. package/src/capabilities.test.ts +11 -1
  305. package/src/capabilities.ts +58 -0
  306. package/src/cli/research-status-commands.ts +7 -3
  307. package/src/cli.ts +11 -6
  308. package/src/config.ts +60 -30
  309. package/src/extraction-faithfulness.test.ts +175 -0
  310. package/src/extraction-faithfulness.ts +53 -1
  311. package/src/faithfulness-config.test.ts +96 -0
  312. package/src/faithfulness-config.ts +138 -0
  313. package/src/graph-snapshot.ts +3 -3
  314. package/src/graph.ts +8 -7
  315. package/src/lifecycle/tombstones.test.ts +242 -1
  316. package/src/lifecycle/tombstones.ts +157 -70
  317. package/src/local-model-endpoint.test.ts +155 -0
  318. package/src/local-model-endpoint.ts +189 -0
  319. package/src/operator-toolkit.ts +24 -7
  320. package/src/orchestration/tier-migration-coordinator.ts +286 -0
  321. package/src/orchestrator.ts +331 -222
  322. package/src/recall-xray.test.ts +62 -0
  323. package/src/recall-xray.ts +34 -0
  324. package/src/trust-score-recall-paths.test.ts +157 -0
  325. package/src/trust-score-stage.test.ts +350 -0
  326. package/src/trust-score-stage.ts +398 -0
  327. package/src/trust-score.test.ts +282 -0
  328. package/src/trust-score.ts +449 -0
  329. package/src/types.ts +61 -1
  330. package/dist/chunk-3JJWNZTT.js.map +0 -1
  331. package/dist/chunk-72VDBALG.js.map +0 -1
  332. package/dist/chunk-A5TUPZ3N.js.map +0 -1
  333. package/dist/chunk-F3PVXKHP.js.map +0 -1
  334. package/dist/chunk-FEB7W5JG.js.map +0 -1
  335. package/dist/chunk-IF362TF6.js.map +0 -1
  336. package/dist/chunk-L5ASZA2F.js.map +0 -1
  337. package/dist/chunk-N3DUH6OO.js.map +0 -1
  338. package/dist/chunk-NFRBADNR.js.map +0 -1
  339. package/dist/chunk-PBH4JUAB.js.map +0 -1
  340. package/dist/chunk-RVLB4E6N.js.map +0 -1
  341. package/dist/chunk-UCJ5CDTK.js.map +0 -1
  342. package/dist/chunk-YGKUAX2B.js.map +0 -1
  343. /package/dist/{auto-sync-24NNS3GU.js.map → auto-sync-F3KCSQFJ.js.map} +0 -0
  344. /package/dist/{chunk-TH7WMHVK.js.map → chunk-3NN73CM7.js.map} +0 -0
  345. /package/dist/{chunk-DGTDRIUO.js.map → chunk-4T3BMHKD.js.map} +0 -0
  346. /package/dist/{chunk-EH27LHML.js.map → chunk-5O42UKXI.js.map} +0 -0
  347. /package/dist/{chunk-KRGC4EDX.js.map → chunk-6FAMMBQ3.js.map} +0 -0
  348. /package/dist/{chunk-67HG7KFE.js.map → chunk-6YKIYQU7.js.map} +0 -0
  349. /package/dist/{chunk-LXSG4L55.js.map → chunk-7CTNUVHZ.js.map} +0 -0
  350. /package/dist/{chunk-7EEVXW7X.js.map → chunk-A7TJZLPK.js.map} +0 -0
  351. /package/dist/{chunk-P5IPFZVH.js.map → chunk-B7WISIAJ.js.map} +0 -0
  352. /package/dist/{chunk-JAKYZGV4.js.map → chunk-BXEOHV7K.js.map} +0 -0
  353. /package/dist/{chunk-S3OPGGZB.js.map → chunk-D7EBTQ24.js.map} +0 -0
  354. /package/dist/{chunk-IO5NQEGZ.js.map → chunk-DKT3X5C3.js.map} +0 -0
  355. /package/dist/{chunk-S35FX6MU.js.map → chunk-EBDA5TYA.js.map} +0 -0
  356. /package/dist/{chunk-2PTBLS2S.js.map → chunk-EDOQM4E4.js.map} +0 -0
  357. /package/dist/{chunk-YTZLBBEB.js.map → chunk-EEJMD2L2.js.map} +0 -0
  358. /package/dist/{chunk-JIZGYQYH.js.map → chunk-EM2LDXJ2.js.map} +0 -0
  359. /package/dist/{chunk-YYOK4GVF.js.map → chunk-EVPNWN7Z.js.map} +0 -0
  360. /package/dist/{chunk-HLIR3RJ7.js.map → chunk-EXUCUPV2.js.map} +0 -0
  361. /package/dist/{chunk-2SXMCQEQ.js.map → chunk-GZQXOQQ2.js.map} +0 -0
  362. /package/dist/{chunk-2KAYTPPT.js.map → chunk-HCVWF657.js.map} +0 -0
  363. /package/dist/{chunk-4KZGLDJJ.js.map → chunk-J3JPB7ZI.js.map} +0 -0
  364. /package/dist/{chunk-LQ6JI4VH.js.map → chunk-LD53WPMU.js.map} +0 -0
  365. /package/dist/{chunk-URM3HW2R.js.map → chunk-LLUGAZ3B.js.map} +0 -0
  366. /package/dist/{chunk-U5H7WHTS.js.map → chunk-N77ELJ2D.js.map} +0 -0
  367. /package/dist/{chunk-2VHMVWPU.js.map → chunk-NXRUUDUF.js.map} +0 -0
  368. /package/dist/{chunk-DIFU33GU.js.map → chunk-OHTOPFRF.js.map} +0 -0
  369. /package/dist/{chunk-74MYJSKB.js.map → chunk-ORAK3LNZ.js.map} +0 -0
  370. /package/dist/{chunk-REWQAFSA.js.map → chunk-PD42EYQN.js.map} +0 -0
  371. /package/dist/{chunk-773DPI6T.js.map → chunk-PKMQK2WY.js.map} +0 -0
  372. /package/dist/{chunk-CTOQEZSN.js.map → chunk-QR67FU5G.js.map} +0 -0
  373. /package/dist/{chunk-AU7Q3LSC.js.map → chunk-RKNJBZ55.js.map} +0 -0
  374. /package/dist/{chunk-YI7RZO7Y.js.map → chunk-SQNED75R.js.map} +0 -0
  375. /package/dist/{chunk-5CEJH5ZN.js.map → chunk-TAPSNIMA.js.map} +0 -0
  376. /package/dist/{chunk-5RHM43BJ.js.map → chunk-TXQCG6SE.js.map} +0 -0
  377. /package/dist/{chunk-ANI5DUAE.js.map → chunk-UIXXE6NI.js.map} +0 -0
  378. /package/dist/{chunk-2COL35PK.js.map → chunk-UO5ZHIIH.js.map} +0 -0
  379. /package/dist/{chunk-OJU73BUX.js.map → chunk-VCLGUIFM.js.map} +0 -0
  380. /package/dist/{chunk-5J4OL7TJ.js.map → chunk-VUA4S7P6.js.map} +0 -0
  381. /package/dist/{chunk-A66YRSOS.js.map → chunk-WQYSDPMA.js.map} +0 -0
  382. /package/dist/{chunk-OZ2KQ7HF.js.map → chunk-YEE5KJQQ.js.map} +0 -0
  383. /package/dist/{chunk-WRFKZEO6.js.map → chunk-YQGHW5ML.js.map} +0 -0
  384. /package/dist/{chunk-APUJIMDM.js.map → chunk-Z4RU5VPM.js.map} +0 -0
  385. /package/dist/{chunk-DCWIQFNA.js.map → chunk-ZUDM75KG.js.map} +0 -0
  386. /package/dist/{forget-U66KPDPY.js.map → forget-OEE5OELK.js.map} +0 -0
  387. /package/dist/{graph-edge-decay-JLOF3YW4.js.map → graph-edge-decay-QPRJQ7DL.js.map} +0 -0
@@ -40,7 +40,7 @@
40
40
  // - Semantic tier (4) ships dark, off by default (rule 48).
41
41
  // ---------------------------------------------------------------------------
42
42
 
43
- import { serializeMutations } from "../utils/serialize-mutations.js";
43
+ import { serializeMutations, withHeldFileLock } from "../utils/serialize-mutations.js";
44
44
 
45
45
  /** Why a tombstone was emitted. */
46
46
  export type TombstoneReason =
@@ -129,6 +129,20 @@ export interface TombstoneStoreOptions {
129
129
  * skipped entirely.
130
130
  */
131
131
  semanticSimilarity?: (a: string, b: string) => number;
132
+ /**
133
+ * Cross-process write-lock timings for the tombstone JSONL mutation lock
134
+ * (issue #1639). The secure-store append is a read-merge-write (read
135
+ * encrypted → decrypt → concat → re-encrypt → atomic rename), so two
136
+ * Remnic processes appending concurrently can each read the same contents
137
+ * and the last writer drops the other's entry — breaking the
138
+ * non-resurrection invariant. The lock serializes the mutation across
139
+ * processes. When omitted, defaults that fit the short tombstone critical
140
+ * section (mirroring the summary-snapshot lock): staleMs 30s, maxWaitMs 5s,
141
+ * heartbeatMs floor(staleMs/3).
142
+ */
143
+ readonly lockStaleMs?: number;
144
+ readonly lockMaxWaitMs?: number;
145
+ readonly lockHeartbeatMs?: number;
132
146
  }
133
147
 
134
148
  /** Injected file I/O — the StorageManager wires its secure-store-aware
@@ -261,7 +275,33 @@ export class TombstoneStore {
261
275
  private readonly namespace: string,
262
276
  private readonly options: TombstoneStoreOptions,
263
277
  private readonly io: TombstoneFileIo,
264
- ) {}
278
+ ) {
279
+ this.lockStaleMs = this.options.lockStaleMs ?? 30_000;
280
+ this.lockMaxWaitMs = this.options.lockMaxWaitMs ?? 5_000;
281
+ this.lockHeartbeatMs =
282
+ this.options.lockHeartbeatMs ?? Math.max(100, Math.floor(this.lockStaleMs / 3));
283
+ }
284
+
285
+ /**
286
+ * Resolved cross-process write-lock timings (issue #1639). Defaults mirror
287
+ * the summary-snapshot lock: a tombstone append critical section is
288
+ * sub-second, so a stale lock older than 30s is a crashed holder; bounded
289
+ * acquisition gives up after 5s and strict-fails rather than clobbering a
290
+ * concurrent writer (which would reintroduce the lost-write race this lock
291
+ * closes).
292
+ */
293
+ private readonly lockStaleMs: number;
294
+ private readonly lockMaxWaitMs: number;
295
+ private readonly lockHeartbeatMs: number;
296
+
297
+ /**
298
+ * Sibling advisory lockfile: `<tombstones>.jsonl` → `<tombstones>.lock`.
299
+ * Shared by append, revoke, and rebuild so every JSONL mutation serializes
300
+ * against the others across processes (issue #1639: "shared with rebuilds").
301
+ */
302
+ private get lockPath(): string {
303
+ return this.filePath.replace(/\.jsonl$/i, "") + ".lock";
304
+ }
265
305
 
266
306
  /** Lazy load + build the in-memory index. Idempotent. */
267
307
  async load(): Promise<void> {
@@ -473,10 +513,48 @@ export class TombstoneStore {
473
513
 
474
514
  private serializeAppend(entry: TombstoneEntry): Promise<void> {
475
515
  const line = JSON.stringify(entry) + "\n";
476
- // serializeMutations recovers after rejection (rule 40): a failed append
477
- // surfaces to THIS caller but the next append is not poisoned.
516
+ // serializeMutations (in-process, rule 40 rejection recovery) wraps the
517
+ // cross-process lock: within one process appends are queued, and across
518
+ // processes the advisory lockfile serializes the read-merge-write so two
519
+ // Remnic processes appending concurrently cannot drop each other's entry
520
+ // (issue #1639). Under the lock we re-sync the in-memory index against
521
+ // disk first, so indexEntry builds on the peer's just-appended entries.
478
522
  return serializeMutations(`tombstone:${this.filePath}`, () =>
479
- this.io.append(this.filePath, line),
523
+ this.withWriteLock(async () => {
524
+ await this.ensureFreshAgainstDisk();
525
+ await this.io.append(this.filePath, line);
526
+ }),
527
+ );
528
+ }
529
+
530
+ /**
531
+ * Acquire the cross-process tombstone write lock and run `task` under it
532
+ * (issue #1639). Strict-fail on acquisition timeout: the secure-store append
533
+ * is a read-merge-write, so a best-effort unlocked run would clobber a
534
+ * concurrent writer — reintroducing the lost-write race this lock closes.
535
+ * The thrown error surfaces to the caller (rule 34: never silently drop);
536
+ * rebuild recovers the tombstone on the next maintenance cycle.
537
+ *
538
+ * In-process callers are already serialized by serializeMutations (one key
539
+ * for append/revoke/rebuild), so this lock only contends across processes —
540
+ * the single-process daemon (default deployment) acquires it immediately.
541
+ */
542
+ private withWriteLock<T>(task: () => Promise<T>): Promise<T> {
543
+ return withHeldFileLock(
544
+ this.lockPath,
545
+ {
546
+ staleMs: this.lockStaleMs,
547
+ maxWaitMs: this.lockMaxWaitMs,
548
+ heartbeatMs: this.lockHeartbeatMs,
549
+ },
550
+ async (acquired) => {
551
+ if (!acquired) {
552
+ throw new Error(
553
+ "could not acquire tombstone write lock (contention timeout or filesystem error)",
554
+ );
555
+ }
556
+ return task();
557
+ },
480
558
  );
481
559
  }
482
560
 
@@ -595,72 +673,81 @@ export class TombstoneStore {
595
673
  * Returns the count of tombstone entries written.
596
674
  */
597
675
  async rebuild(retiredMemories: ReadonlyArray<RetiredMemoryRecord>): Promise<number> {
598
- // Preserve existing revocations (all namespaces ids are globally
599
- // unique) so a rebuild does not silently un-revoke.
600
- const existingRevocations = this.entries.filter((e) => e.kind === "revocation");
601
- // Preserve tombstone entries from OTHER namespaces when the backing file
602
- // is shared (issue #1579 thread Oc2MJ). rebuild rewrites the entire file;
603
- // without preserving foreign entries, rebuilding namespace A would
604
- // silently delete namespace B's tombstones, allowing resurrection in B.
605
- const foreignTombstones = this.entries.filter(
606
- (e) => e.kind === "tombstone" && e.namespace !== this.namespace,
607
- );
608
- // Reuse existing tombstone ids for source-equivalent entries so a prior
609
- // revocation (which references the tombstone id) survives rebuild — minting
610
- // fresh ids would orphan the revocation and silently un-revoke the content.
611
- // Issue #1579 thread Oci-T: key the reuse map by (sourceMemoryId,
612
- // supersessionKey), not just sourceMemoryId. A retired fact with multiple
613
- // structured-attribute keys emits one record per key (see
614
- // collectRetiredMemoriesForRebuild); keying only by sourceMemoryId made
615
- // every rebuilt record share one id, so a revocation of key A silently
616
- // revoked key B (over-revoke) or, if a prior single-key tombstone was
617
- // revoked, the new multi-key records all inherited a revoked id and
618
- // silently un-blocked (orphan). Including the supersession-key
619
- // discriminator keeps each keyed tombstone's id (and thus its revocation)
620
- // independent. Records without a supersession key fall back to a stable
621
- // empty-string discriminator so they still reuse by sourceMemoryId alone.
622
- const existingBySource = new Map<string, string>();
623
- for (const e of this.entries) {
624
- if (e.kind === "tombstone") {
625
- existingBySource.set(`${e.sourceMemoryId}\u{0000}${e.supersessionKey ?? ""}`, e.id);
626
- }
627
- }
628
- const rebuilt: TombstoneEntry[] = retiredMemories.map((m) => ({
629
- id:
630
- existingBySource.get(`${m.memoryId}\u{0000}${m.supersessionKey ?? ""}`) ??
631
- newTombstoneId(),
632
- kind: "tombstone" as const,
633
- reason: m.reason,
634
- sourceMemoryId: m.memoryId,
635
- contentHash: m.contentHash ?? this.options.hashContent(m.rawContent),
636
- normalizedText: this.options.normalizeText(m.rawContent),
637
- ...(m.entityRef ? { entityRef: m.entityRef } : {}),
638
- ...(m.supersessionKey ? { supersessionKey: m.supersessionKey } : {}),
639
- namespace: this.namespace,
640
- createdAt: m.createdAt,
641
- createdBy: m.createdBy,
642
- }));
643
- // Sort deterministically (rule 38): createdAt, then id for stability.
644
- rebuilt.sort((a, b) =>
645
- a.createdAt === b.createdAt
646
- ? a.id < b.id ? -1 : a.id > b.id ? 1 : 0
647
- : a.createdAt < b.createdAt ? -1 : 1,
648
- );
649
- const all = [...rebuilt, ...existingRevocations, ...foreignTombstones].sort((a, b) =>
650
- a.createdAt === b.createdAt
651
- ? a.id < b.id ? -1 : a.id > b.id ? 1 : 0
652
- : a.createdAt < b.createdAt ? -1 : 1,
653
- );
654
- const serialized = all.map((e) => JSON.stringify(e)).join("\n") + (all.length > 0 ? "\n" : "");
655
- await serializeMutations(`tombstone-rebuild:${this.filePath}`, () =>
656
- this.io.write(this.filePath, serialized),
676
+ // The full read-merge-write runs UNDER the cross-process lock (issue #1639,
677
+ // cursor/codex review): we ensureFreshAgainstDisk() first so the in-memory
678
+ // index reflects any peer append/revoke that landed while we waited for the
679
+ // lock, THEN compute the payload. Rebuild rewrites the entire JSONL, so a
680
+ // payload built from a stale this.entries would overwrite a peer's just-
681
+ // written entry and resurrect a retired fact — the exact invariant this
682
+ // store exists to enforce. serializeMutations (in-process) wraps the
683
+ // cross-process lock; the key is unified with append/revoke so they cannot
684
+ // interleave in-process either.
685
+ const rebuiltCount = await serializeMutations(`tombstone:${this.filePath}`, () =>
686
+ this.withWriteLock(async () => {
687
+ await this.ensureFreshAgainstDisk();
688
+ // Preserve existing revocations (all namespaces ids are globally
689
+ // unique) so a rebuild does not silently un-revoke.
690
+ const existingRevocations = this.entries.filter((e) => e.kind === "revocation");
691
+ // Preserve tombstone entries from OTHER namespaces when the backing
692
+ // file is shared (issue #1579 thread Oc2MJ). rebuild rewrites the
693
+ // entire file; without preserving foreign entries, rebuilding
694
+ // namespace A would silently delete namespace B's tombstones, allowing
695
+ // resurrection in B.
696
+ const foreignTombstones = this.entries.filter(
697
+ (e) => e.kind === "tombstone" && e.namespace !== this.namespace,
698
+ );
699
+ // Reuse existing tombstone ids for source-equivalent entries so a prior
700
+ // revocation (which references the tombstone id) survives rebuild —
701
+ // minting fresh ids would orphan the revocation and silently un-revoke.
702
+ // Issue #1579 thread Oci-T: key the reuse map by (sourceMemoryId,
703
+ // supersessionKey). See the append path above for the full rationale.
704
+ const existingBySource = new Map<string, string>();
705
+ for (const e of this.entries) {
706
+ if (e.kind === "tombstone") {
707
+ existingBySource.set(`${e.sourceMemoryId}\u{0000}${e.supersessionKey ?? ""}`, e.id);
708
+ }
709
+ }
710
+ const rebuilt: TombstoneEntry[] = retiredMemories.map((m) => ({
711
+ id:
712
+ existingBySource.get(`${m.memoryId}\u{0000}${m.supersessionKey ?? ""}`) ??
713
+ newTombstoneId(),
714
+ kind: "tombstone" as const,
715
+ reason: m.reason,
716
+ sourceMemoryId: m.memoryId,
717
+ contentHash: m.contentHash ?? this.options.hashContent(m.rawContent),
718
+ normalizedText: this.options.normalizeText(m.rawContent),
719
+ ...(m.entityRef ? { entityRef: m.entityRef } : {}),
720
+ ...(m.supersessionKey ? { supersessionKey: m.supersessionKey } : {}),
721
+ namespace: this.namespace,
722
+ createdAt: m.createdAt,
723
+ createdBy: m.createdBy,
724
+ }));
725
+ // Sort deterministically (rule 38): createdAt, then id for stability.
726
+ rebuilt.sort((a, b) =>
727
+ a.createdAt === b.createdAt
728
+ ? a.id < b.id ? -1 : a.id > b.id ? 1 : 0
729
+ : a.createdAt < b.createdAt ? -1 : 1,
730
+ );
731
+ const all = [...rebuilt, ...existingRevocations, ...foreignTombstones].sort((a, b) =>
732
+ a.createdAt === b.createdAt
733
+ ? a.id < b.id ? -1 : a.id > b.id ? 1 : 0
734
+ : a.createdAt < b.createdAt ? -1 : 1,
735
+ );
736
+ const serialized =
737
+ all.map((e) => JSON.stringify(e)).join("\n") + (all.length > 0 ? "\n" : "");
738
+ await this.io.write(this.filePath, serialized);
739
+ // Update the in-memory index under the same lock so it stays consistent
740
+ // with the file we just wrote (no window for a lookup to see a stale
741
+ // index that misses a just-rebuilt entry).
742
+ this.resetIndex();
743
+ for (const entry of all) this.indexEntry(entry);
744
+ this.corruptedLines = 0;
745
+ this.loaded = true;
746
+ this.markWritten();
747
+ return rebuilt.length;
748
+ }),
657
749
  );
658
- this.resetIndex();
659
- for (const entry of all) this.indexEntry(entry);
660
- this.corruptedLines = 0;
661
- this.loaded = true;
662
- this.markWritten();
663
- return rebuilt.length;
750
+ return rebuiltCount;
664
751
  }
665
752
  }
666
753
 
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Tests for the local model-lab endpoint resolver + openai-compatible caller
3
+ * (issue #1585). Pure unit tests — the fetch caller takes an injected
4
+ * `fetchImpl`, so no network, no GPU, no live server.
5
+ */
6
+
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+
10
+ import {
11
+ resolveFaithfulnessGateEndpoint,
12
+ resolveCorrectionIntentEndpoint,
13
+ callOpenAiCompatibleChat,
14
+ type EndpointChatMessage,
15
+ } from "./local-model-endpoint.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Resolvers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ test("resolveFaithfulnessGateEndpoint: null when either key empty (default = no pointer)", () => {
22
+ assert.equal(resolveFaithfulnessGateEndpoint({ extractionFaithfulnessModel: "", extractionFaithfulnessBaseUrl: "" }), null);
23
+ assert.equal(resolveFaithfulnessGateEndpoint({ extractionFaithfulnessModel: "m", extractionFaithfulnessBaseUrl: "" }), null);
24
+ assert.equal(resolveFaithfulnessGateEndpoint({ extractionFaithfulnessModel: "", extractionFaithfulnessBaseUrl: "http://x/v1" }), null);
25
+ // whitespace-only counts as empty.
26
+ assert.equal(resolveFaithfulnessGateEndpoint({ extractionFaithfulnessModel: " ", extractionFaithfulnessBaseUrl: "http://x/v1" }), null);
27
+ });
28
+
29
+ test("resolveFaithfulnessGateEndpoint: returns trimmed endpoint when both keys set", () => {
30
+ const ep = resolveFaithfulnessGateEndpoint({
31
+ extractionFaithfulnessModel: " remnic-faithfulness-gate-v1 ",
32
+ extractionFaithfulnessBaseUrl: "http://localhost:11434/v1",
33
+ });
34
+ assert.deepEqual(ep, { baseUrl: "http://localhost:11434/v1", model: "remnic-faithfulness-gate-v1" });
35
+ });
36
+
37
+ test("resolveCorrectionIntentEndpoint: same contract as the gate resolver", () => {
38
+ assert.equal(resolveCorrectionIntentEndpoint({ correctionIntentModel: "", correctionIntentBaseUrl: "" }), null);
39
+ assert.equal(resolveCorrectionIntentEndpoint({ correctionIntentModel: "m", correctionIntentBaseUrl: "" }), null);
40
+ assert.deepEqual(
41
+ resolveCorrectionIntentEndpoint({ correctionIntentModel: "ci-v1", correctionIntentBaseUrl: "http://localhost:8000/v1" }),
42
+ { baseUrl: "http://localhost:8000/v1", model: "ci-v1" },
43
+ );
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // callOpenAiCompatibleChat — injected fetch, no network
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** Build a minimal Response-shaped object the caller reads (ok, json, .json). */
51
+ function jsonResponse(status: number, body: unknown): Response {
52
+ return new Response(JSON.stringify(body), {
53
+ status,
54
+ headers: { "content-type": "application/json" },
55
+ });
56
+ }
57
+
58
+ const MESSAGES: EndpointChatMessage[] = [
59
+ { role: "system", content: "verifier" },
60
+ { role: "user", content: "fact vs quote" },
61
+ ];
62
+
63
+ test("callOpenAiCompatibleChat: happy path — first choice content + reported model", async () => {
64
+ let calledUrl = "";
65
+ let calledBody: Record<string, unknown> | undefined;
66
+ const fakeFetch = (url: string, init: RequestInit): Promise<Response> => {
67
+ calledUrl = url;
68
+ calledBody = JSON.parse(String(init.body));
69
+ return Promise.resolve(
70
+ jsonResponse(200, {
71
+ model: "remnic-faithfulness-gate-v1",
72
+ choices: [{ message: { content: '[{"index":0,"verdict":"entailed"}]' } }],
73
+ }),
74
+ );
75
+ };
76
+ const result = await callOpenAiCompatibleChat(
77
+ { baseUrl: "http://localhost:11434/v1", model: "remnic-faithfulness-gate-v1" },
78
+ MESSAGES,
79
+ { timeoutMs: 5000, temperature: 0.1, maxTokens: 2048, responseFormatJson: true },
80
+ fakeFetch as typeof fetch,
81
+ );
82
+ assert.equal(result?.content, '[{"index":0,"verdict":"entailed"}]');
83
+ assert.equal(result?.modelUsed, "remnic-faithfulness-gate-v1");
84
+ // URL joined correctly (no double slash), body carries model + json_object format.
85
+ assert.equal(calledUrl, "http://localhost:11434/v1/chat/completions");
86
+ assert.equal(calledBody?.model, "remnic-faithfulness-gate-v1");
87
+ assert.deepEqual(calledBody?.response_format, { type: "json_object" });
88
+ assert.equal(calledBody?.max_tokens, 2048);
89
+ });
90
+
91
+ test("callOpenAiCompatibleChat: trailing slash on baseUrl is tolerated", async () => {
92
+ const seen = (await callOpenAiCompatibleChat(
93
+ { baseUrl: "http://localhost:11434/v1/", model: "m" },
94
+ MESSAGES,
95
+ { timeoutMs: 1000 },
96
+ ((url: string) => {
97
+ assert.equal(url, "http://localhost:11434/v1/chat/completions");
98
+ return Promise.resolve(jsonResponse(200, { choices: [{ message: { content: "ok" } }] }));
99
+ }) as typeof fetch,
100
+ ))!;
101
+ assert.equal(seen.content, "ok");
102
+ assert.equal(seen.modelUsed, "m"); // server reported no model → falls back to requested name
103
+ });
104
+
105
+ test("callOpenAiCompatibleChat: returns null on non-2xx (fail closed)", async () => {
106
+ const result = await callOpenAiCompatibleChat(
107
+ { baseUrl: "http://x/v1", model: "m" },
108
+ MESSAGES,
109
+ { timeoutMs: 1000 },
110
+ (() => Promise.resolve(jsonResponse(500, { error: "boom" }))) as typeof fetch,
111
+ );
112
+ assert.equal(result, null);
113
+ });
114
+
115
+ test("callOpenAiCompatibleChat: returns null on network error (fail closed)", async () => {
116
+ const result = await callOpenAiCompatibleChat(
117
+ { baseUrl: "http://x/v1", model: "m" },
118
+ MESSAGES,
119
+ { timeoutMs: 1000 },
120
+ (() => Promise.reject(new Error("ECONNREFUSED"))) as typeof fetch,
121
+ );
122
+ assert.equal(result, null);
123
+ });
124
+
125
+ test("callOpenAiCompatibleChat: returns null on malformed body (no fabricated shape)", async () => {
126
+ for (const body of [
127
+ null,
128
+ {},
129
+ { choices: [] },
130
+ { choices: [{ message: {} }] },
131
+ { choices: [{ message: { content: 42 } }] }, // non-string content
132
+ { choices: "not-an-array" },
133
+ ]) {
134
+ const result = await callOpenAiCompatibleChat(
135
+ { baseUrl: "http://x/v1", model: "m" },
136
+ MESSAGES,
137
+ { timeoutMs: 1000 },
138
+ (() => Promise.resolve(jsonResponse(200, body))) as typeof fetch,
139
+ );
140
+ assert.equal(result, null, `body ${JSON.stringify(body)} should yield null`);
141
+ }
142
+ });
143
+
144
+ test("callOpenAiCompatibleChat: returns null on .json() throw", async () => {
145
+ const result = await callOpenAiCompatibleChat(
146
+ { baseUrl: "http://x/v1", model: "m" },
147
+ MESSAGES,
148
+ { timeoutMs: 1000 },
149
+ (() =>
150
+ Promise.resolve(
151
+ new Response("not-json", { status: 200, headers: { "content-type": "application/json" } }),
152
+ )) as typeof fetch,
153
+ );
154
+ assert.equal(result, null);
155
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Local fine-tuned model endpoint resolver + openai-compatible caller
3
+ * (issue #1585 model-lab integration).
4
+ *
5
+ * Remnic's two classification workloads — the extraction faithfulness gate
6
+ * (#1576) and passive-correction intent detection (#1581) — can run on small
7
+ * fine-tuned models served locally (Ollama / vLLM) instead of prompted
8
+ * frontier LLMs. The model-lab (`model-lab/`) produces those models; this
9
+ * module is the config-side half: it resolves the optional local endpoint
10
+ * pointer from config and, when set, makes a minimal openai-compatible
11
+ * chat-completions call to it.
12
+ *
13
+ * Design:
14
+ * - **Pointers are optional.** Empty defaults preserve the existing routing
15
+ * chain exactly (rule 39 / byte-identical pre-feature). A resolver returns
16
+ * `null` when the pointer is unset, so callers short-circuit before any
17
+ * network.
18
+ * - **Graceful degradation.** A local-endpoint failure (network, non-2xx,
19
+ * malformed body, timeout) returns `null`; callers fall back to the
20
+ * configured chain (checklist §4 — never block writes on a classifier).
21
+ * - **No inline casts.** The fetch JSON is narrowed with `in` / `typeof`
22
+ * guards (project rule: never assert a shape to read one property).
23
+ * - **Injectable fetch.** `callOpenAiCompatibleChat` takes an optional
24
+ * `fetchImpl` so the caller is unit-testable without a live server.
25
+ */
26
+
27
+ import type { PluginConfig } from "./types.js";
28
+
29
+ /** A resolved local endpoint: where to call + which model to name. */
30
+ export interface LocalModelEndpoint {
31
+ /** Base URL, e.g. `http://localhost:11434/v1` (no trailing slash required). */
32
+ baseUrl: string;
33
+ /** Model name the server expects, e.g. `remnic-faithfulness-gate-v1`. */
34
+ model: string;
35
+ }
36
+
37
+ /** A chat message in the openai-compatible shape this caller sends. */
38
+ export interface EndpointChatMessage {
39
+ role: "system" | "user" | "assistant";
40
+ content: string;
41
+ }
42
+
43
+ /** Options for a single local-endpoint chat call. */
44
+ export interface EndpointChatOptions {
45
+ /** Per-call timeout in ms. The caller's batch AbortSignal is NOT forwarded
46
+ * (matches the local-LLM precedent: each attempt is bounded by its own
47
+ * timer so one slow server can't starve the batch). */
48
+ timeoutMs: number;
49
+ /** Sampling temperature — classification wants low entropy (0.0–0.2). */
50
+ temperature?: number;
51
+ /** Max output tokens. */
52
+ maxTokens?: number;
53
+ /** Request JSON-object output (the gate emits a JSON verdict array). */
54
+ responseFormatJson?: boolean;
55
+ }
56
+
57
+ /** Result of a local-endpoint chat call. */
58
+ export interface EndpointChatResult {
59
+ content: string;
60
+ /** The model the server reports it used (falls back to the requested name). */
61
+ modelUsed: string;
62
+ }
63
+
64
+ /**
65
+ * Resolve the faithfulness-gate local endpoint from config (issue #1585).
66
+ *
67
+ * Returns the endpoint only when BOTH a base URL and a model name are
68
+ * configured; otherwise `null` (gate uses its existing routing chain). The
69
+ * gate's verifier needs a model identity, so a base URL alone is intentionally
70
+ * insufficient — silently calling whatever the server's default model is would
71
+ * be an unreviewed behavioral change.
72
+ */
73
+ export function resolveFaithfulnessGateEndpoint(
74
+ config: Pick<PluginConfig, "extractionFaithfulnessModel" | "extractionFaithfulnessBaseUrl">,
75
+ ): LocalModelEndpoint | null {
76
+ const baseUrl = config.extractionFaithfulnessBaseUrl.trim();
77
+ const model = config.extractionFaithfulnessModel.trim();
78
+ if (!baseUrl || !model) return null;
79
+ return { baseUrl, model };
80
+ }
81
+
82
+ /**
83
+ * Resolve the correction-intent local endpoint from config (issue #1581/#1585).
84
+ *
85
+ * Same contract as the gate resolver. Consumption by the model-backed
86
+ * detector is the consuming child's job — the rule-based
87
+ * `detectPassiveCorrections` remains the default path; this pointer lets a
88
+ * future model-backed detector route to a fine-tuned local classifier when an
89
+ * operator configures one.
90
+ */
91
+ export function resolveCorrectionIntentEndpoint(
92
+ config: Pick<PluginConfig, "correctionIntentModel" | "correctionIntentBaseUrl">,
93
+ ): LocalModelEndpoint | null {
94
+ const baseUrl = config.correctionIntentBaseUrl.trim();
95
+ const model = config.correctionIntentModel.trim();
96
+ if (!baseUrl || !model) return null;
97
+ return { baseUrl, model };
98
+ }
99
+
100
+ type FetchLike = typeof fetch;
101
+
102
+ /**
103
+ * Make an openai-compatible chat-completions call to a local endpoint.
104
+ *
105
+ * POSTs to `${baseUrl}/chat/completions` with `{ model, messages, ... }` and
106
+ * returns the first choice's content. Any failure (non-2xx, network error,
107
+ * malformed body, timeout) returns `null` so callers fall back gracefully.
108
+ *
109
+ * The response is narrowed with runtime guards — never an unchecked cast — so
110
+ * a surprising server response fails closed (`null`) rather than reading a
111
+ * fabricated field.
112
+ */
113
+ export async function callOpenAiCompatibleChat(
114
+ endpoint: LocalModelEndpoint,
115
+ messages: EndpointChatMessage[],
116
+ options: EndpointChatOptions,
117
+ fetchImpl: FetchLike = fetch,
118
+ ): Promise<EndpointChatResult | null> {
119
+ const url = joinBaseUrl(endpoint.baseUrl, "/chat/completions");
120
+ const controller = new AbortController();
121
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
122
+ try {
123
+ const body: Record<string, unknown> = {
124
+ model: endpoint.model,
125
+ messages,
126
+ temperature: options.temperature ?? 0.1,
127
+ };
128
+ if (typeof options.maxTokens === "number") {
129
+ body.max_tokens = options.maxTokens;
130
+ }
131
+ if (options.responseFormatJson) {
132
+ body.response_format = { type: "json_object" };
133
+ }
134
+ let response: Response;
135
+ try {
136
+ response = await fetchImpl(url, {
137
+ method: "POST",
138
+ headers: { "content-type": "application/json" },
139
+ body: JSON.stringify(body),
140
+ signal: controller.signal,
141
+ });
142
+ } catch {
143
+ // Network error or abort — fail closed, let the caller fall back.
144
+ return null;
145
+ }
146
+ if (!response.ok) return null;
147
+ const parsed: unknown = await response.json().catch(() => null);
148
+ const content = extractFirstChoiceContent(parsed);
149
+ if (content === null) return null;
150
+ return { content, modelUsed: extractModel(parsed) ?? endpoint.model };
151
+ } finally {
152
+ clearTimeout(timer);
153
+ }
154
+ }
155
+
156
+ /** Join a base URL and a path, tolerating a trailing slash on the base. */
157
+ function joinBaseUrl(base: string, path: string): string {
158
+ return base.endsWith("/") ? `${base.slice(0, -1)}${path}` : `${base}${path}`;
159
+ }
160
+
161
+ /**
162
+ * Narrow the openai-compatible response and pull the first choice's content.
163
+ *
164
+ * `in` / `typeof` guards only — no `as` cast (project rule). Returns `null`
165
+ * for any shape that isn't `{ choices: [{ message: { content: string } }, …] }`.
166
+ */
167
+ function extractFirstChoiceContent(data: unknown): string | null {
168
+ if (!data || typeof data !== "object") return null;
169
+ // After `in`, TS narrows the property to `unknown` — read it directly, no cast.
170
+ if (!("choices" in data) || !Array.isArray(data.choices)) return null;
171
+ const choices: unknown[] = data.choices;
172
+ for (const choice of choices) {
173
+ if (!choice || typeof choice !== "object") continue;
174
+ if (!("message" in choice)) continue;
175
+ const message = choice.message;
176
+ if (!message || typeof message !== "object") continue;
177
+ if (!("content" in message)) continue;
178
+ const content = message.content;
179
+ if (typeof content === "string" && content.length > 0) return content;
180
+ }
181
+ return null;
182
+ }
183
+
184
+ /** Narrow the `model` field from the response (best-effort, may be absent). */
185
+ function extractModel(data: unknown): string | null {
186
+ if (!data || typeof data !== "object" || !("model" in data)) return null;
187
+ const model = data.model;
188
+ return typeof model === "string" && model.length > 0 ? model : null;
189
+ }