@remnic/core 9.3.726 → 9.3.728

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 (342) hide show
  1. package/dist/access-boundary.d.ts +5 -5
  2. package/dist/access-boundary.js +22 -22
  3. package/dist/access-cli.js +45 -45
  4. package/dist/access-http.d.ts +20 -5
  5. package/dist/access-http.js +27 -27
  6. package/dist/access-mcp.d.ts +5 -5
  7. package/dist/access-mcp.js +26 -26
  8. package/dist/access-operations-batch.js +23 -23
  9. package/dist/access-operations.d.ts +6 -6
  10. package/dist/access-operations.js +25 -25
  11. package/dist/access-schema.d.ts +4 -4
  12. package/dist/access-schema.js +3 -3
  13. package/dist/{access-service-B2PlPtxh.d.ts → access-service-Bgpjxo4f.d.ts} +36 -4
  14. package/dist/access-service.d.ts +5 -5
  15. package/dist/access-service.js +21 -21
  16. package/dist/access-surface-catalog.d.ts +5 -5
  17. package/dist/action-confidence.d.ts +1 -1
  18. package/dist/active-memory-bridge.d.ts +1 -1
  19. package/dist/active-recall.d.ts +1 -1
  20. package/dist/active-recall.js +3 -3
  21. package/dist/behavior-learner.d.ts +1 -1
  22. package/dist/behavior-signals.d.ts +1 -1
  23. package/dist/bootstrap.d.ts +4 -4
  24. package/dist/briefing.d.ts +1 -1
  25. package/dist/briefing.js +7 -7
  26. package/dist/buffer-surprise-report.d.ts +1 -1
  27. package/dist/buffer.d.ts +1 -1
  28. package/dist/calibration.d.ts +1 -1
  29. package/dist/capabilities.d.ts +44 -2
  30. package/dist/capabilities.js +5 -3
  31. package/dist/{capsule-crypto-GWVG7LGC.js → capsule-crypto-7FJQINUR.js} +2 -2
  32. package/dist/{catalog-CzUb-GyD.d.ts → catalog-bMczlO2G.d.ts} +1 -1
  33. package/dist/causal-behavior.d.ts +1 -1
  34. package/dist/causal-consolidation.d.ts +1 -1
  35. package/dist/causal-consolidation.js +8 -8
  36. package/dist/causal-trajectory-graph.d.ts +1 -1
  37. package/dist/{chunk-OWHERGF2.js → chunk-2NLLXCJG.js} +2 -2
  38. package/dist/{chunk-BIA7ELIB.js → chunk-36SEDFAU.js} +3 -3
  39. package/dist/{chunk-UQSVUEN2.js → chunk-37436GML.js} +2 -2
  40. package/dist/{chunk-DVRHLN2O.js → chunk-3RCBIOOJ.js} +18 -2
  41. package/dist/{chunk-DVRHLN2O.js.map → chunk-3RCBIOOJ.js.map} +1 -1
  42. package/dist/{chunk-RRHHQYJS.js → chunk-3TSTGI6F.js} +2 -2
  43. package/dist/{chunk-5OOA7SGX.js → chunk-4ESAR77N.js} +2 -2
  44. package/dist/{chunk-FO3HO52S.js → chunk-4H4F3BFL.js} +2 -2
  45. package/dist/{chunk-BEUCACTV.js → chunk-4NOI577P.js} +2 -2
  46. package/dist/{chunk-BBIE7W52.js → chunk-4NR2PDZ5.js} +2 -2
  47. package/dist/{chunk-WGTWGDR5.js → chunk-76NHR6ZL.js} +5 -5
  48. package/dist/{chunk-PKMQK2WY.js → chunk-773DPI6T.js} +4 -4
  49. package/dist/{chunk-MQVKHFIU.js → chunk-7RLCCNHU.js} +8 -8
  50. package/dist/{chunk-XGTBNVIU.js → chunk-AAD2ZS6P.js} +11 -1
  51. package/dist/chunk-AAD2ZS6P.js.map +1 -0
  52. package/dist/{chunk-EZNDNMRH.js → chunk-AQLOP5F4.js} +2 -2
  53. package/dist/{chunk-UNNDFYUX.js → chunk-ARV3AUOM.js} +5 -5
  54. package/dist/{chunk-NPL7VDM6.js → chunk-ASKXIAHD.js} +2 -2
  55. package/dist/{chunk-TQQH2FTX.js → chunk-BQCHEV7O.js} +106 -15
  56. package/dist/{chunk-TQQH2FTX.js.map → chunk-BQCHEV7O.js.map} +1 -1
  57. package/dist/{chunk-ZA2S2VLL.js → chunk-C2PG7IEH.js} +5 -2
  58. package/dist/chunk-C2PG7IEH.js.map +1 -0
  59. package/dist/{chunk-K4ZNARDO.js → chunk-CBWI22JN.js} +2 -2
  60. package/dist/{chunk-ZOHMPFOB.js → chunk-CKFLLAIS.js} +3 -3
  61. package/dist/{chunk-JPUT5NGH.js → chunk-DMQWV6SQ.js} +2 -2
  62. package/dist/{chunk-F3XZN24Z.js → chunk-DRUDC2UX.js} +2 -2
  63. package/dist/{chunk-KJR66XOM.js → chunk-EY6MDMHK.js} +2 -2
  64. package/dist/{chunk-3TUT4PWB.js → chunk-FVZ2ISIX.js} +2 -2
  65. package/dist/{chunk-C4X4QGIO.js → chunk-G6NKALRN.js} +5 -2
  66. package/dist/chunk-G6NKALRN.js.map +1 -0
  67. package/dist/{chunk-6SQQOJ4U.js → chunk-GKRLPHPU.js} +2 -2
  68. package/dist/{chunk-PLG6JHQS.js → chunk-H5BWGCRZ.js} +43 -42
  69. package/dist/chunk-H5BWGCRZ.js.map +1 -0
  70. package/dist/{chunk-2NBP25BG.js → chunk-HGNOCPZX.js} +171 -79
  71. package/dist/chunk-HGNOCPZX.js.map +1 -0
  72. package/dist/{chunk-5NWYQUQC.js → chunk-HOHVIVDR.js} +2 -2
  73. package/dist/{chunk-DCKNMLEQ.js → chunk-I5REUWLH.js} +11 -5
  74. package/dist/chunk-I5REUWLH.js.map +1 -0
  75. package/dist/{chunk-6WAJQKBS.js → chunk-JYLFZWUX.js} +2 -2
  76. package/dist/{chunk-5W7CTZ2E.js → chunk-KQAFEZQX.js} +4 -4
  77. package/dist/{chunk-UKTDXTWX.js → chunk-M4AYVEWS.js} +7 -7
  78. package/dist/{chunk-YTZ3VGC7.js → chunk-M4V2JVSZ.js} +2 -2
  79. package/dist/{chunk-TA3Y6GEX.js → chunk-MLKWJOAJ.js} +2 -2
  80. package/dist/{chunk-DQXPJ6HD.js → chunk-O6YH5F47.js} +4 -4
  81. package/dist/{chunk-7AG54AF3.js → chunk-PUR2CKX2.js} +1 -1
  82. package/dist/chunk-PUR2CKX2.js.map +1 -0
  83. package/dist/{chunk-OAHOL3ZC.js → chunk-QNEUZIAH.js} +152 -62
  84. package/dist/chunk-QNEUZIAH.js.map +1 -0
  85. package/dist/{chunk-Q6D75LVH.js → chunk-UM5AWJNQ.js} +79 -17
  86. package/dist/chunk-UM5AWJNQ.js.map +1 -0
  87. package/dist/{chunk-53EEQYE5.js → chunk-UTHQPSRC.js} +8 -8
  88. package/dist/{chunk-BB57OZWC.js → chunk-UVPL565C.js} +2 -2
  89. package/dist/{chunk-6AFVIOVZ.js → chunk-V4GNSDFH.js} +2 -2
  90. package/dist/{chunk-EHISUJFN.js → chunk-WAS3NV46.js} +6 -3
  91. package/dist/chunk-WAS3NV46.js.map +1 -0
  92. package/dist/{chunk-UVYI6VIX.js → chunk-X7Y7WX73.js} +1 -1
  93. package/dist/{chunk-V7CMKMHM.js → chunk-Y4YD5TYW.js} +3 -3
  94. package/dist/{chunk-7NS7AWMZ.js → chunk-YGIQORJ3.js} +3 -3
  95. package/dist/{chunk-MDBZTH7N.js → chunk-YKBHOBJK.js} +44 -10
  96. package/dist/chunk-YKBHOBJK.js.map +1 -0
  97. package/dist/{chunk-3MS3QVX6.js → chunk-YOLZS7NY.js} +2 -2
  98. package/dist/{chunk-6OIDDKAA.js → chunk-YTU7WKYN.js} +35 -9
  99. package/dist/chunk-YTU7WKYN.js.map +1 -0
  100. package/dist/{cli-IOlDHSBT.d.ts → cli-BLME420h.d.ts} +3 -3
  101. package/dist/cli.d.ts +6 -6
  102. package/dist/cli.js +49 -49
  103. package/dist/compat/codex-cli-fallback.d.ts +1 -0
  104. package/dist/compat/codex-cli-fallback.js +22 -0
  105. package/dist/compat/codex-cli-fallback.js.map +1 -0
  106. package/dist/compat/codex-thread-key.d.ts +1 -0
  107. package/dist/compat/codex-thread-key.js +18 -0
  108. package/dist/compat/codex-thread-key.js.map +1 -0
  109. package/dist/compounding/engine.d.ts +1 -1
  110. package/dist/compounding/engine.js +7 -7
  111. package/dist/compounding/preference-consolidator.d.ts +1 -1
  112. package/dist/compression-optimizer.d.ts +1 -1
  113. package/dist/config.d.ts +1 -1
  114. package/dist/config.js +3 -3
  115. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  116. package/dist/connectors/codex-materialize-runner.js +7 -7
  117. package/dist/connectors/codex-materialize.d.ts +1 -1
  118. package/dist/connectors/index.d.ts +1 -1
  119. package/dist/connectors/index.js +7 -7
  120. package/dist/consolidation-provenance-check.d.ts +1 -1
  121. package/dist/consolidation-undo.d.ts +1 -1
  122. package/dist/contradiction/index.d.ts +1 -1
  123. package/dist/conversation-index/backend.d.ts +1 -1
  124. package/dist/conversation-index/chunker.d.ts +1 -1
  125. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  126. package/dist/conversation-index/indexer.d.ts +1 -1
  127. package/dist/conversation-index/search.d.ts +1 -1
  128. package/dist/day-summary.d.ts +1 -1
  129. package/dist/delinearize.d.ts +1 -1
  130. package/dist/direct-answer-wiring.d.ts +1 -1
  131. package/dist/direct-answer.d.ts +1 -1
  132. package/dist/embedding-fallback.d.ts +1 -1
  133. package/dist/embedding-fallback.js +2 -1
  134. package/dist/enrichment/index.d.ts +1 -1
  135. package/dist/entity-retrieval.d.ts +1 -1
  136. package/dist/entity-retrieval.js +7 -7
  137. package/dist/entity-schema.d.ts +1 -1
  138. package/dist/explicit-capture.d.ts +4 -4
  139. package/dist/extraction-faithfulness.d.ts +1 -1
  140. package/dist/extraction-faithfulness.js +2 -2
  141. package/dist/extraction-judge-telemetry.d.ts +1 -1
  142. package/dist/extraction-judge-training.d.ts +1 -1
  143. package/dist/extraction-judge.d.ts +1 -1
  144. package/dist/extraction.d.ts +1 -1
  145. package/dist/extraction.js +3 -2
  146. package/dist/faithfulness-config.d.ts +1 -0
  147. package/dist/faithfulness-config.js +1 -1
  148. package/dist/fallback-llm.d.ts +1 -1
  149. package/dist/{first-start-migration-PG5HBC3K.js → first-start-migration-22LPFHD5.js} +6 -2
  150. package/dist/first-start-migration-22LPFHD5.js.map +1 -0
  151. package/dist/graph-dashboard-diff.d.ts +1 -1
  152. package/dist/graph-dashboard-key.d.ts +1 -1
  153. package/dist/graph-dashboard-parser.d.ts +1 -1
  154. package/dist/graph-edge-reinforcement.d.ts +1 -1
  155. package/dist/graph-snapshot.d.ts +1 -1
  156. package/dist/graph.d.ts +1 -1
  157. package/dist/identity-continuity.d.ts +1 -1
  158. package/dist/importance.d.ts +1 -1
  159. package/dist/index.d.ts +9 -9
  160. package/dist/index.js +64 -64
  161. package/dist/intent.d.ts +1 -1
  162. package/dist/lcm/engine.d.ts +1 -1
  163. package/dist/lcm/engine.js +2 -2
  164. package/dist/lcm/index.d.ts +1 -1
  165. package/dist/lcm/index.js +7 -7
  166. package/dist/lcm/tools.d.ts +1 -1
  167. package/dist/lifecycle.d.ts +1 -1
  168. package/dist/live-connectors-runner.d.ts +1 -1
  169. package/dist/local-llm.d.ts +1 -1
  170. package/dist/local-model-endpoint.d.ts +9 -4
  171. package/dist/local-model-endpoint.js +1 -1
  172. package/dist/maintenance/memory-governance.d.ts +1 -1
  173. package/dist/maintenance/memory-governance.js +7 -7
  174. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  175. package/dist/maintenance/rebuild-memory-projection.js +8 -8
  176. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  177. package/dist/memory-action-policy.d.ts +1 -1
  178. package/dist/memory-cache.d.ts +1 -1
  179. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  180. package/dist/memory-projection-store.d.ts +1 -1
  181. package/dist/memory-provenance.d.ts +1 -1
  182. package/dist/memory-worth-outcomes.d.ts +1 -1
  183. package/dist/models-json.d.ts +1 -1
  184. package/dist/namespaces/migrate.d.ts +2 -2
  185. package/dist/namespaces/migrate.js +14 -13
  186. package/dist/namespaces/principal.d.ts +1 -1
  187. package/dist/namespaces/search.d.ts +1 -1
  188. package/dist/namespaces/search.js +6 -5
  189. package/dist/namespaces/storage.d.ts +2 -2
  190. package/dist/namespaces/storage.js +7 -7
  191. package/dist/native-knowledge.d.ts +1 -1
  192. package/dist/operator-toolkit.d.ts +1 -1
  193. package/dist/operator-toolkit.js +20 -20
  194. package/dist/orchestration/maintenance.d.ts +2 -2
  195. package/dist/orchestration/maintenance.js +9 -9
  196. package/dist/{orchestrator-C_OMSIMZ.d.ts → orchestrator-DqPAz8ot.d.ts} +3 -3
  197. package/dist/orchestrator.d.ts +4 -4
  198. package/dist/orchestrator.js +33 -33
  199. package/dist/patterns-cli.d.ts +1 -1
  200. package/dist/policy-runtime.d.ts +1 -1
  201. package/dist/provenance.d.ts +1 -1
  202. package/dist/provenance.js +1 -1
  203. package/dist/qmd-recall-cache.d.ts +1 -1
  204. package/dist/qmd.d.ts +1 -1
  205. package/dist/recall-disclosure-escalation.d.ts +1 -1
  206. package/dist/recall-explain-renderer.d.ts +1 -1
  207. package/dist/recall-explain-renderer.js +3 -3
  208. package/dist/recall-planner-llm.d.ts +1 -1
  209. package/dist/recall-state.d.ts +1 -1
  210. package/dist/recall-tag-filter.d.ts +1 -1
  211. package/dist/recall-xray-cli.d.ts +1 -1
  212. package/dist/recall-xray-cli.js +4 -4
  213. package/dist/recall-xray-renderer.d.ts +1 -1
  214. package/dist/recall-xray-renderer.js +3 -3
  215. package/dist/recall-xray.d.ts +1 -1
  216. package/dist/recall-xray.js +2 -2
  217. package/dist/resolve-auth-token.d.ts +1 -1
  218. package/dist/resume-bundles.js +4 -4
  219. package/dist/retrieval-agents.d.ts +1 -1
  220. package/dist/retrieval-tiers.d.ts +1 -1
  221. package/dist/routing/engine.d.ts +1 -1
  222. package/dist/routing/store.d.ts +1 -1
  223. package/dist/search/embed-helper.d.ts +1 -1
  224. package/dist/search/embed-helper.js +2 -1
  225. package/dist/search/factory.d.ts +1 -1
  226. package/dist/search/factory.js +5 -4
  227. package/dist/search/index.d.ts +1 -1
  228. package/dist/search/index.js +9 -8
  229. package/dist/search/lancedb-backend.d.ts +1 -1
  230. package/dist/search/meilisearch-backend.d.ts +1 -1
  231. package/dist/search/noop-backend.d.ts +1 -1
  232. package/dist/search/orama-backend.d.ts +1 -1
  233. package/dist/search/port.d.ts +1 -1
  234. package/dist/search/remote-backend.d.ts +1 -1
  235. package/dist/{semantic-consolidation-B8LiYtvx.d.ts → semantic-consolidation-Cl15ksZy.d.ts} +1 -1
  236. package/dist/semantic-consolidation.d.ts +2 -2
  237. package/dist/semantic-consolidation.js +8 -8
  238. package/dist/semantic-rule-promotion.js +7 -7
  239. package/dist/semantic-rule-verifier.d.ts +1 -1
  240. package/dist/semantic-rule-verifier.js +7 -7
  241. package/dist/session-observer-bands.d.ts +1 -1
  242. package/dist/session-observer-state.d.ts +1 -1
  243. package/dist/shared-context/manager.d.ts +1 -1
  244. package/dist/signal.d.ts +1 -1
  245. package/dist/storage.d.ts +1 -1
  246. package/dist/storage.js +6 -6
  247. package/dist/summarizer.d.ts +1 -1
  248. package/dist/summary-snapshot.d.ts +1 -1
  249. package/dist/temporal-supersession.d.ts +1 -1
  250. package/dist/temporal-validity.d.ts +1 -1
  251. package/dist/threading.d.ts +1 -1
  252. package/dist/tier-migration.d.ts +1 -1
  253. package/dist/tier-routing.d.ts +1 -1
  254. package/dist/topics.d.ts +1 -1
  255. package/dist/transcript.d.ts +1 -1
  256. package/dist/transfer/backup.js +2 -2
  257. package/dist/transfer/capsule-export.js +2 -2
  258. package/dist/transfer/capsule-import.js +2 -2
  259. package/dist/trust-score-stage.d.ts +1 -1
  260. package/dist/trust-score.d.ts +1 -1
  261. package/dist/{types-DmTaqob2.d.ts → types-ChQb9D6s.d.ts} +8 -0
  262. package/dist/types.d.ts +1 -1
  263. package/dist/types.js +1 -1
  264. package/dist/utility-runtime.d.ts +1 -1
  265. package/dist/verified-recall.js +7 -7
  266. package/package.json +26 -2
  267. package/src/access-http.ts +68 -1
  268. package/src/access-service.ts +2 -1
  269. package/src/admin/admin-surfaces.test.ts +289 -0
  270. package/src/admin/admin-surfaces.ts +138 -7
  271. package/src/bitemporal-dedup-backfill.test.ts +294 -0
  272. package/src/capabilities.test.ts +155 -0
  273. package/src/capabilities.ts +91 -0
  274. package/src/chat/chat-engine.test.ts +104 -1
  275. package/src/chat/chat-http.test.ts +119 -1
  276. package/src/chat/chat-http.ts +252 -97
  277. package/src/chat/chat-session.ts +166 -7
  278. package/src/cli.ts +3 -2
  279. package/src/compat/codex-cli-fallback.ts +23 -0
  280. package/src/compat/codex-subpath-aliases.test.ts +93 -0
  281. package/src/compat/codex-thread-key.ts +23 -0
  282. package/src/embedding-fallback.ts +2 -1
  283. package/src/extraction-faithfulness.test.ts +240 -0
  284. package/src/extraction-faithfulness.ts +58 -6
  285. package/src/extraction.ts +7 -3
  286. package/src/faithfulness-config.ts +16 -1
  287. package/src/local-model-endpoint.ts +24 -3
  288. package/src/maintenance/first-start-migration.ts +3 -1
  289. package/src/orchestrator.ts +238 -34
  290. package/src/provenance-extraction.test.ts +266 -0
  291. package/src/provenance.ts +66 -16
  292. package/src/search/embed-helper.ts +3 -2
  293. package/src/types.ts +8 -0
  294. package/dist/chunk-2NBP25BG.js.map +0 -1
  295. package/dist/chunk-6OIDDKAA.js.map +0 -1
  296. package/dist/chunk-7AG54AF3.js.map +0 -1
  297. package/dist/chunk-C4X4QGIO.js.map +0 -1
  298. package/dist/chunk-DCKNMLEQ.js.map +0 -1
  299. package/dist/chunk-EHISUJFN.js.map +0 -1
  300. package/dist/chunk-MDBZTH7N.js.map +0 -1
  301. package/dist/chunk-OAHOL3ZC.js.map +0 -1
  302. package/dist/chunk-PLG6JHQS.js.map +0 -1
  303. package/dist/chunk-Q6D75LVH.js.map +0 -1
  304. package/dist/chunk-XGTBNVIU.js.map +0 -1
  305. package/dist/chunk-ZA2S2VLL.js.map +0 -1
  306. package/dist/first-start-migration-PG5HBC3K.js.map +0 -1
  307. /package/dist/{capsule-crypto-GWVG7LGC.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
  308. /package/dist/{chunk-OWHERGF2.js.map → chunk-2NLLXCJG.js.map} +0 -0
  309. /package/dist/{chunk-BIA7ELIB.js.map → chunk-36SEDFAU.js.map} +0 -0
  310. /package/dist/{chunk-UQSVUEN2.js.map → chunk-37436GML.js.map} +0 -0
  311. /package/dist/{chunk-RRHHQYJS.js.map → chunk-3TSTGI6F.js.map} +0 -0
  312. /package/dist/{chunk-5OOA7SGX.js.map → chunk-4ESAR77N.js.map} +0 -0
  313. /package/dist/{chunk-FO3HO52S.js.map → chunk-4H4F3BFL.js.map} +0 -0
  314. /package/dist/{chunk-BEUCACTV.js.map → chunk-4NOI577P.js.map} +0 -0
  315. /package/dist/{chunk-BBIE7W52.js.map → chunk-4NR2PDZ5.js.map} +0 -0
  316. /package/dist/{chunk-WGTWGDR5.js.map → chunk-76NHR6ZL.js.map} +0 -0
  317. /package/dist/{chunk-PKMQK2WY.js.map → chunk-773DPI6T.js.map} +0 -0
  318. /package/dist/{chunk-MQVKHFIU.js.map → chunk-7RLCCNHU.js.map} +0 -0
  319. /package/dist/{chunk-EZNDNMRH.js.map → chunk-AQLOP5F4.js.map} +0 -0
  320. /package/dist/{chunk-UNNDFYUX.js.map → chunk-ARV3AUOM.js.map} +0 -0
  321. /package/dist/{chunk-NPL7VDM6.js.map → chunk-ASKXIAHD.js.map} +0 -0
  322. /package/dist/{chunk-K4ZNARDO.js.map → chunk-CBWI22JN.js.map} +0 -0
  323. /package/dist/{chunk-ZOHMPFOB.js.map → chunk-CKFLLAIS.js.map} +0 -0
  324. /package/dist/{chunk-JPUT5NGH.js.map → chunk-DMQWV6SQ.js.map} +0 -0
  325. /package/dist/{chunk-F3XZN24Z.js.map → chunk-DRUDC2UX.js.map} +0 -0
  326. /package/dist/{chunk-KJR66XOM.js.map → chunk-EY6MDMHK.js.map} +0 -0
  327. /package/dist/{chunk-3TUT4PWB.js.map → chunk-FVZ2ISIX.js.map} +0 -0
  328. /package/dist/{chunk-6SQQOJ4U.js.map → chunk-GKRLPHPU.js.map} +0 -0
  329. /package/dist/{chunk-5NWYQUQC.js.map → chunk-HOHVIVDR.js.map} +0 -0
  330. /package/dist/{chunk-6WAJQKBS.js.map → chunk-JYLFZWUX.js.map} +0 -0
  331. /package/dist/{chunk-5W7CTZ2E.js.map → chunk-KQAFEZQX.js.map} +0 -0
  332. /package/dist/{chunk-UKTDXTWX.js.map → chunk-M4AYVEWS.js.map} +0 -0
  333. /package/dist/{chunk-YTZ3VGC7.js.map → chunk-M4V2JVSZ.js.map} +0 -0
  334. /package/dist/{chunk-TA3Y6GEX.js.map → chunk-MLKWJOAJ.js.map} +0 -0
  335. /package/dist/{chunk-DQXPJ6HD.js.map → chunk-O6YH5F47.js.map} +0 -0
  336. /package/dist/{chunk-53EEQYE5.js.map → chunk-UTHQPSRC.js.map} +0 -0
  337. /package/dist/{chunk-BB57OZWC.js.map → chunk-UVPL565C.js.map} +0 -0
  338. /package/dist/{chunk-6AFVIOVZ.js.map → chunk-V4GNSDFH.js.map} +0 -0
  339. /package/dist/{chunk-UVYI6VIX.js.map → chunk-X7Y7WX73.js.map} +0 -0
  340. /package/dist/{chunk-V7CMKMHM.js.map → chunk-Y4YD5TYW.js.map} +0 -0
  341. /package/dist/{chunk-7NS7AWMZ.js.map → chunk-YGIQORJ3.js.map} +0 -0
  342. /package/dist/{chunk-3MS3QVX6.js.map → chunk-YOLZS7NY.js.map} +0 -0
@@ -17,7 +17,7 @@ import { test } from "node:test";
17
17
  import type { EngramAccessService } from "../access-service.js";
18
18
  import { parseConfig } from "../config.js";
19
19
  import { handleChatMessage, handleChatEventsSSE } from "./chat-http.js";
20
- import { createChatSession, appendTranscriptEntry, chatSessionFile } from "./chat-session.js";
20
+ import { createChatSession, loadChatSession, appendTranscriptEntry, chatSessionFile } from "./chat-session.js";
21
21
  import { DEFAULT_CHAT_CONFIG } from "./chat-config.js";
22
22
 
23
23
  // Minimal mock res capturing writes. All tracking fields live on one
@@ -195,3 +195,121 @@ test("HTTP chat/events SSE: 403 for a session owned by another principal", async
195
195
  assert.equal(m.status, 403);
196
196
  });
197
197
  });
198
+
199
+
200
+ test("HTTP chat/events SSE: pushes new transcript entries live (issue #1687)", async () => {
201
+ await withTempDir(async (memoryDir) => {
202
+ const session = await createChatSession(memoryDir, { principal: "alice" });
203
+ await appendTranscriptEntry(memoryDir, session.id, { role: "user", content: "first" });
204
+
205
+ const service = makeService({
206
+ memoryDir,
207
+ configRef: parseConfig({ memoryDir, chat: { ...DEFAULT_CHAT_CONFIG, enabled: true } }),
208
+ });
209
+ const m = mockRes();
210
+ const req = mockReq();
211
+ await handleChatEventsSSE(req as never, m as never, session.id, { service, config: service.configRef!.chat, memoryDir }, "alice");
212
+ assert.equal(m.status, 200);
213
+ assert.equal(m.headers["content-type"], "text/event-stream");
214
+ // The stream must advertise a retry directive so reconnects are safe.
215
+ assert.ok(m.body.includes("retry: 5000"), "SSE must emit a retry: directive");
216
+
217
+ const initialFrames = m.body.split("\n\n").filter((f) => f.startsWith("data:"));
218
+
219
+ // Append a NEW entry AFTER the client connected — it must be pushed live
220
+ // to the open SSE connection via the per-session pub/sub.
221
+ await appendTranscriptEntry(memoryDir, session.id, { role: "assistant", content: "pushed-live" });
222
+
223
+ const allFrames = m.body.split("\n\n").filter((f) => f.startsWith("data:"));
224
+ assert.ok(allFrames.length > initialFrames.length, "expected a pushed frame after append");
225
+ assert.ok(m.body.includes("pushed-live"), "the appended entry must reach the live SSE stream");
226
+ // The pushed frame must be well-formed SSE (data: <json>\n\n).
227
+ assert.ok(m.body.includes("data: {"), "frames must be data: <json> framed");
228
+ req.emit("close");
229
+ });
230
+ });
231
+
232
+ test("HTTP chat/message: concurrent identical requests are deduplicated (issue #1687)", async () => {
233
+ await withTempDir(async (memoryDir) => {
234
+ const session = await createChatSession(memoryDir, { principal: "alice" });
235
+ let llmCalls = 0;
236
+ const service = makeService({
237
+ memoryDir,
238
+ configRef: parseConfig({ memoryDir, chat: { ...DEFAULT_CHAT_CONFIG, enabled: true } }),
239
+ fallbackLlmRef: ({
240
+ chatCompletion: async () => {
241
+ llmCalls++;
242
+ // Simulate enough work that the second request lands while the
243
+ // first is still in-flight (the dedup window).
244
+ await new Promise((r) => setTimeout(r, 30));
245
+ return { content: "shared-reply", modelUsed: "test-model" };
246
+ },
247
+ }) as never,
248
+ });
249
+ const opts = { service, config: service.configRef!.chat, memoryDir };
250
+ const m1 = mockRes();
251
+ const m2 = mockRes();
252
+ await Promise.all([
253
+ handleChatMessage(mockReq() as never, m1 as never, { message: "duplicate", chatSessionId: session.id }, opts, "alice"),
254
+ handleChatMessage(mockReq() as never, m2 as never, { message: "duplicate", chatSessionId: session.id }, opts, "alice"),
255
+ ]);
256
+ // Exactly ONE LLM call despite two concurrent identical requests.
257
+ assert.equal(llmCalls, 1, "a concurrent duplicate must not re-process");
258
+ // Both callers receive the (same) reply.
259
+ assert.equal(m1.status, 200);
260
+ assert.equal(m2.status, 200);
261
+ assert.ok(m1.body.includes("shared-reply"));
262
+ assert.ok(m2.body.includes("shared-reply"));
263
+ // The transcript must contain exactly one user "duplicate" line — the
264
+ // duplicate did not double-append.
265
+ const loaded = await loadChatSession(memoryDir, session.id);
266
+ const userDups = loaded!.transcript.filter((e) => e.role === "user" && e.content === "duplicate");
267
+ assert.equal(userDups.length, 1, "duplicate request must not append a second user transcript line");
268
+ });
269
+ });
270
+
271
+ test("HTTP chat/message: distinct messages are NOT deduplicated (issue #1687)", async () => {
272
+ await withTempDir(async (memoryDir) => {
273
+ const session = await createChatSession(memoryDir, { principal: "alice" });
274
+ let llmCalls = 0;
275
+ const service = makeService({
276
+ memoryDir,
277
+ configRef: parseConfig({ memoryDir, chat: { ...DEFAULT_CHAT_CONFIG, enabled: true } }),
278
+ fallbackLlmRef: ({
279
+ chatCompletion: async () => {
280
+ llmCalls++;
281
+ await new Promise((r) => setTimeout(r, 20));
282
+ return { content: "reply", modelUsed: "test-model" };
283
+ },
284
+ }) as never,
285
+ });
286
+ const opts = { service, config: service.configRef!.chat, memoryDir };
287
+ await Promise.all([
288
+ handleChatMessage(mockReq() as never, mockRes() as never, { message: "alpha", chatSessionId: session.id }, opts, "alice"),
289
+ handleChatMessage(mockReq() as never, mockRes() as never, { message: "beta", chatSessionId: session.id }, opts, "alice"),
290
+ ]);
291
+ // Distinct messages both process independently.
292
+ assert.equal(llmCalls, 2, "distinct messages must not be coalesced");
293
+ });
294
+ });
295
+
296
+ test("HTTP chat/events SSE: early disconnect during load bails cleanly — no write to ended response, no leak (issue #1687 review)", async () => {
297
+ await withTempDir(async (memoryDir) => {
298
+ const session = await createChatSession(memoryDir, { principal: "alice" });
299
+ const service = makeService({
300
+ memoryDir,
301
+ configRef: parseConfig({ memoryDir, chat: { ...DEFAULT_CHAT_CONFIG, enabled: true } }),
302
+ });
303
+ const m = mockRes();
304
+ const req = mockReq();
305
+ // Emit close as a microtask so it fires while the handler is awaiting
306
+ // loadChatSession (before writeHead). The handler must bail out without
307
+ // writing to the ended response and without setting up a heartbeat.
308
+ queueMicrotask(() => req.emit("close"));
309
+ // Resolving without throwing is the core contract.
310
+ await handleChatEventsSSE(req as never, m as never, session.id, { service, config: service.configRef!.chat, memoryDir }, "alice");
311
+ // writeHead was skipped (status stays 0) because the handler returned at
312
+ // the `if (closed) return` guard before any SSE framing.
313
+ assert.equal(m.status, 0, "handler must not writeHead after an early close");
314
+ });
315
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Chat HTTP handler (issue #1583 PR 3).
2
+ * Chat HTTP handler (issue #1583 PR 3; deferred items #1685 / #1687).
3
3
  *
4
4
  * Implements the HTTP backend for the admin console Chat pane:
5
5
  * - POST /engram/v1/chat/message — send a message, get a reply
@@ -9,41 +9,133 @@
9
9
  * Per-session isolation: every request verifies the session's principal
10
10
  * matches the caller (rule 42/47).
11
11
  *
12
+ * Deferred items wired here (issues #1685 / #1687):
13
+ * - The HTTP message handler delegates to the shared `processChatMessage`
14
+ * (Thread 18) so session/engine/transcript construction is identical to
15
+ * the MCP surface, instead of re-implementing it.
16
+ * - Concurrent identical POSTs are coalesced by an in-flight dedup keyed on
17
+ * `memoryDir + chatSessionId + principal + message` so a double-submit
18
+ * processes exactly once (issue #1687 Thread 18 / #1685). The memoryDir
19
+ * scopes the key per store (AGENTS.md State Scoping); an absent principal
20
+ * is encoded distinctly so it can never alias a real principal string.
21
+ * - The SSE stream subscribes to live transcript appends and pushes new
22
+ * entries to connected clients (issue #1685 item 2 / #1687 Thread 8).
23
+ * Subscription is established before the transcript snapshot is read so no
24
+ * concurrent append is missed (cursor Bugbot race fix); the burst drains
25
+ * the buffer only after switching to live mode so no buffered entry is
26
+ * dropped. The disconnect cleanup is registered immediately and guarded
27
+ * by a `closed` flag so an early close cannot leak the heartbeat or write
28
+ * to an ended response.
29
+ *
12
30
  * This module is imported by access-http.ts with thin route registration —
13
31
  * the god-file ratchet (#1520) tracks access-http.ts LOC.
14
32
  */
15
33
 
16
34
  import type { IncomingMessage, ServerResponse } from "node:http";
35
+ import { createHash } from "node:crypto";
17
36
 
18
37
  import type { EngramAccessService } from "../access-service.js";
19
- import type { ChatConfig } from "./chat-types.js";
20
- import type { ChatTurnResult } from "./chat-types.js";
21
- import { ChatEngine } from "./chat-engine.js";
22
- import { isConfirmationMessage } from "./chat-engine.js";
23
- import { createProductionChatLlmAdapter } from "./chat-llm.js";
24
- import { createChatExecutor } from "./chat-executor.js";
38
+ import type { ChatConfig, ChatTranscriptEntry, ChatTurnResult } from "./chat-types.js";
39
+ import { processChatMessage } from "./chat-factory.js";
25
40
  import {
26
- createChatSession,
27
41
  loadChatSession,
28
- appendTranscriptEntry,
29
42
  sessionBelongsToPrincipal,
30
- markPendingPlan,
31
- markPlanResolved,
32
- markPendingPromotion,
33
- markPromotionResolved,
43
+ subscribeChatTranscript,
34
44
  } from "./chat-session.js";
35
45
 
36
46
  export interface ChatHttpHandlerOptions {
37
47
  service: EngramAccessService;
38
48
  config: ChatConfig | undefined;
39
49
  memoryDir: string;
50
+ /**
51
+ * Optional hook the host server supplies so the SSE handler can register
52
+ * its disconnect cleanup with the server's stop() set (cursor Medium
53
+ * review). Returns an unregister function the handler calls on normal
54
+ * client disconnect. When omitted, cleanup runs only on req 'close'.
55
+ */
56
+ registerSseCleanup?: (cleanup: () => void) => () => void;
40
57
  }
41
58
 
59
+ // ---------------------------------------------------------------------------
60
+ // In-flight request dedup (issue #1687 Thread 18 / #1685)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Pending chat-message promises keyed by
65
+ * `memoryDir:chatSessionId:principal:messageHash`. A concurrent identical
66
+ * POST joins the in-flight promise instead of re-processing (no second LLM
67
+ * call, no second transcript append). Entries are always removed via
68
+ * `finally` so the map cannot leak across requests.
69
+ *
70
+ * The memoryDir is part of the key so two Remnic stores in one process
71
+ * cannot share a promise (AGENTS.md State Scoping). Only requests targeting
72
+ * an existing `chatSessionId` are coalesced: when no session id is supplied
73
+ * each request legitimately mints its own session, so identical message
74
+ * bodies are genuinely distinct turns.
75
+ */
76
+ const chatInFlight = new Map<string, Promise<ChatTurnResult>>();
77
+
78
+ /**
79
+ * Encode the principal into a fingerprint component where an absent principal
80
+ * occupies a distinct namespace from any real principal string (control-byte
81
+ * prefix), so a no-principal request can never coalesce with a request whose
82
+ * principal is literally `"_"` (or any other string) and reuse its authorized
83
+ * promise (codex P2 review thread).
84
+ */
85
+ function principalFingerprintComponent(principal: string | undefined): string {
86
+ return principal === undefined ? "\x00absent" : `\x01${principal}`;
87
+ }
88
+
89
+ function chatDedupFingerprint(memoryDir: string, chatSessionId: string, principal: string | undefined, message: string): string {
90
+ // sha256 keeps the key bounded for long messages; the principal component
91
+ // is distinct per principal (incl. absent) so two callers targeting the
92
+ // same session id can never share a result — the wrong principal still
93
+ // independently hits access_denied inside processChatMessage.
94
+ const messageHash = createHash("sha256").update(message, "utf8").digest("hex").slice(0, 32);
95
+ return `${memoryDir}:${chatSessionId}:${principalFingerprintComponent(principal)}:${messageHash}`;
96
+ }
97
+
98
+ /**
99
+ * Run `processChatMessage` with concurrent-identical-request coalescing.
100
+ * Distinct messages (or distinct sessions / principals / stores) never collide.
101
+ */
102
+ function processChatMessageDedup(opts: {
103
+ service: EngramAccessService;
104
+ config: ChatConfig | undefined;
105
+ memoryDir: string;
106
+ message: string;
107
+ chatSessionId?: string;
108
+ principal?: string;
109
+ }): Promise<ChatTurnResult> {
110
+ // No session id → no coalescing (each request creates its own session).
111
+ if (!opts.chatSessionId) {
112
+ return processChatMessage(opts);
113
+ }
114
+ const fp = chatDedupFingerprint(opts.memoryDir, opts.chatSessionId, opts.principal, opts.message);
115
+ const existing = chatInFlight.get(fp);
116
+ if (existing) return existing;
117
+ const pending = processChatMessage(opts).finally(() => {
118
+ chatInFlight.delete(fp);
119
+ });
120
+ chatInFlight.set(fp, pending);
121
+ return pending;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // POST /engram/v1/chat/message
126
+ // ---------------------------------------------------------------------------
127
+
42
128
  /**
43
129
  * Handle POST /engram/v1/chat/message.
44
130
  *
45
131
  * Body: `{ message: string, chatSessionId?: string }`
46
132
  * Response: `{ reply: string, chatSessionId: string, pendingPlan?: {...} }`
133
+ *
134
+ * Boundary checks (chat-disabled / missing message / no-LLM) run before
135
+ * delegation so each maps to a precise HTTP status without spinning up a
136
+ * session. Session/engine work is delegated to `processChatMessage`
137
+ * (Thread 18), wrapped in {@link processChatMessageDedup} for double-submit
138
+ * protection.
47
139
  */
48
140
  export async function handleChatMessage(
49
141
  req: IncomingMessage,
@@ -63,8 +155,7 @@ export async function handleChatMessage(
63
155
  return;
64
156
  }
65
157
 
66
- const llm = opts.service.fallbackLlmRef ?? opts.service.localLlmRef;
67
- if (!llm) {
158
+ if (!opts.service.fallbackLlmRef && !opts.service.localLlmRef) {
68
159
  respondJson(res, 503, {
69
160
  error: "No LLM model is available. Configure a local or cloud model.",
70
161
  code: "no_llm",
@@ -72,74 +163,42 @@ export async function handleChatMessage(
72
163
  return;
73
164
  }
74
165
 
75
- const adapter = createProductionChatLlmAdapter(llm as {
76
- chatCompletion(
77
- messages: Array<{ role: string; content: string }>,
78
- options?: { model?: string; signal?: AbortSignal },
79
- ): Promise<{ content: string } | null>;
80
- });
166
+ const chatSessionId = typeof body.chatSessionId === "string" ? body.chatSessionId : undefined;
81
167
 
82
- // Load or create session.
83
- const requestedSessionId = typeof body.chatSessionId === "string" ? body.chatSessionId : undefined;
84
- let session;
85
- if (requestedSessionId) {
86
- session = await loadChatSession(opts.memoryDir, requestedSessionId);
87
- if (!session) {
168
+ let result: ChatTurnResult;
169
+ try {
170
+ result = await processChatMessageDedup({
171
+ service: opts.service,
172
+ config: opts.config,
173
+ memoryDir: opts.memoryDir,
174
+ message,
175
+ ...(chatSessionId ? { chatSessionId } : {}),
176
+ ...(principal ? { principal } : {}),
177
+ });
178
+ } catch (err) {
179
+ // processChatMessage throws tagged errors for the session/LLM/engine
180
+ // paths; map each back to its HTTP status. Dedup coalescing means a
181
+ // concurrent duplicate shares the same rejection. A 'chat_session_expired'
182
+ // means the session was swept by the TTL cleanup mid-turn (codex P2) —
183
+ // tell the client to start a fresh session (410 Gone).
184
+ const code = err instanceof Error ? err.message : String(err);
185
+ if (code === "chat_session_not_found") {
88
186
  respondJson(res, 404, { error: "chat_session_not_found", code: "chat_session_not_found" });
89
- return;
90
- }
91
- if (!sessionBelongsToPrincipal(session, principal)) {
187
+ } else if (code === "access_denied") {
92
188
  respondJson(res, 403, { error: "access_denied", code: "access_denied" });
93
- return;
189
+ } else if (code === "chat_session_expired") {
190
+ respondJson(res, 410, { error: "chat_session_expired", code: "chat_session_expired" });
191
+ } else if (code === "chat_disabled") {
192
+ respondJson(res, 404, { error: "chat_disabled", code: "chat_disabled" });
193
+ } else if (code === "no_llm_available" || code === "engine_unavailable") {
194
+ respondJson(res, 503, {
195
+ error: "No LLM model is available. Configure a local or cloud model.",
196
+ code: "no_llm",
197
+ });
198
+ } else {
199
+ respondJson(res, 500, { error: "internal_error", code: "internal_error" });
94
200
  }
95
- } else {
96
- session = await createChatSession(opts.memoryDir, { principal });
97
- }
98
-
99
- const executor = createChatExecutor({
100
- service: opts.service,
101
- principal,
102
- ...(session.namespace ? { namespace: session.namespace } : {}),
103
- ...(session.sessionKey ? { sessionKey: session.sessionKey } : {}),
104
- });
105
-
106
- const engine = new ChatEngine({
107
- llm: adapter,
108
- executor,
109
- maxToolCallsPerTurn: opts.config.maxToolCallsPerTurn,
110
- ...(opts.config.model ? { model: opts.config.model } : {}),
111
- correctionAvailable: false,
112
- scopeInspectAvailable: false,
113
- });
114
-
115
- await appendTranscriptEntry(opts.memoryDir, session.id, {
116
- role: "user",
117
- content: message,
118
- });
119
-
120
- // Snapshot pending state BEFORE the turn so we only persist a marker when
121
- // state actually changed (Thread 15 — a normal turn must not clear an
122
- // active pending plan/promotion from an earlier turn).
123
- const priorPendingPlanId = session.pendingPlanId;
124
- const priorPendingPromotionId = session.pendingPromotionId;
125
-
126
- const result: ChatTurnResult = await engine.processMessage(message, session);
127
-
128
- await appendTranscriptEntry(opts.memoryDir, session.id, {
129
- role: "assistant",
130
- content: result.reply,
131
- });
132
-
133
- // Persist pending-plan/promotion state (append-only markers). Only append
134
- // when state actually changed.
135
- if (result.pendingPlan?.planId) {
136
- await markPendingPlan(opts.memoryDir, session.id, result.pendingPlan.planId);
137
- } else if (session.pendingPromotionId && !priorPendingPromotionId) {
138
- await markPendingPromotion(opts.memoryDir, session.id, session.pendingPromotionId);
139
- } else if (priorPendingPlanId && !session.pendingPlanId && !result.error) {
140
- await markPlanResolved(opts.memoryDir, session.id, priorPendingPlanId);
141
- } else if (priorPendingPromotionId && !session.pendingPromotionId && !result.error) {
142
- await markPromotionResolved(opts.memoryDir, session.id, priorPendingPromotionId);
201
+ return;
143
202
  }
144
203
 
145
204
  // Strip internal error details from the wire response (CodeQL — no stack
@@ -150,12 +209,30 @@ export async function handleChatMessage(
150
209
  respondJson(res, 200, wireResult);
151
210
  }
152
211
 
212
+ // ---------------------------------------------------------------------------
213
+ // GET /engram/v1/chat/events/:chatSessionId (SSE)
214
+ // ---------------------------------------------------------------------------
215
+
153
216
  /**
154
217
  * Handle GET /engram/v1/chat/events/:chatSessionId.
155
218
  *
156
- * SSE stream. Sends a heartbeat every 25s so proxies don't time out.
157
- * Currently streams the session transcript as an initial burst, then keeps
158
- * the connection open for future updates.
219
+ * SSE stream. Sends the existing transcript as an initial burst, then keeps
220
+ * the connection open and **pushes new transcript entries live** as they are
221
+ * appended (issue #1685 item 2 / #1687 Thread 8) via the per-session pub/sub
222
+ * in `chat-session.ts`. A heartbeat is emitted every 25 s so proxies do not
223
+ * time out. A `retry:` directive asks reconnecting clients to wait 5 s, and
224
+ * because the initial burst always replays the full transcript (entries carry
225
+ * a strictly-monotonic `seq`), clients dedupe on reconnect — the stream is
226
+ * reconnect-safe.
227
+ *
228
+ * Race-free ordering (cursor Bugbot): the subscription is established BEFORE
229
+ * the transcript snapshot is read; entries appended concurrently during the
230
+ * snapshot read are buffered, then the buffer is drained AFTER switching to
231
+ * live mode (so an append landing between the drain and the mode switch is
232
+ * never dropped — cursor review). Every write goes through a `closed`-guarded
233
+ * helper so a disconnect during the burst cannot write to an ended response
234
+ * (cursor/kilo review). The disconnect cleanup is registered immediately and
235
+ * guarded by `closed` so an early close cannot leak the heartbeat.
159
236
  */
160
237
  export async function handleChatEventsSSE(
161
238
  req: IncomingMessage,
@@ -169,12 +246,74 @@ export async function handleChatEventsSSE(
169
246
  return;
170
247
  }
171
248
 
249
+ // Liveness flag + handle, both closed over by the disconnect cleanup. The
250
+ // cleanup is idempotent (guarded by `closed`) so a repeat close is harmless.
251
+ let heartbeat: NodeJS.Timeout | undefined;
252
+ let closed = false;
253
+ // Every SSE write goes through this helper so a closed connection is never
254
+ // written to (cursor Low review). Returns false when the write was skipped.
255
+ const writeSse = (chunk: string): boolean => {
256
+ if (closed) return false;
257
+ try {
258
+ res.write(chunk);
259
+ return true;
260
+ } catch {
261
+ return false;
262
+ }
263
+ };
264
+
265
+ // Subscribe BEFORE reading the transcript so a concurrent append between
266
+ // the snapshot read and subscription is never missed (cursor Bugbot race).
267
+ // During the initial burst entries are buffered; after the burst the
268
+ // listener switches to writing straight to the response. A `closed` check
269
+ // short-circuits pushes to a connection that is already gone.
270
+ const pending: ChatTranscriptEntry[] = [];
271
+ let bursting = true;
272
+ const unsubscribe = subscribeChatTranscript(opts.memoryDir, chatSessionId, (entry) => {
273
+ if (closed) return;
274
+ if (bursting) {
275
+ pending.push(entry);
276
+ return;
277
+ }
278
+ writeSse(`data: ${JSON.stringify(entry)}\n\n`);
279
+ });
280
+
281
+ // Register disconnect cleanup IMMEDIATELY so a close during loadChatSession
282
+ // or the burst still releases the subscription (cursor Bugbot). The flag
283
+ // gates every later write so the handler never touches an ended response
284
+ // (kilo review thread) and never sets up a heartbeat for a dead socket
285
+ // (cursor review thread).
286
+ const doCleanup = (): void => {
287
+ closed = true;
288
+ clearInterval(heartbeat);
289
+ unsubscribe();
290
+ try { res.end(); } catch { /* already ended */ }
291
+ };
292
+ // Register with the host server's stop() cleanup set (cursor Medium) so an
293
+ // HTTP shutdown forcibly releases the subscription + heartbeat even when a
294
+ // lingering client never emits 'close'. Unregistered again on normal close.
295
+ const unregisterServerCleanup = opts.registerSseCleanup?.(doCleanup) ?? ((): void => {});
296
+ req.on("close", () => {
297
+ doCleanup();
298
+ unregisterServerCleanup();
299
+ });
300
+
172
301
  const session = await loadChatSession(opts.memoryDir, chatSessionId);
302
+ // If the client disconnected during the load, cleanup already ran — bail
303
+ // out before touching the response (prevents "headers after end").
304
+ if (closed) return;
173
305
  if (!session) {
306
+ // No SSE stream is established — release the server stop() registration
307
+ // alongside the subscription so sseCleanupFns does not retain a stale
308
+ // callback for this JSON error response (cursor Medium: error-path leak).
309
+ unsubscribe();
310
+ unregisterServerCleanup();
174
311
  respondJson(res, 404, { error: "chat_session_not_found", code: "chat_session_not_found" });
175
312
  return;
176
313
  }
177
314
  if (!sessionBelongsToPrincipal(session, principal)) {
315
+ unsubscribe();
316
+ unregisterServerCleanup();
178
317
  respondJson(res, 403, { error: "access_denied", code: "access_denied" });
179
318
  return;
180
319
  }
@@ -186,26 +325,42 @@ export async function handleChatEventsSSE(
186
325
  connection: "keep-alive",
187
326
  });
188
327
 
189
- // Send the transcript as an initial burst.
328
+ // Ask reconnecting clients to wait 5 s before retrying (reconnect-safe:
329
+ // the initial burst below replays the full transcript on rejoin).
330
+ writeSse("retry: 5000\n\n");
331
+
332
+ // Send the transcript snapshot as the initial burst, recording delivered
333
+ // seqs so buffered concurrent appends are not double-delivered.
334
+ const deliveredSeqs = new Set<number>();
190
335
  for (const entry of session.transcript) {
191
- res.write(`data: ${JSON.stringify(entry)}\n\n`);
336
+ writeSse(`data: ${JSON.stringify(entry)}\n\n`);
337
+ deliveredSeqs.add(entry.seq);
192
338
  }
193
339
 
194
- // Heartbeat. Unref'd so a lingering connection never blocks process
195
- // exit (rule 47 no shared mutable objects keep the loop alive).
196
- const heartbeat = setInterval(() => {
197
- try {
198
- res.write(`data: ${JSON.stringify({ type: "heartbeat" })}\n\n`);
199
- } catch {
200
- // Connection closed clearInterval below.
340
+ // Switch to live push FIRST, THEN drain buffered appends. Ordering matters:
341
+ // if an append lands between the drain and the mode switch it would be
342
+ // buffered (bursting still true) and never re-drained (cursor Medium). With
343
+ // live mode on, appends during the drain write straight through and the
344
+ // buffer is emptied once.
345
+ bursting = false;
346
+ for (const entry of pending) {
347
+ if (!deliveredSeqs.has(entry.seq)) {
348
+ writeSse(`data: ${JSON.stringify(entry)}\n\n`);
201
349
  }
202
- }, 25_000);
203
- heartbeat.unref();
350
+ }
204
351
 
205
- req.on("close", () => {
206
- clearInterval(heartbeat);
207
- try { res.end(); } catch { /* already ended */ }
208
- });
352
+ // Heartbeat. Unref'd so a lingering connection never blocks process
353
+ // exit (rule 47 — no shared mutable objects keep the loop alive). Cleared
354
+ // by the close handler above when the client disconnects. Guard against
355
+ // arming after a disconnect during the burst/drain: the close handler
356
+ // already ran doCleanup (clearing any prior handle and unregistering the
357
+ // server callback), so an interval armed here would be untracked and never
358
+ // cleared — bail out instead (codex P2).
359
+ if (closed) return;
360
+ heartbeat = setInterval(() => {
361
+ writeSse(`data: ${JSON.stringify({ type: "heartbeat" })}\n\n`);
362
+ }, 25_000);
363
+ heartbeat.unref?.();
209
364
  }
210
365
 
211
366
  // ---------------------------------------------------------------------------