@remnic/core 9.4.2 → 9.6.0

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 (379) hide show
  1. package/dist/access-admin-ops-surface.d.ts +6 -6
  2. package/dist/access-admin-ops-surface.js +28 -28
  3. package/dist/access-boundary.d.ts +27 -8
  4. package/dist/access-boundary.js +33 -29
  5. package/dist/access-cli.js +83 -81
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-http.d.ts +52 -6
  8. package/dist/access-http.js +40 -38
  9. package/dist/access-identity-continuity-surface.d.ts +5 -5
  10. package/dist/access-identity-continuity-surface.js +28 -28
  11. package/dist/access-lcm-surface.d.ts +6 -6
  12. package/dist/access-lcm-surface.js +28 -28
  13. package/dist/access-mcp.d.ts +16 -6
  14. package/dist/access-mcp.js +37 -35
  15. package/dist/access-observe-write-surface.d.ts +6 -6
  16. package/dist/access-observe-write-surface.js +28 -28
  17. package/dist/access-operations-batch.js +33 -32
  18. package/dist/access-operations.d.ts +9 -9
  19. package/dist/access-operations.js +35 -34
  20. package/dist/access-recall-surface.d.ts +6 -6
  21. package/dist/access-recall-surface.js +28 -28
  22. package/dist/access-schema.d.ts +4 -4
  23. package/dist/access-schema.js +4 -4
  24. package/dist/{access-service-m-2VO_Ig.d.ts → access-service-CXJaql3A.d.ts} +4 -4
  25. package/dist/access-service.d.ts +6 -6
  26. package/dist/access-service.js +28 -28
  27. package/dist/access-surface-catalog.d.ts +6 -6
  28. package/dist/access-surface-catalog.js +1 -0
  29. package/dist/access-surface-catalog.js.map +1 -1
  30. package/dist/access-token-capabilities.d.ts +174 -0
  31. package/dist/access-token-capabilities.js +33 -0
  32. package/dist/action-confidence.d.ts +1 -1
  33. package/dist/active-memory-bridge.d.ts +1 -1
  34. package/dist/active-recall.d.ts +1 -1
  35. package/dist/active-recall.js +2 -2
  36. package/dist/adapters/index.js +4 -4
  37. package/dist/adapters/registry.js +2 -2
  38. package/dist/{auto-sync-HMHQHUAG.js → auto-sync-4A5YBWYO.js} +4 -4
  39. package/dist/behavior-learner.d.ts +1 -1
  40. package/dist/behavior-signals.d.ts +1 -1
  41. package/dist/bootstrap.d.ts +5 -5
  42. package/dist/briefing.d.ts +2 -2
  43. package/dist/briefing.js +9 -9
  44. package/dist/buffer-surprise-report.d.ts +1 -1
  45. package/dist/buffer.d.ts +2 -2
  46. package/dist/calibration.d.ts +1 -1
  47. package/dist/capabilities.d.ts +1 -1
  48. package/dist/{capsule-crypto-CZJSLEFG.js → capsule-crypto-RAARDY3M.js} +2 -2
  49. package/dist/capsule-crypto-RAARDY3M.js.map +1 -0
  50. package/dist/{catalog-BxWokdC2.d.ts → catalog-C9cu67Ig.d.ts} +1 -1
  51. package/dist/causal-behavior.d.ts +1 -1
  52. package/dist/causal-behavior.js +5 -5
  53. package/dist/causal-chain.js +3 -3
  54. package/dist/causal-consolidation.d.ts +1 -1
  55. package/dist/causal-consolidation.js +13 -13
  56. package/dist/causal-retrieval.js +3 -3
  57. package/dist/causal-trajectory-graph.d.ts +1 -1
  58. package/dist/causal-trajectory.js +1 -1
  59. package/dist/{chunk-6ZIETXZ2.js → chunk-23JFRB73.js} +2 -2
  60. package/dist/{chunk-IC6QX5TC.js → chunk-3XIY7MDQ.js} +6 -6
  61. package/dist/{chunk-FZGGZAAU.js → chunk-4GNEDXDI.js} +146 -2
  62. package/dist/chunk-4GNEDXDI.js.map +1 -0
  63. package/dist/{chunk-LOR6ZM5R.js → chunk-4LV23CHK.js} +2 -2
  64. package/dist/{chunk-E2SPGGUI.js → chunk-4OKLXWDZ.js} +13 -6
  65. package/dist/chunk-4OKLXWDZ.js.map +1 -0
  66. package/dist/{chunk-AER6MT24.js → chunk-6SXVCD7W.js} +2 -2
  67. package/dist/{chunk-OHDJ34CC.js → chunk-72WG5QKN.js} +2 -2
  68. package/dist/{chunk-CZAZX7JP.js → chunk-7MW3CVLD.js} +4 -4
  69. package/dist/{chunk-CXLNZXG5.js → chunk-7XGSIUP5.js} +1 -1
  70. package/dist/{chunk-CZAA5N5S.js → chunk-AYZJID4S.js} +2 -2
  71. package/dist/{chunk-M7XQSUBB.js → chunk-B3ABVQ36.js} +116 -9
  72. package/dist/chunk-B3ABVQ36.js.map +1 -0
  73. package/dist/{chunk-6BISDEN6.js → chunk-BEM4D2QG.js} +14 -14
  74. package/dist/{chunk-ZPOROUTX.js → chunk-CYWA2CXR.js} +5 -5
  75. package/dist/{chunk-M5QKGHCR.js → chunk-CYWCKFCL.js} +5 -5
  76. package/dist/chunk-D7FOKIY5.js +173 -0
  77. package/dist/chunk-D7FOKIY5.js.map +1 -0
  78. package/dist/{chunk-4BFRBP45.js → chunk-DEG5ULFJ.js} +2 -2
  79. package/dist/{chunk-JLR3JSTL.js → chunk-DS7MM2ES.js} +3 -3
  80. package/dist/{chunk-2BFLDH5F.js → chunk-EEIROWEG.js} +2 -2
  81. package/dist/{chunk-5QZDMSSN.js → chunk-FKCRGLRD.js} +237 -82
  82. package/dist/{chunk-5QZDMSSN.js.map → chunk-FKCRGLRD.js.map} +1 -1
  83. package/dist/{chunk-X4A62ET7.js → chunk-H2DXDQCQ.js} +17 -17
  84. package/dist/{chunk-27FLVMDC.js → chunk-HLN7RROI.js} +7 -7
  85. package/dist/chunk-IC2TCZJ2.js +54 -0
  86. package/dist/chunk-IC2TCZJ2.js.map +1 -0
  87. package/dist/{chunk-2U2A5HF4.js → chunk-J3XHPFEU.js} +6 -6
  88. package/dist/{chunk-AKUCU7CI.js → chunk-JI5AVXYO.js} +5 -5
  89. package/dist/{chunk-KCJ5H6XQ.js → chunk-JSNVWNGX.js} +2 -2
  90. package/dist/{chunk-7Z2DZNWN.js → chunk-KL4UAXUY.js} +43 -2
  91. package/dist/{chunk-7Z2DZNWN.js.map → chunk-KL4UAXUY.js.map} +1 -1
  92. package/dist/{chunk-FLVL4L3L.js → chunk-KTULX4YO.js} +4 -4
  93. package/dist/{chunk-ILFJ7AVS.js → chunk-LADFWYHY.js} +35 -21
  94. package/dist/chunk-LADFWYHY.js.map +1 -0
  95. package/dist/{chunk-437S3G37.js → chunk-LNQUAJNC.js} +1 -1
  96. package/dist/{chunk-EPGWWCZF.js → chunk-LR5DZ56F.js} +6 -6
  97. package/dist/{chunk-YDSWRD33.js → chunk-N3JDIRBW.js} +4 -4
  98. package/dist/{chunk-OUMTBFSQ.js → chunk-NKCPUHCI.js} +5 -5
  99. package/dist/{chunk-7QWMTOIF.js → chunk-P454KXD6.js} +112 -34
  100. package/dist/chunk-P454KXD6.js.map +1 -0
  101. package/dist/{chunk-DR6PEJU3.js → chunk-PCLIWBGD.js} +44 -59
  102. package/dist/chunk-PCLIWBGD.js.map +1 -0
  103. package/dist/{chunk-BGTAL46Y.js → chunk-PNLFN2PZ.js} +2 -2
  104. package/dist/{chunk-P6SYWE4G.js → chunk-PVUJ6WVV.js} +10 -10
  105. package/dist/{chunk-YPEMNNNY.js → chunk-RGK27ELN.js} +6 -6
  106. package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
  107. package/dist/{chunk-66FLU5BT.js → chunk-SIRPLF54.js} +6 -6
  108. package/dist/{chunk-M7GGMGMZ.js → chunk-SVOHGUMI.js} +1202 -126
  109. package/dist/chunk-SVOHGUMI.js.map +1 -0
  110. package/dist/{chunk-HRCACVHL.js → chunk-TLUDFPZL.js} +2 -2
  111. package/dist/{chunk-2FOD2YY3.js → chunk-UFUKHGZD.js} +236 -103
  112. package/dist/chunk-UFUKHGZD.js.map +1 -0
  113. package/dist/{chunk-2MR3MFQB.js → chunk-UPSAT4PY.js} +2 -2
  114. package/dist/{chunk-4P4626RY.js → chunk-UTRJFDP2.js} +4 -4
  115. package/dist/{chunk-CGS7UKWM.js → chunk-VCW74PNH.js} +2 -2
  116. package/dist/{chunk-5GPPACXK.js → chunk-VSMRDBFT.js} +2 -1
  117. package/dist/{chunk-NG7YBCJO.js → chunk-XCL57M6Q.js} +4 -4
  118. package/dist/{chunk-CLX5VDNY.js → chunk-Y4RKAQZ7.js} +4 -4
  119. package/dist/{chunk-DGYCWCQ7.js → chunk-Y7UNNAZD.js} +2 -2
  120. package/dist/{chunk-EZ25VE3G.js → chunk-YNDLCWXS.js} +4 -4
  121. package/dist/{chunk-XN3TUUX2.js → chunk-YQB32INI.js} +2 -2
  122. package/dist/{cli-EiBBOt3r.d.ts → cli-CqdPA6z9.d.ts} +3 -3
  123. package/dist/cli.d.ts +7 -7
  124. package/dist/cli.js +61 -59
  125. package/dist/compounding/engine.d.ts +2 -2
  126. package/dist/compounding/engine.js +9 -9
  127. package/dist/compounding/preference-consolidator.d.ts +1 -1
  128. package/dist/compression-optimizer.d.ts +1 -1
  129. package/dist/config.d.ts +1 -1
  130. package/dist/config.js +2 -2
  131. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  132. package/dist/connectors/codex-materialize-runner.js +9 -9
  133. package/dist/connectors/codex-materialize.d.ts +1 -1
  134. package/dist/connectors/index.d.ts +1 -1
  135. package/dist/connectors/index.js +13 -11
  136. package/dist/consolidation-provenance-check.d.ts +2 -2
  137. package/dist/consolidation-undo.d.ts +2 -2
  138. package/dist/contradiction/index.d.ts +2 -2
  139. package/dist/contradiction/index.js +4 -4
  140. package/dist/conversation-index/backend.d.ts +1 -1
  141. package/dist/conversation-index/backend.js +2 -2
  142. package/dist/conversation-index/chunker.d.ts +1 -1
  143. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  144. package/dist/conversation-index/indexer.d.ts +1 -1
  145. package/dist/conversation-index/search.d.ts +1 -1
  146. package/dist/dashboard-runtime.js +2 -2
  147. package/dist/day-summary.d.ts +1 -1
  148. package/dist/delinearize.d.ts +1 -1
  149. package/dist/direct-answer-wiring.d.ts +1 -1
  150. package/dist/direct-answer.d.ts +1 -1
  151. package/dist/embedding-fallback.d.ts +1 -1
  152. package/dist/enrichment/index.d.ts +1 -1
  153. package/dist/entity-retrieval.d.ts +2 -2
  154. package/dist/entity-retrieval.js +9 -9
  155. package/dist/entity-schema.d.ts +1 -1
  156. package/dist/explicit-capture.d.ts +5 -5
  157. package/dist/extraction-faithfulness.d.ts +1 -1
  158. package/dist/extraction-judge-telemetry.d.ts +1 -1
  159. package/dist/extraction-judge-training.d.ts +1 -1
  160. package/dist/extraction-judge.d.ts +1 -1
  161. package/dist/extraction.d.ts +1 -1
  162. package/dist/fallback-llm.d.ts +1 -1
  163. package/dist/{first-start-migration-DY6YCRC2.js → first-start-migration-VBJQL6OI.js} +5 -5
  164. package/dist/graph-dashboard-diff.d.ts +1 -1
  165. package/dist/graph-dashboard-key.d.ts +1 -1
  166. package/dist/graph-dashboard-parser.d.ts +1 -1
  167. package/dist/graph-edge-reinforcement.d.ts +1 -1
  168. package/dist/graph-snapshot.d.ts +1 -1
  169. package/dist/graph.d.ts +1 -1
  170. package/dist/identity-continuity.d.ts +1 -1
  171. package/dist/importance.d.ts +1 -1
  172. package/dist/index.d.ts +271 -17
  173. package/dist/index.js +163 -99
  174. package/dist/intent.d.ts +1 -1
  175. package/dist/lcm/engine.d.ts +1 -1
  176. package/dist/lcm/engine.js +2 -2
  177. package/dist/lcm/index.d.ts +1 -1
  178. package/dist/lcm/index.js +2 -2
  179. package/dist/lcm/tools.d.ts +1 -1
  180. package/dist/lifecycle.d.ts +1 -1
  181. package/dist/live-connectors-runner.d.ts +1 -1
  182. package/dist/local-llm.d.ts +1 -1
  183. package/dist/local-model-endpoint.d.ts +1 -1
  184. package/dist/maintenance/memory-governance.d.ts +1 -1
  185. package/dist/maintenance/memory-governance.js +10 -10
  186. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +9 -9
  187. package/dist/maintenance/rebuild-memory-projection.js +11 -11
  188. package/dist/mcp-memory-inspector-app.d.ts +6 -6
  189. package/dist/mcp-read-only-tools.d.ts +16 -0
  190. package/dist/mcp-read-only-tools.js +8 -0
  191. package/dist/mcp-read-only-tools.js.map +1 -0
  192. package/dist/memory-action-policy.d.ts +1 -1
  193. package/dist/memory-cache.d.ts +1 -1
  194. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  195. package/dist/memory-projection-store.d.ts +1 -1
  196. package/dist/memory-provenance.d.ts +1 -1
  197. package/dist/memory-worth-outcomes.d.ts +2 -2
  198. package/dist/models-json.d.ts +1 -1
  199. package/dist/namespaces/migrate.d.ts +3 -3
  200. package/dist/namespaces/migrate.js +19 -19
  201. package/dist/namespaces/principal.d.ts +1 -1
  202. package/dist/namespaces/search.d.ts +1 -1
  203. package/dist/namespaces/search.js +10 -10
  204. package/dist/namespaces/storage.d.ts +3 -3
  205. package/dist/namespaces/storage.js +9 -9
  206. package/dist/native-knowledge.d.ts +1 -1
  207. package/dist/operator-toolkit.d.ts +2 -2
  208. package/dist/operator-toolkit.js +25 -25
  209. package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
  210. package/dist/orchestration/maintenance.d.ts +2 -2
  211. package/dist/orchestration/maintenance.js +11 -11
  212. package/dist/{orchestrator-B2Y28Z9t.d.ts → orchestrator-CWFrT8AF.d.ts} +91 -60
  213. package/dist/orchestrator.d.ts +5 -5
  214. package/dist/orchestrator.js +85 -83
  215. package/dist/patterns-cli.d.ts +1 -1
  216. package/dist/policy-runtime.d.ts +1 -1
  217. package/dist/provenance.d.ts +1 -1
  218. package/dist/{qmd-Db9mOkzB.d.ts → qmd--fnPMK60.d.ts} +1 -1
  219. package/dist/qmd-preflight.d.ts +2 -2
  220. package/dist/qmd-recall-cache.d.ts +1 -1
  221. package/dist/qmd.d.ts +2 -2
  222. package/dist/recall-disclosure-escalation.d.ts +1 -1
  223. package/dist/recall-explain-renderer.d.ts +1 -1
  224. package/dist/recall-planner-llm.d.ts +1 -1
  225. package/dist/recall-state.d.ts +1 -1
  226. package/dist/recall-tag-filter.d.ts +1 -1
  227. package/dist/recall-timings.d.ts +1 -1
  228. package/dist/recall-xray-cli.d.ts +1 -1
  229. package/dist/recall-xray-renderer.d.ts +1 -1
  230. package/dist/recall-xray.d.ts +1 -1
  231. package/dist/resolve-auth-token.d.ts +1 -1
  232. package/dist/resume-bundles.js +4 -4
  233. package/dist/retrieval-agents.d.ts +2 -2
  234. package/dist/retrieval-tiers.d.ts +1 -1
  235. package/dist/routing/engine.d.ts +1 -1
  236. package/dist/routing/store.d.ts +1 -1
  237. package/dist/schemas.d.ts +22 -22
  238. package/dist/search/document-scanner.js +2 -2
  239. package/dist/search/embed-helper.d.ts +1 -1
  240. package/dist/search/factory.d.ts +1 -1
  241. package/dist/search/factory.js +9 -9
  242. package/dist/search/index.d.ts +1 -1
  243. package/dist/search/index.js +13 -13
  244. package/dist/search/lancedb-backend.d.ts +1 -1
  245. package/dist/search/lancedb-backend.js +3 -3
  246. package/dist/search/meilisearch-backend.d.ts +1 -1
  247. package/dist/search/meilisearch-backend.js +3 -3
  248. package/dist/search/noop-backend.d.ts +1 -1
  249. package/dist/search/orama-backend.d.ts +1 -1
  250. package/dist/search/orama-backend.js +3 -3
  251. package/dist/search/port.d.ts +1 -1
  252. package/dist/search/remote-backend.d.ts +1 -1
  253. package/dist/{semantic-consolidation-BDVIbLRw.d.ts → semantic-consolidation-O5lMZ4LG.d.ts} +1 -1
  254. package/dist/semantic-consolidation.d.ts +2 -2
  255. package/dist/semantic-consolidation.js +11 -11
  256. package/dist/semantic-rule-promotion.js +9 -9
  257. package/dist/semantic-rule-verifier.d.ts +1 -1
  258. package/dist/semantic-rule-verifier.js +10 -10
  259. package/dist/session-observer-bands.d.ts +1 -1
  260. package/dist/session-observer-state.d.ts +1 -1
  261. package/dist/shared-context/manager.d.ts +1 -1
  262. package/dist/signal.d.ts +1 -1
  263. package/dist/storage-C1maCNbc.d.ts +1591 -0
  264. package/dist/storage.d.ts +5 -1239
  265. package/dist/storage.js +8 -8
  266. package/dist/summarizer.d.ts +1 -1
  267. package/dist/summary-snapshot.d.ts +1 -1
  268. package/dist/temporal-supersession.d.ts +2 -2
  269. package/dist/temporal-validity.d.ts +1 -1
  270. package/dist/threading.d.ts +1 -1
  271. package/dist/tier-migration.d.ts +2 -2
  272. package/dist/tier-routing.d.ts +1 -1
  273. package/dist/{tier-stats-TG6GUZ6N.js → tier-stats-5XF4UBRQ.js} +4 -4
  274. package/dist/tokens.d.ts +17 -2
  275. package/dist/tokens.js +3 -1
  276. package/dist/topics.d.ts +1 -1
  277. package/dist/transcript.d.ts +1 -1
  278. package/dist/transfer/backup.js +2 -2
  279. package/dist/transfer/capsule-export.js +3 -3
  280. package/dist/transfer/capsule-import.js +3 -3
  281. package/dist/transfer/types.d.ts +12 -12
  282. package/dist/trust-score-stage.d.ts +1 -1
  283. package/dist/trust-score.d.ts +1 -1
  284. package/dist/{types-ljkd82rn.d.ts → types-CGgcNWCG.d.ts} +31 -1
  285. package/dist/types.d.ts +1 -1
  286. package/dist/utility-runtime.d.ts +1 -1
  287. package/dist/verified-recall.js +10 -10
  288. package/package.json +2 -2
  289. package/src/access-boundary.test.ts +62 -0
  290. package/src/access-boundary.ts +165 -126
  291. package/src/access-http.test.ts +781 -0
  292. package/src/access-http.ts +334 -108
  293. package/src/access-mcp.test.ts +264 -0
  294. package/src/access-mcp.ts +45 -70
  295. package/src/access-operations-batch.ts +35 -12
  296. package/src/access-surface-catalog.test.ts +22 -14
  297. package/src/access-surface-catalog.ts +6 -3
  298. package/src/access-token-capabilities.test.ts +844 -0
  299. package/src/access-token-capabilities.ts +391 -0
  300. package/src/chat/chat-mcp.test.ts +232 -0
  301. package/src/chat/chat-session.ts +34 -0
  302. package/src/cli-wearables-fuse.test.ts +197 -0
  303. package/src/cli.ts +25 -0
  304. package/src/index.ts +14 -0
  305. package/src/mcp-read-only-tools.ts +61 -0
  306. package/src/storage.ts +16 -0
  307. package/src/tokens.ts +32 -3
  308. package/src/wearables/cli.test.ts +102 -0
  309. package/src/wearables/cli.ts +65 -0
  310. package/src/wearables/config.test.ts +32 -0
  311. package/src/wearables/config.ts +52 -0
  312. package/src/wearables/day-store.test.ts +246 -0
  313. package/src/wearables/day-store.ts +272 -3
  314. package/src/wearables/fusion/cluster.ts +230 -0
  315. package/src/wearables/fusion/fuse.ts +218 -0
  316. package/src/wearables/fusion/fusion.test.ts +2575 -0
  317. package/src/wearables/fusion/index.ts +50 -0
  318. package/src/wearables/fusion/reconcile.ts +831 -0
  319. package/src/wearables/fusion/reconstruct.ts +251 -0
  320. package/src/wearables/fusion/store.ts +480 -0
  321. package/src/wearables/fusion/types.ts +228 -0
  322. package/src/wearables/index.ts +41 -0
  323. package/src/wearables/service.test.ts +1030 -2
  324. package/src/wearables/service.ts +258 -16
  325. package/src/wearables/speakers.test.ts +128 -0
  326. package/src/wearables/speakers.ts +58 -4
  327. package/src/wearables/storage-io.test.ts +146 -0
  328. package/src/wearables/types.ts +31 -0
  329. package/dist/chunk-2FOD2YY3.js.map +0 -1
  330. package/dist/chunk-7QWMTOIF.js.map +0 -1
  331. package/dist/chunk-DR6PEJU3.js.map +0 -1
  332. package/dist/chunk-E2SPGGUI.js.map +0 -1
  333. package/dist/chunk-FZGGZAAU.js.map +0 -1
  334. package/dist/chunk-ILFJ7AVS.js.map +0 -1
  335. package/dist/chunk-M7GGMGMZ.js.map +0 -1
  336. package/dist/chunk-M7XQSUBB.js.map +0 -1
  337. /package/dist/{capsule-crypto-CZJSLEFG.js.map → access-token-capabilities.js.map} +0 -0
  338. /package/dist/{auto-sync-HMHQHUAG.js.map → auto-sync-4A5YBWYO.js.map} +0 -0
  339. /package/dist/{chunk-6ZIETXZ2.js.map → chunk-23JFRB73.js.map} +0 -0
  340. /package/dist/{chunk-IC6QX5TC.js.map → chunk-3XIY7MDQ.js.map} +0 -0
  341. /package/dist/{chunk-LOR6ZM5R.js.map → chunk-4LV23CHK.js.map} +0 -0
  342. /package/dist/{chunk-AER6MT24.js.map → chunk-6SXVCD7W.js.map} +0 -0
  343. /package/dist/{chunk-OHDJ34CC.js.map → chunk-72WG5QKN.js.map} +0 -0
  344. /package/dist/{chunk-CZAZX7JP.js.map → chunk-7MW3CVLD.js.map} +0 -0
  345. /package/dist/{chunk-CXLNZXG5.js.map → chunk-7XGSIUP5.js.map} +0 -0
  346. /package/dist/{chunk-CZAA5N5S.js.map → chunk-AYZJID4S.js.map} +0 -0
  347. /package/dist/{chunk-6BISDEN6.js.map → chunk-BEM4D2QG.js.map} +0 -0
  348. /package/dist/{chunk-ZPOROUTX.js.map → chunk-CYWA2CXR.js.map} +0 -0
  349. /package/dist/{chunk-M5QKGHCR.js.map → chunk-CYWCKFCL.js.map} +0 -0
  350. /package/dist/{chunk-4BFRBP45.js.map → chunk-DEG5ULFJ.js.map} +0 -0
  351. /package/dist/{chunk-JLR3JSTL.js.map → chunk-DS7MM2ES.js.map} +0 -0
  352. /package/dist/{chunk-2BFLDH5F.js.map → chunk-EEIROWEG.js.map} +0 -0
  353. /package/dist/{chunk-X4A62ET7.js.map → chunk-H2DXDQCQ.js.map} +0 -0
  354. /package/dist/{chunk-27FLVMDC.js.map → chunk-HLN7RROI.js.map} +0 -0
  355. /package/dist/{chunk-2U2A5HF4.js.map → chunk-J3XHPFEU.js.map} +0 -0
  356. /package/dist/{chunk-AKUCU7CI.js.map → chunk-JI5AVXYO.js.map} +0 -0
  357. /package/dist/{chunk-KCJ5H6XQ.js.map → chunk-JSNVWNGX.js.map} +0 -0
  358. /package/dist/{chunk-FLVL4L3L.js.map → chunk-KTULX4YO.js.map} +0 -0
  359. /package/dist/{chunk-437S3G37.js.map → chunk-LNQUAJNC.js.map} +0 -0
  360. /package/dist/{chunk-EPGWWCZF.js.map → chunk-LR5DZ56F.js.map} +0 -0
  361. /package/dist/{chunk-YDSWRD33.js.map → chunk-N3JDIRBW.js.map} +0 -0
  362. /package/dist/{chunk-OUMTBFSQ.js.map → chunk-NKCPUHCI.js.map} +0 -0
  363. /package/dist/{chunk-BGTAL46Y.js.map → chunk-PNLFN2PZ.js.map} +0 -0
  364. /package/dist/{chunk-P6SYWE4G.js.map → chunk-PVUJ6WVV.js.map} +0 -0
  365. /package/dist/{chunk-YPEMNNNY.js.map → chunk-RGK27ELN.js.map} +0 -0
  366. /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
  367. /package/dist/{chunk-66FLU5BT.js.map → chunk-SIRPLF54.js.map} +0 -0
  368. /package/dist/{chunk-HRCACVHL.js.map → chunk-TLUDFPZL.js.map} +0 -0
  369. /package/dist/{chunk-2MR3MFQB.js.map → chunk-UPSAT4PY.js.map} +0 -0
  370. /package/dist/{chunk-4P4626RY.js.map → chunk-UTRJFDP2.js.map} +0 -0
  371. /package/dist/{chunk-CGS7UKWM.js.map → chunk-VCW74PNH.js.map} +0 -0
  372. /package/dist/{chunk-5GPPACXK.js.map → chunk-VSMRDBFT.js.map} +0 -0
  373. /package/dist/{chunk-NG7YBCJO.js.map → chunk-XCL57M6Q.js.map} +0 -0
  374. /package/dist/{chunk-CLX5VDNY.js.map → chunk-Y4RKAQZ7.js.map} +0 -0
  375. /package/dist/{chunk-DGYCWCQ7.js.map → chunk-Y7UNNAZD.js.map} +0 -0
  376. /package/dist/{chunk-EZ25VE3G.js.map → chunk-YNDLCWXS.js.map} +0 -0
  377. /package/dist/{chunk-XN3TUUX2.js.map → chunk-YQB32INI.js.map} +0 -0
  378. /package/dist/{first-start-migration-DY6YCRC2.js.map → first-start-migration-VBJQL6OI.js.map} +0 -0
  379. /package/dist/{tier-stats-TG6GUZ6N.js.map → tier-stats-5XF4UBRQ.js.map} +0 -0
@@ -0,0 +1,2575 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { composeDayTranscriptBody } from "../day-store.js";
5
+ import { emptySpeakerRegistry } from "../speakers.js";
6
+ import type { WearableConversation } from "../types.js";
7
+ import {
8
+ DEFAULT_PROXIMITY_GAP_MS,
9
+ DEFAULT_WINDOW_TOLERANCE_MS,
10
+ FUSION_ALGO_VERSION,
11
+ canonicalDayKey,
12
+ clusterConversations,
13
+ composeFusionDayMeta,
14
+ fuseDay,
15
+ fusionInputsFromConversations,
16
+ hashFusionBody,
17
+ reconstructFusionInputs,
18
+ serializeFusionDay,
19
+ parseFusionDay,
20
+ } from "./index.js";
21
+ import type { FusionConversationInput } from "./index.js";
22
+
23
+ const DATE = "2026-06-10";
24
+ const REGISTRY = emptySpeakerRegistry();
25
+
26
+ /** Build a minimal wearable conversation with resolved self/others. */
27
+ function conversation(
28
+ source: string,
29
+ id: string,
30
+ startIso: string,
31
+ segments: Array<{
32
+ text: string;
33
+ speakerKey?: string;
34
+ speakerName?: string;
35
+ isWearer?: boolean;
36
+ startIso?: string;
37
+ endIso?: string;
38
+ }>,
39
+ extra: Partial<WearableConversation> = {},
40
+ ): WearableConversation {
41
+ return {
42
+ id,
43
+ source,
44
+ startIso,
45
+ segments: segments.map((segment) => ({
46
+ text: segment.text,
47
+ speakerKey: segment.speakerKey ?? "user",
48
+ ...(segment.speakerName !== undefined ? { speakerName: segment.speakerName } : {}),
49
+ ...(segment.isWearer !== undefined ? { isWearer: segment.isWearer } : {}),
50
+ ...(segment.startIso !== undefined ? { startIso: segment.startIso } : {}),
51
+ ...(segment.endIso !== undefined ? { endIso: segment.endIso } : {}),
52
+ })),
53
+ ...extra,
54
+ };
55
+ }
56
+
57
+ function inputs(
58
+ ...perSource: Array<{ source: string; conversations: WearableConversation[] }>
59
+ ): FusionConversationInput[] {
60
+ return perSource.flatMap(({ source, conversations }) =>
61
+ fusionInputsFromConversations(source, conversations, REGISTRY),
62
+ );
63
+ }
64
+
65
+ test("exact overlap: two sources, same time/text fuse to one segment, no disagreement", () => {
66
+ const fused = fuseDay(
67
+ DATE,
68
+ inputs(
69
+ {
70
+ source: "limitless",
71
+ conversations: [
72
+ conversation(
73
+ "limitless",
74
+ "c1",
75
+ "2026-06-10T09:00:00.000Z",
76
+ [
77
+ {
78
+ text: "Let's ship the launch on Friday.",
79
+ isWearer: true,
80
+ startIso: "2026-06-10T09:00:30.000Z",
81
+ },
82
+ ],
83
+ { endIso: "2026-06-10T09:01:00.000Z" },
84
+ ),
85
+ ],
86
+ },
87
+ {
88
+ source: "bee",
89
+ conversations: [
90
+ conversation(
91
+ "bee",
92
+ "c1",
93
+ "2026-06-10T09:00:00.000Z",
94
+ [
95
+ {
96
+ text: "Let's ship the launch on Friday.",
97
+ isWearer: true,
98
+ startIso: "2026-06-10T09:00:31.000Z",
99
+ },
100
+ ],
101
+ { endIso: "2026-06-10T09:01:00.000Z" },
102
+ ),
103
+ ],
104
+ },
105
+ ),
106
+ { sourceTrust: { limitless: 0.9, bee: 0.7 } },
107
+ );
108
+
109
+ assert.equal(fused.conversations.length, 1);
110
+ const conv = fused.conversations[0]!;
111
+ assert.deepEqual(conv.sources.sort(), ["bee", "limitless"]);
112
+ assert.equal(conv.segments.length, 1, "overlapping identical text collapses to one segment");
113
+ const segment = conv.segments[0]!;
114
+ assert.equal(segment.text, "Let's ship the launch on Friday.");
115
+ assert.equal(segment.provenance.source, "limitless");
116
+ assert.equal(segment.provenance.reason, "higher-trust");
117
+ assert.equal(segment.provenance.alternatives.length, 1);
118
+ assert.equal(conv.disagreements.length, 0, "identical text is not a disagreement");
119
+ });
120
+
121
+ test("partial overlap: adjacent conversations still cluster into one fused conversation", () => {
122
+ const clusters = clusterConversations(
123
+ inputs(
124
+ {
125
+ source: "limitless",
126
+ conversations: [
127
+ conversation(
128
+ "limitless",
129
+ "c1",
130
+ "2026-06-10T09:00:00.000Z",
131
+ [{ text: "First topic.", isWearer: true }],
132
+ { endIso: "2026-06-10T09:20:00.000Z" },
133
+ ),
134
+ ],
135
+ },
136
+ {
137
+ source: "bee",
138
+ conversations: [
139
+ conversation(
140
+ "bee",
141
+ "c1",
142
+ "2026-06-10T09:22:00.000Z",
143
+ [{ text: "Second topic.", isWearer: true }],
144
+ { endIso: "2026-06-10T09:40:00.000Z" },
145
+ ),
146
+ ],
147
+ },
148
+ ),
149
+ );
150
+ // 2-minute gap < 5-minute default proximity -> one cluster.
151
+ assert.equal(clusters.length, 1);
152
+ });
153
+
154
+ test("explicit end earlier than start is clamped to start so a within-gap neighbor still clusters (#1849)", () => {
155
+ // A parseable endIso that precedes the start (malformed/cross-day input)
156
+ // must collapse to the start: effectiveInterval returns [09:00, 09:00]
157
+ // instead of the negative-length [09:00, 08:55]. Without the clamp the
158
+ // malformed end (08:55) sits 8 min before the neighbor's 09:03 start —
159
+ // outside the 5-min gap — and the two split; with the clamp the 09:00 end
160
+ // keeps the 09:03 neighbor within the gap so they merge into one cluster.
161
+ const clusters = clusterConversations(
162
+ inputs(
163
+ {
164
+ source: "limitless",
165
+ conversations: [
166
+ conversation(
167
+ "limitless",
168
+ "c1",
169
+ "2026-06-10T09:00:00.000Z",
170
+ [{ text: "Clamped window topic.", isWearer: true }],
171
+ { endIso: "2026-06-10T08:55:00.000Z" },
172
+ ),
173
+ ],
174
+ },
175
+ {
176
+ source: "bee",
177
+ conversations: [
178
+ conversation(
179
+ "bee",
180
+ "c1",
181
+ "2026-06-10T09:03:00.000Z",
182
+ [{ text: "Neighbor topic.", isWearer: true }],
183
+ { endIso: "2026-06-10T09:10:00.000Z" },
184
+ ),
185
+ ],
186
+ },
187
+ ),
188
+ );
189
+ assert.equal(
190
+ clusters.length,
191
+ 1,
192
+ "clamped-to-start end keeps the within-gap neighbor in one cluster",
193
+ );
194
+ assert.deepEqual(
195
+ clusters[0]!.map((c) => c.source).sort(),
196
+ ["bee", "limitless"],
197
+ );
198
+ });
199
+
200
+ test("same-source conversations within the proximity gap stay separate without a cross-source bridge (issue #1849)", () => {
201
+ // The proximity gap bridges the SAME real-world conversation recorded by
202
+ // DIFFERENT sources. Two conversations from the SAME source within the
203
+ // gap must NOT merge: no other source corroborates that they are the
204
+ // same event, so the source's own conversation boundaries are preserved.
205
+ const a: FusionConversationInput = {
206
+ source: "limitless",
207
+ conversationId: "c1",
208
+ startIso: "2026-06-10T09:00:00.000Z",
209
+ endIso: "2026-06-10T09:10:00.000Z",
210
+ segments: [{ speaker: "Me (you)", isSelf: true, text: "First meeting." }],
211
+ };
212
+ const b: FusionConversationInput = {
213
+ source: "limitless",
214
+ conversationId: "c2",
215
+ startIso: "2026-06-10T09:12:00.000Z",
216
+ endIso: "2026-06-10T09:20:00.000Z",
217
+ segments: [{ speaker: "Me (you)", isSelf: true, text: "Second meeting." }],
218
+ };
219
+ // 2-minute gap < 5-minute default, but SAME source => two clusters.
220
+ const clusters = clusterConversations([a, b]);
221
+ assert.equal(
222
+ clusters.length,
223
+ 2,
224
+ "same-source conversations stay separate without a cross-source bridge",
225
+ );
226
+ assert.deepEqual(
227
+ clusters.map((c) => c[0]!.conversationId),
228
+ ["c1", "c2"],
229
+ "each conversation is its own cluster, in chronological order",
230
+ );
231
+ });
232
+
233
+ test("different-source conversations within the proximity gap still merge (cross-source bridge)", () => {
234
+ // Cross-source proximity bridging is the INTENDED use of the gap —
235
+ // verify it is preserved by the cross-source bridge rule.
236
+ const a: FusionConversationInput = {
237
+ source: "limitless",
238
+ conversationId: "c1",
239
+ startIso: "2026-06-10T09:00:00.000Z",
240
+ endIso: "2026-06-10T09:10:00.000Z",
241
+ segments: [{ speaker: "Me (you)", isSelf: true, text: "First meeting." }],
242
+ };
243
+ const b: FusionConversationInput = {
244
+ source: "bee",
245
+ conversationId: "c1",
246
+ startIso: "2026-06-10T09:12:00.000Z",
247
+ endIso: "2026-06-10T09:20:00.000Z",
248
+ segments: [{ speaker: "Me (you)", isSelf: true, text: "First meeting." }],
249
+ };
250
+ // 2-minute gap < 5-minute default, DIFFERENT sources => one cluster.
251
+ const clusters = clusterConversations([a, b]);
252
+ assert.equal(clusters.length, 1, "different-source conversations within the gap merge");
253
+ assert.deepEqual(clusters[0]!.map((c) => c.source).sort(), ["bee", "limitless"]);
254
+ });
255
+
256
+ test("same-source conversations bridged by a different-source chain merge into one cluster (issue #1849)", () => {
257
+ // A(src1) and C(src1) are the SAME source. B(src2) sits within the gap
258
+ // of both and bridges them: A→B→C is a valid cross-source chain. Without
259
+ // B, A and C would stay separate (same source, no bridge).
260
+ //
261
+ // Chosen behavior: a same-source pair that would NOT merge on its own
262
+ // MAY share a cluster when a different-source conversation within the gap
263
+ // corroborates the bridge — the union-find transitive closure links them
264
+ // through B. This preserves source boundaries when no bridge exists while
265
+ // still fusing corroborated chains.
266
+ const a: FusionConversationInput = {
267
+ source: "limitless",
268
+ conversationId: "c1",
269
+ startIso: "2026-06-10T09:00:00.000Z",
270
+ endIso: "2026-06-10T09:03:00.000Z",
271
+ segments: [{ speaker: "Me (you)", isSelf: true, text: "Topic one." }],
272
+ };
273
+ const b: FusionConversationInput = {
274
+ source: "bee",
275
+ conversationId: "c1",
276
+ startIso: "2026-06-10T09:06:00.000Z",
277
+ endIso: "2026-06-10T09:10:00.000Z",
278
+ segments: [{ speaker: "Me (you)", isSelf: true, text: "Topic one." }],
279
+ };
280
+ const c: FusionConversationInput = {
281
+ source: "limitless",
282
+ conversationId: "c2",
283
+ startIso: "2026-06-10T09:09:00.000Z",
284
+ endIso: "2026-06-10T09:20:00.000Z",
285
+ segments: [{ speaker: "Me (you)", isSelf: true, text: "Topic two." }],
286
+ };
287
+ // A-B: diff source, 3-min gap (< 5 min) => bridge.
288
+ // A-C: same source, and C is 6 min past A.end (outside the gap) anyway.
289
+ // B-C: diff source, C overlaps B => bridge.
290
+ // => one cluster {A, B, C}, sorted by (start, source, id).
291
+ const clusters = clusterConversations([a, b, c]);
292
+ assert.equal(
293
+ clusters.length,
294
+ 1,
295
+ "the bee conversation bridges the two limitless conversations into one cluster",
296
+ );
297
+ assert.deepEqual(
298
+ clusters[0]!.map((conv) => conv.conversationId),
299
+ ["c1", "c1", "c2"],
300
+ "cluster is sorted by (start, source, id): limitless c1, bee c1, limitless c2",
301
+ );
302
+ });
303
+
304
+ test("conflicting ASR text for the same window is recorded as a disagreement", () => {
305
+ const fused = fuseDay(
306
+ DATE,
307
+ inputs(
308
+ {
309
+ source: "limitless",
310
+ conversations: [
311
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
312
+ {
313
+ text: "Let's ship the launch on Friday.",
314
+ isWearer: true,
315
+ startIso: "2026-06-10T09:00:30.000Z",
316
+ },
317
+ ]),
318
+ ],
319
+ },
320
+ {
321
+ source: "bee",
322
+ conversations: [
323
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
324
+ {
325
+ text: "Let's skip the launch entirely.",
326
+ isWearer: true,
327
+ startIso: "2026-06-10T09:00:31.000Z",
328
+ },
329
+ ]),
330
+ ],
331
+ },
332
+ ),
333
+ );
334
+
335
+ const conv = fused.conversations[0]!;
336
+ assert.equal(conv.disagreements.length, 1);
337
+ const disagreement = conv.disagreements[0]!;
338
+ assert.equal(disagreement.kind, "asr-text");
339
+ assert.equal(disagreement.candidates.length, 2);
340
+ assert.ok(disagreement.provisional, "a provisional winner is kept, never silently dropped");
341
+ // The fused segment carries lowered confidence when a conflict exists.
342
+ assert.ok(conv.segments[0]!.confidence < 0.8);
343
+ });
344
+
345
+ test("truncation is more-complete, not a disagreement: verbatim beats clipped", () => {
346
+ const fused = fuseDay(
347
+ DATE,
348
+ inputs(
349
+ {
350
+ source: "limitless",
351
+ conversations: [
352
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
353
+ {
354
+ text: "The deploy window opens at three and closes at five.",
355
+ isWearer: true,
356
+ startIso: "2026-06-10T09:00:30.000Z",
357
+ },
358
+ ]),
359
+ ],
360
+ },
361
+ {
362
+ source: "bee",
363
+ conversations: [
364
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
365
+ {
366
+ text: "The deploy window opens at three and closes at",
367
+ isWearer: true,
368
+ startIso: "2026-06-10T09:00:31.000Z",
369
+ },
370
+ ]),
371
+ ],
372
+ },
373
+ ),
374
+ );
375
+
376
+ const conv = fused.conversations[0]!;
377
+ assert.equal(conv.disagreements.length, 0, "a clipped transcript is not a conflict");
378
+ const segment = conv.segments[0]!;
379
+ assert.equal(
380
+ segment.text,
381
+ "The deploy window opens at three and closes at five.",
382
+ );
383
+ assert.equal(segment.provenance.reason, "more-complete");
384
+ });
385
+
386
+ test("summary-style + verbatim-style source: summary preserved, verbatim segments win", () => {
387
+ const fused = fuseDay(
388
+ DATE,
389
+ inputs(
390
+ {
391
+ source: "omi",
392
+ conversations: [
393
+ conversation(
394
+ "omi",
395
+ "c1",
396
+ "2026-06-10T09:00:00.000Z",
397
+ [{ text: "Discussed the launch.", isWearer: true }],
398
+ {
399
+ endIso: "2026-06-10T09:10:00.000Z",
400
+ title: "Launch sync",
401
+ summary: "A short summary of the launch discussion.",
402
+ },
403
+ ),
404
+ ],
405
+ },
406
+ {
407
+ source: "limitless",
408
+ conversations: [
409
+ conversation(
410
+ "limitless",
411
+ "c1",
412
+ "2026-06-10T09:00:00.000Z",
413
+ [
414
+ {
415
+ text: "We agreed to launch on Friday at noon.",
416
+ isWearer: true,
417
+ startIso: "2026-06-10T09:00:30.000Z",
418
+ },
419
+ {
420
+ text: "I will send the checklist.",
421
+ isWearer: true,
422
+ startIso: "2026-06-10T09:01:00.000Z",
423
+ },
424
+ ],
425
+ { endIso: "2026-06-10T09:10:00.000Z" },
426
+ ),
427
+ ],
428
+ },
429
+ ),
430
+ );
431
+
432
+ const conv = fused.conversations[0]!;
433
+ assert.equal(conv.title, "Launch sync");
434
+ assert.equal(conv.summary, "A short summary of the launch discussion.");
435
+ // Verbatim source contributes more complete, on-the-record segments.
436
+ assert.ok(
437
+ conv.segments.some((segment) =>
438
+ segment.text.startsWith("We agreed to launch"),
439
+ ),
440
+ "verbatim segment is retained",
441
+ );
442
+ });
443
+
444
+ test("missing timestamps: segments without start are preserved and still fused", () => {
445
+ const fused = fuseDay(
446
+ DATE,
447
+ inputs(
448
+ {
449
+ source: "limitless",
450
+ conversations: [
451
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
452
+ { text: "No timestamp on this utterance.", isWearer: true },
453
+ ]),
454
+ ],
455
+ },
456
+ {
457
+ source: "bee",
458
+ conversations: [
459
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
460
+ { text: "No timestamp on this utterance.", isWearer: true },
461
+ ]),
462
+ ],
463
+ },
464
+ ),
465
+ );
466
+
467
+ const conv = fused.conversations[0]!;
468
+ assert.equal(conv.segments.length, 1);
469
+ assert.equal(conv.segments[0]!.text, "No timestamp on this utterance.");
470
+ assert.equal(conv.segments[0]!.startIso, undefined);
471
+ });
472
+
473
+ test("speaker-label uncertainty: generic single-source label carries lowered confidence", () => {
474
+ const fused = fuseDay(
475
+ DATE,
476
+ inputs(
477
+ {
478
+ source: "bee",
479
+ conversations: [
480
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
481
+ {
482
+ text: "Hello there.",
483
+ speakerKey: "0",
484
+ startIso: "2026-06-10T09:00:30.000Z",
485
+ },
486
+ ]),
487
+ ],
488
+ },
489
+ ),
490
+ );
491
+
492
+ const conv = fused.conversations[0]!;
493
+ const generic = conv.speakers.find((speaker) => !speaker.isSelf);
494
+ assert.ok(generic, "a non-self speaker is present");
495
+ assert.ok(
496
+ generic!.confidence <= 0.5,
497
+ "a generic label seen in one source is marked uncertain",
498
+ );
499
+ });
500
+
501
+ test("idempotency: identical inputs produce a stable id and content hash", () => {
502
+ const a = fuseDay(
503
+ DATE,
504
+ inputs({
505
+ source: "limitless",
506
+ conversations: [
507
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
508
+ { text: "Stable input.", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
509
+ ]),
510
+ ],
511
+ }),
512
+ );
513
+ // Re-run with a different source ORDER — must hash identically.
514
+ const b = fuseDay(
515
+ DATE,
516
+ inputs({
517
+ source: "limitless",
518
+ conversations: [
519
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
520
+ { text: "Stable input.", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
521
+ ]),
522
+ ],
523
+ }),
524
+ );
525
+
526
+ assert.equal(a.conversations[0]!.id, b.conversations[0]!.id);
527
+ assert.equal(a.contentHash, b.contentHash);
528
+ });
529
+
530
+ test("content hash folds fusion config: same inputs, changed knob => new hash", () => {
531
+ // Two contributing sources so per-source trust is part of the fingerprint.
532
+ const day = inputs(
533
+ {
534
+ source: "limitless",
535
+ conversations: [
536
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
537
+ { text: "Config-sensitive hash.", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
538
+ ]),
539
+ ],
540
+ },
541
+ {
542
+ source: "bee",
543
+ conversations: [
544
+ conversation("bee", "c2", "2026-06-10T09:00:20.000Z", [
545
+ { text: "Second source.", isWearer: false, startIso: "2026-06-10T09:00:40.000Z" },
546
+ ]),
547
+ ],
548
+ },
549
+ );
550
+
551
+ const base = fuseDay(DATE, day);
552
+
553
+ // A change to any clustering/reconciliation knob must invalidate the hash.
554
+ const widerGap = fuseDay(DATE, day, { proximityGapMs: 600_000 });
555
+ assert.notEqual(
556
+ base.contentHash,
557
+ widerGap.contentHash,
558
+ "proximityGapMs change must invalidate the content hash",
559
+ );
560
+ const tighterTol = fuseDay(DATE, day, { windowToleranceMs: 5_000 });
561
+ assert.notEqual(
562
+ base.contentHash,
563
+ tighterTol.contentHash,
564
+ "windowToleranceMs change must invalidate the content hash",
565
+ );
566
+ const reweighted = fuseDay(DATE, day, { sourceTrust: { limitless: 0.95 } });
567
+ assert.notEqual(
568
+ base.contentHash,
569
+ reweighted.contentHash,
570
+ "per-source trust change must invalidate the content hash",
571
+ );
572
+
573
+ // Idempotency is preserved: explicit defaults hash identically to omission,
574
+ // and the per-conversation id stays stable across a config change.
575
+ const withDefaults = fuseDay(DATE, day, {
576
+ proximityGapMs: DEFAULT_PROXIMITY_GAP_MS,
577
+ windowToleranceMs: DEFAULT_WINDOW_TOLERANCE_MS,
578
+ });
579
+ assert.equal(
580
+ base.contentHash,
581
+ withDefaults.contentHash,
582
+ "explicit defaults must hash identically to omission",
583
+ );
584
+ assert.equal(
585
+ base.conversations[0]!.id,
586
+ widerGap.conversations[0]!.id,
587
+ "conversation id is input-only and stable across a config change",
588
+ );
589
+ });
590
+
591
+ test("content hash folds the fusion algorithm version (issue #1849)", () => {
592
+ // The day contentHash is the idempotency key. When the fusion ALGORITHM
593
+ // changes (clustering/reconciliation/reconstruction fixes) with byte-
594
+ // identical inputs and config, the hash must still differ so a stale
595
+ // artifact is regenerated rather than skipped — contentHash + bodyHash
596
+ // alone only prove the stored body is self-consistent, not that it was
597
+ // produced by the current algorithm.
598
+ const day = inputs(
599
+ {
600
+ source: "limitless",
601
+ conversations: [
602
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
603
+ { text: "Algorithm-sensitive hash.", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
604
+ ]),
605
+ ],
606
+ },
607
+ {
608
+ source: "bee",
609
+ conversations: [
610
+ conversation("bee", "c2", "2026-06-10T09:00:20.000Z", [
611
+ { text: "Second source.", isWearer: false, startIso: "2026-06-10T09:00:40.000Z" },
612
+ ]),
613
+ ],
614
+ },
615
+ );
616
+
617
+ const current = fuseDay(DATE, day);
618
+
619
+ // A stale algorithm version must produce a DIFFERENT day hash.
620
+ const staleHash = canonicalDayKey(DATE, day, {}, "2000-01-01-stale");
621
+ assert.notEqual(
622
+ current.contentHash,
623
+ staleHash,
624
+ "a different algorithm version must invalidate the content hash",
625
+ );
626
+
627
+ // Same version => identical hash (idempotency preserved).
628
+ assert.equal(
629
+ current.contentHash,
630
+ canonicalDayKey(DATE, day, {}, FUSION_ALGO_VERSION),
631
+ "same version must produce the same hash",
632
+ );
633
+
634
+ // The per-conversation id is input-only and unaffected by the version.
635
+ assert.ok(
636
+ current.conversations[0]!.id.startsWith("fusion-"),
637
+ "conversation id is unaffected by the algorithm version",
638
+ );
639
+ });
640
+
641
+ test("hash encoding is delimiter-injection-safe: distinct inputs differing only by where a `|`/newline falls never collide (issue #1849)", () => {
642
+ // The conversation id + day contentHash are computed from a serialization
643
+ // of the structured fusion inputs. That serialization must be UNAMBIGUOUS:
644
+ // a user-defined speaker label or segment text containing `|` (or a
645
+ // newline plus a forged `seg|…` line) must not be able to forge a
646
+ // field/record boundary — otherwise two DISTINCT inputs would serialize to
647
+ // identical bytes, hash alike, and fuseDay would wrongly take the
648
+ // idempotent-skip path, keeping a stale artifact.
649
+ const startIso = "2026-06-10T09:00:00.000Z";
650
+
651
+ // Collision pair over the OLD `|`/newline-delimited form:
652
+ // - "split": ONE segment whose text embeds a newline plus a forged
653
+ // `seg|…` line, so its serialized bytes looked like two segment lines.
654
+ // - "two": TWO real segments whose serialized lines are byte-identical
655
+ // to "split" once the old delimiter joins ran.
656
+ const split: FusionConversationInput[] = [
657
+ {
658
+ source: "limitless",
659
+ conversationId: "c1",
660
+ startIso,
661
+ segments: [
662
+ { speaker: "Alice", isSelf: false, text: "Bob\nseg|Carol|other|||Dave" },
663
+ ],
664
+ },
665
+ ];
666
+ const two: FusionConversationInput[] = [
667
+ {
668
+ source: "limitless",
669
+ conversationId: "c1",
670
+ startIso,
671
+ segments: [
672
+ { speaker: "Alice", isSelf: false, text: "Bob" },
673
+ { speaker: "Carol", isSelf: false, text: "Dave" },
674
+ ],
675
+ },
676
+ ];
677
+
678
+ const fusedSplit = fuseDay(DATE, split);
679
+ const fusedTwo = fuseDay(DATE, two);
680
+
681
+ // The two distinct inputs must NOT collide.
682
+ assert.notEqual(
683
+ fusedSplit.contentHash,
684
+ fusedTwo.contentHash,
685
+ "a segment text containing a forged delimiter must not collide with two real segments",
686
+ );
687
+ assert.notEqual(
688
+ fusedSplit.conversations[0]!.id,
689
+ fusedTwo.conversations[0]!.id,
690
+ "the per-conversation id must also be collision-safe",
691
+ );
692
+
693
+ // A `|` inside a speaker label must not be confused with one inside the
694
+ // text field (field-boundary forgery).
695
+ const labelPipe: FusionConversationInput[] = [
696
+ {
697
+ source: "limitless",
698
+ conversationId: "c1",
699
+ startIso,
700
+ segments: [{ speaker: "Alice|Bob", isSelf: false, text: "Carol" }],
701
+ },
702
+ ];
703
+ const textPipe: FusionConversationInput[] = [
704
+ {
705
+ source: "limitless",
706
+ conversationId: "c1",
707
+ startIso,
708
+ segments: [{ speaker: "Alice", isSelf: false, text: "Bob|Carol" }],
709
+ },
710
+ ];
711
+ assert.notEqual(
712
+ fuseDay(DATE, labelPipe).contentHash,
713
+ fuseDay(DATE, textPipe).contentHash,
714
+ "a `|` in a speaker label vs in the text must hash differently",
715
+ );
716
+
717
+ // Idempotency is preserved for genuine no-ops: the same logical input
718
+ // (built twice, independently) still produces the SAME hash, so the
719
+ // idempotent-skip path keeps working for unchanged inputs.
720
+ assert.equal(
721
+ fuseDay(DATE, split).contentHash,
722
+ fuseDay(DATE, JSON.parse(JSON.stringify(split))).contentHash,
723
+ "identical logical inputs must hash identically (idempotent skip still works)",
724
+ );
725
+ });
726
+
727
+
728
+ test("serialize/parse round-trips a fused day file", () => {
729
+ const fused = fuseDay(
730
+ DATE,
731
+ inputs({
732
+ source: "limitless",
733
+ conversations: [
734
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
735
+ { text: "Round trip.", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
736
+ ]),
737
+ ],
738
+ }),
739
+ );
740
+ const meta = composeFusionDayMeta(
741
+ DATE,
742
+ fused.conversations,
743
+ fused.sources,
744
+ fused.contentHash,
745
+ "2026-06-11T00:00:00.000Z",
746
+ );
747
+ const serialized = serializeFusionDay(meta, fused.conversations);
748
+ const parsed = parseFusionDay(serialized);
749
+ assert.ok(parsed);
750
+ assert.equal(parsed!.meta.kind, "wearable-fusion");
751
+ assert.equal(parsed!.meta.date, DATE);
752
+ assert.equal(parsed!.meta.bodyHash, hashFusionBody(fused.conversations));
753
+ assert.equal(parsed!.parseOk, true);
754
+ assert.equal(parsed!.conversations.length, 1);
755
+ assert.equal(parsed!.conversations[0]!.segments[0]!.text, "Round trip.");
756
+ });
757
+
758
+ test("parseFusionDay returns null for non-fusion content", () => {
759
+ assert.equal(parseFusionDay("---\nkind: wearable-transcript\n---\n\nbody\n"), null);
760
+ assert.equal(parseFusionDay("not a transcript at all"), null);
761
+ });
762
+
763
+ test("parseFusionDay accepts a well-formed conversation array", () => {
764
+ const fused = fuseDay(
765
+ DATE,
766
+ inputs({
767
+ source: "limitless",
768
+ conversations: [
769
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
770
+ { text: "Well formed.", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
771
+ ]),
772
+ ],
773
+ }),
774
+ );
775
+ const serialized = serializeFusionDay(
776
+ composeFusionDayMeta(DATE, fused.conversations, fused.sources, fused.contentHash, "2026-06-11T00:00:00.000Z"),
777
+ fused.conversations,
778
+ );
779
+ const parsed = parseFusionDay(serialized);
780
+ assert.ok(parsed);
781
+ assert.equal(parsed!.parseOk, true);
782
+ assert.equal(parsed!.conversations.length, fused.conversations.length);
783
+ });
784
+
785
+ test("parseFusionDay accepts a legitimately-empty body", () => {
786
+ const serialized = serializeFusionDay(
787
+ composeFusionDayMeta(DATE, [], [], "abc", "2026-06-11T00:00:00.000Z"),
788
+ [],
789
+ );
790
+ const parsed = parseFusionDay(serialized);
791
+ assert.ok(parsed);
792
+ assert.equal(parsed!.parseOk, true);
793
+ assert.equal(parsed!.conversations.length, 0);
794
+ });
795
+
796
+ test("parseFusionDay rejects corrupt bodies even with matching hash + count", () => {
797
+ // Frontmatter carries a hash + count that would otherwise "match"; the
798
+ // body itself must drive parseOk:false so fuseDay force-rewrites.
799
+ const header = [
800
+ "---",
801
+ "kind: wearable-fusion",
802
+ `date: ${JSON.stringify(DATE)}`,
803
+ "sourceCount: 1",
804
+ "conversationCount: 1",
805
+ 'contentHash: "matches"',
806
+ 'bodyHash: "matches"',
807
+ 'fusedAt: "2026-06-11T00:00:00.000Z"',
808
+ "---",
809
+ "",
810
+ ].join("\n");
811
+ // Non-array JSON, empty object, null, wrong-typed, and partial elements
812
+ // are all rejected; a legitimately-empty [] is accepted.
813
+ for (const [body, expectOk] of [
814
+ ['{"not":"array"}', false],
815
+ ["[{}]", false],
816
+ ["[null]", false],
817
+ ['[{"id":1}]', false],
818
+ ['[{"id":"x"}]', false],
819
+ ["[]", true],
820
+ ] as const) {
821
+ const parsed = parseFusionDay(`${header}${body}\n`);
822
+ assert.ok(parsed, `expected a non-null parse for body ${body}`);
823
+ assert.equal(
824
+ parsed!.parseOk,
825
+ expectOk,
826
+ `expected parseOk:${expectOk} for body ${body}`,
827
+ );
828
+ if (!expectOk) {
829
+ assert.equal(parsed!.conversations.length, 0, `expected empty convs for body ${body}`);
830
+ }
831
+ }
832
+ });
833
+
834
+ test("parseFusionDay rejects a blank or whitespace-only body as corrupt (#1849)", () => {
835
+ // An existing artifact whose body is blank/whitespace-only (frontmatter
836
+ // present but no JSON) is corrupt — NOT a valid empty `[]`. The
837
+ // serializer always writes JSON.stringify(conversations, null, 2) which
838
+ // produces "[]" for zero conversations, so a blank body means the file
839
+ // was truncated, partially written, or manually emptied.
840
+ const header = [
841
+ "---",
842
+ "kind: wearable-fusion",
843
+ `date: ${JSON.stringify(DATE)}`,
844
+ "sourceCount: 0",
845
+ "conversationCount: 0",
846
+ 'contentHash: "abc"',
847
+ 'bodyHash: "def"',
848
+ 'fusedAt: "2026-06-11T00:00:00.000Z"',
849
+ "---",
850
+ "",
851
+ ].join("\n");
852
+ for (const body of ["", " ", "\n\n", "\t"]) {
853
+ const parsed = parseFusionDay(`${header}${body}\n`);
854
+ assert.ok(parsed, `expected non-null parse for blank body`);
855
+ assert.equal(
856
+ parsed!.parseOk,
857
+ false,
858
+ `blank/whitespace body must be corrupt: ${JSON.stringify(body)}`,
859
+ );
860
+ assert.equal(parsed!.conversations.length, 0);
861
+ }
862
+ // A valid serialized [] remains accepted with parseOk true.
863
+ const validParsed = parseFusionDay(`${header}[]\n`);
864
+ assert.ok(validParsed);
865
+ assert.equal(validParsed!.parseOk, true);
866
+ assert.equal(validParsed!.conversations.length, 0);
867
+ });
868
+
869
+ test("reconstructFusionInputs parses a rendered transcript body", () => {
870
+ const body = [
871
+ "# limitless transcript — 2026-06-10",
872
+ "",
873
+ "## 09:00–09:20 · Morning coffee (conversation conv-1)",
874
+ "",
875
+ "**Me (you)** [09:00]: Hello world.",
876
+ "**Jane** [09:01]: Hi there.",
877
+ "",
878
+ ].join("\n");
879
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
880
+ assert.equal(parsed.length, 1);
881
+ const conv = parsed[0]!;
882
+ assert.equal(conv.conversationId, "conv-1");
883
+ assert.equal(conv.startIso, "2026-06-10T09:00:00.000Z");
884
+ assert.equal(conv.segments.length, 2);
885
+ assert.equal(conv.segments[0]!.speaker, "Me (you)");
886
+ assert.equal(conv.segments[0]!.isSelf, true);
887
+ assert.equal(conv.segments[1]!.speaker, "Jane");
888
+ assert.equal(conv.segments[1]!.isSelf, false);
889
+ });
890
+
891
+ test("reconstruct normalizes renderer 24:xx midnight clocks to valid 00:xx ISO", () => {
892
+ // en-US hour12:false renders the first wall-clock hour as 24:xx on some
893
+ // ICU builds; reconstruct must never emit an invalid/next-day timestamp.
894
+ const body = [
895
+ "# limitless transcript — 2026-06-10",
896
+ "",
897
+ "## 24:00–24:30 · Late night (conversation c1)",
898
+ "",
899
+ "**Me (you)** [24:05]: Hello world.",
900
+ "",
901
+ ].join("\n");
902
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
903
+ assert.equal(parsed.length, 1);
904
+ const conv = parsed[0]!;
905
+ // 24:00 -> 00:00 on the SAME date (not the next day); valid + parseable.
906
+ assert.equal(conv.startIso, "2026-06-10T00:00:00.000Z");
907
+ assert.equal(conv.endIso, "2026-06-10T00:30:00.000Z");
908
+ assert.ok(Number.isFinite(Date.parse(conv.startIso!)), "startIso is a valid timestamp");
909
+ assert.equal(conv.segments.length, 1);
910
+ const segIso = conv.segments[0]!.startIso!;
911
+ assert.equal(segIso, "2026-06-10T00:05:00.000Z");
912
+ assert.ok(Number.isFinite(Date.parse(segIso)), "24:05 -> valid 00:05 ISO");
913
+ });
914
+
915
+ test("reconstruct rolls a cross-midnight conversation end into the next day", () => {
916
+ const body = [
917
+ "# limitless transcript — 2026-06-10",
918
+ "",
919
+ "## 23:55–00:10 · Late call (conversation c1)",
920
+ "",
921
+ "**Me (you)** [23:58]: Still talking.",
922
+ "**Jane** [00:05]: After midnight.",
923
+ "",
924
+ ].join("\n");
925
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
926
+ assert.equal(parsed.length, 1);
927
+ const conv = parsed[0]!;
928
+ assert.equal(conv.startIso, "2026-06-10T23:55:00.000Z");
929
+ // end clock 00:10 < start clock 23:55 -> rolled to the next calendar day.
930
+ assert.equal(conv.endIso, "2026-06-11T00:10:00.000Z");
931
+ assert.ok(conv.endIso! >= conv.startIso!, "endIso must not precede startIso");
932
+ const isos = conv.segments.map((s) => s.startIso);
933
+ assert.ok(
934
+ isos.includes("2026-06-10T23:58:00.000Z"),
935
+ "pre-midnight segment stays on the rendered date",
936
+ );
937
+ assert.ok(
938
+ isos.includes("2026-06-11T00:05:00.000Z"),
939
+ "post-midnight segment rolls to the next day",
940
+ );
941
+ });
942
+
943
+ test("reconstruct rolls a post-midnight segment even when the heading end clock is missing", () => {
944
+ // A stored transcript whose heading end is unparseable (e.g. "--:--",
945
+ // the rendered form of a missing endIso) must still roll a subsequent
946
+ // segment whose clock precedes the start clock into the next calendar
947
+ // day. The roll decision is driven by the segment-vs-start comparison,
948
+ // not gated on a parseable heading end clock.
949
+ const body = [
950
+ "# limitless transcript — 2026-06-10",
951
+ "",
952
+ "## 23:55–--:-- · Late call (conversation c1)",
953
+ "",
954
+ "**Me (you)** [23:58]: Still talking.",
955
+ "**Jane** [00:05]: After midnight.",
956
+ "",
957
+ ].join("\n");
958
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
959
+ assert.equal(parsed.length, 1);
960
+ const conv = parsed[0]!;
961
+ assert.equal(conv.startIso, "2026-06-10T23:55:00.000Z");
962
+ // No parseable end clock -> no endIso is reconstructed.
963
+ assert.equal(conv.endIso, undefined);
964
+ const isos = conv.segments.map((s) => s.startIso);
965
+ assert.ok(
966
+ isos.includes("2026-06-10T23:58:00.000Z"),
967
+ "pre-midnight segment stays on the rendered date",
968
+ );
969
+ assert.ok(
970
+ isos.includes("2026-06-11T00:05:00.000Z"),
971
+ "post-midnight segment rolls to the next day despite a missing heading end",
972
+ );
973
+ // The rolled segment must sort AFTER the conversation start so the
974
+ // timeline ordering stays correct.
975
+ const rolled = conv.segments.find((s) => s.startIso === "2026-06-11T00:05:00.000Z");
976
+ assert.ok(rolled, "rolled segment present");
977
+ assert.ok(
978
+ Date.parse(rolled!.startIso!) > Date.parse(conv.startIso!),
979
+ "segment ISO must sort after the start ISO",
980
+ );
981
+ });
982
+
983
+ test("truncated high-trust text yields to a longer corroborating transcript", () => {
984
+ // bee is the MORE trusted source but its text is a clipped prefix of
985
+ // omi's full wording; the more-complete wording must win (not the trust).
986
+ const fused = fuseDay(
987
+ DATE,
988
+ inputs(
989
+ {
990
+ source: "bee",
991
+ conversations: [
992
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
993
+ {
994
+ text: "The deploy window opens at",
995
+ isWearer: true,
996
+ startIso: "2026-06-10T09:00:30.000Z",
997
+ },
998
+ ]),
999
+ ],
1000
+ },
1001
+ {
1002
+ source: "omi",
1003
+ conversations: [
1004
+ conversation("omi", "c1", "2026-06-10T09:00:00.000Z", [
1005
+ {
1006
+ text: "The deploy window opens at three and closes at five.",
1007
+ isWearer: true,
1008
+ startIso: "2026-06-10T09:00:30.000Z",
1009
+ },
1010
+ ]),
1011
+ ],
1012
+ },
1013
+ ),
1014
+ { sourceTrust: { bee: 0.95, omi: 0.6 } },
1015
+ );
1016
+ const conv = fused.conversations[0]!;
1017
+ const segment = conv.segments[0]!;
1018
+ assert.equal(
1019
+ segment.text,
1020
+ "The deploy window opens at three and closes at five.",
1021
+ );
1022
+ assert.equal(segment.provenance.reason, "more-complete");
1023
+ assert.equal(segment.provenance.source, "omi");
1024
+ assert.equal(conv.disagreements.length, 0, "a truncation is not a disagreement");
1025
+ });
1026
+
1027
+ test("distinct untimestamped same-speaker utterances do not collapse across sources", () => {
1028
+ const fused = fuseDay(
1029
+ DATE,
1030
+ inputs(
1031
+ {
1032
+ source: "limitless",
1033
+ conversations: [
1034
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
1035
+ { text: "First distinct untimestamped thought.", isWearer: true },
1036
+ ]),
1037
+ ],
1038
+ },
1039
+ {
1040
+ source: "bee",
1041
+ conversations: [
1042
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
1043
+ { text: "Second distinct untimestamped thought.", isWearer: true },
1044
+ ]),
1045
+ ],
1046
+ },
1047
+ ),
1048
+ );
1049
+ const conv = fused.conversations[0]!;
1050
+ assert.equal(
1051
+ conv.segments.length,
1052
+ 2,
1053
+ "distinct untimestamped utterances stay separate, not collapsed",
1054
+ );
1055
+ const texts = conv.segments.map((s) => s.text).sort();
1056
+ assert.deepEqual(texts, [
1057
+ "First distinct untimestamped thought.",
1058
+ "Second distinct untimestamped thought.",
1059
+ ]);
1060
+ });
1061
+
1062
+ test("repeated short utterance attaches to the closest matching group, not the first (issue #1849)", () => {
1063
+ // Source A (limitless) says "yes" twice — at 09:00:00 and 09:00:20.
1064
+ // Source B (bee) says "yes" once at 09:00:21, which is within
1065
+ // windowToleranceMs (30s) of BOTH of A's utterances. First-match would
1066
+ // attach B to the 09:00:00 group (found first); closest-match must attach
1067
+ // it to 09:00:20 (nearest), so B corroborates the utterance it actually
1068
+ // overlaps and provenance stays correct.
1069
+ const fused = fuseDay(
1070
+ DATE,
1071
+ inputs(
1072
+ {
1073
+ source: "limitless",
1074
+ conversations: [
1075
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
1076
+ { text: "yes", isWearer: true, startIso: "2026-06-10T09:00:00.000Z" },
1077
+ { text: "yes", isWearer: true, startIso: "2026-06-10T09:00:20.000Z" },
1078
+ ]),
1079
+ ],
1080
+ },
1081
+ {
1082
+ source: "bee",
1083
+ conversations: [
1084
+ conversation("bee", "c2", "2026-06-10T09:00:00.000Z", [
1085
+ { text: "yes", isWearer: true, startIso: "2026-06-10T09:00:21.000Z" },
1086
+ ]),
1087
+ ],
1088
+ },
1089
+ ),
1090
+ { sourceTrust: { limitless: 0.9, bee: 0.7 } },
1091
+ );
1092
+
1093
+ assert.equal(fused.conversations.length, 1);
1094
+ const conv = fused.conversations[0]!;
1095
+ // Two distinct utterances stay separate (same source never collapses).
1096
+ assert.equal(conv.segments.length, 2);
1097
+
1098
+ const byStart = [...conv.segments].sort((a, b) =>
1099
+ (a.startIso ?? "").localeCompare(b.startIso ?? ""),
1100
+ );
1101
+ // The early "yes" carries ONLY limitless — B did not corroborate it.
1102
+ assert.equal(byStart[0]!.startIso, "2026-06-10T09:00:00.000Z");
1103
+ assert.equal(
1104
+ byStart[0]!.provenance.alternatives.length,
1105
+ 0,
1106
+ "B must not corroborate the 09:00:00 utterance under closest-match",
1107
+ );
1108
+ // The later "yes" carries BOTH sources — B corroborated the nearest group.
1109
+ assert.equal(byStart[1]!.startIso, "2026-06-10T09:00:20.000Z");
1110
+ assert.ok(
1111
+ byStart[1]!.provenance.alternatives.some((a) => a.source === "bee"),
1112
+ "B must corroborate the 09:00:20 utterance (closest match)",
1113
+ );
1114
+ });
1115
+
1116
+ test("equal-distance candidate groups tie-break to the earlier anchor (issue #1849)", () => {
1117
+ // B's "yes" at 09:00:15 is 15s from A's 09:00:00 and 15s from A's
1118
+ // 09:00:30 — an exact tie. The documented tie-break prefers the smaller
1119
+ // anchor, so B corroborates the earlier (09:00:00) utterance.
1120
+ const fused = fuseDay(
1121
+ DATE,
1122
+ inputs(
1123
+ {
1124
+ source: "limitless",
1125
+ conversations: [
1126
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
1127
+ { text: "yes", isWearer: true, startIso: "2026-06-10T09:00:00.000Z" },
1128
+ { text: "yes", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
1129
+ ]),
1130
+ ],
1131
+ },
1132
+ {
1133
+ source: "bee",
1134
+ conversations: [
1135
+ conversation("bee", "c2", "2026-06-10T09:00:00.000Z", [
1136
+ { text: "yes", isWearer: true, startIso: "2026-06-10T09:00:15.000Z" },
1137
+ ]),
1138
+ ],
1139
+ },
1140
+ ),
1141
+ { sourceTrust: { limitless: 0.9, bee: 0.7 } },
1142
+ );
1143
+
1144
+ const conv = fused.conversations[0]!;
1145
+ assert.equal(conv.segments.length, 2);
1146
+ const byStart = [...conv.segments].sort((a, b) =>
1147
+ (a.startIso ?? "").localeCompare(b.startIso ?? ""),
1148
+ );
1149
+ // The 09:00:00 utterance gets B's corroboration (smaller anchor wins tie).
1150
+ assert.equal(byStart[0]!.startIso, "2026-06-10T09:00:00.000Z");
1151
+ assert.ok(
1152
+ byStart[0]!.provenance.alternatives.some((a) => a.source === "bee"),
1153
+ "tie-break must attach B to the earlier anchor (09:00:00)",
1154
+ );
1155
+ // The 09:00:30 utterance stays limitless-only.
1156
+ assert.equal(byStart[1]!.startIso, "2026-06-10T09:00:30.000Z");
1157
+ assert.equal(
1158
+ byStart[1]!.provenance.alternatives.length,
1159
+ 0,
1160
+ "B must not corroborate the 09:00:30 utterance",
1161
+ );
1162
+ });
1163
+
1164
+ test("distinct timestamped same-window utterances stay separate, not collapsed", () => {
1165
+ // Two sources capture genuinely different utterances inside the same
1166
+ // time window (within the alignment tolerance). They must NOT merge into
1167
+ // one segment — that would silently drop one source's content. Each
1168
+ // stays its own segment with its own provenance (consistent with the
1169
+ // untimestamped-collapse guard); the cross-source conflict is still
1170
+ // surfaced for review rather than silently resolved.
1171
+ const fused = fuseDay(
1172
+ DATE,
1173
+ inputs(
1174
+ {
1175
+ source: "limitless",
1176
+ conversations: [
1177
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
1178
+ {
1179
+ text: "Meeting with Sarah at noon.",
1180
+ isWearer: true,
1181
+ startIso: "2026-06-10T09:00:30.000Z",
1182
+ },
1183
+ ]),
1184
+ ],
1185
+ },
1186
+ {
1187
+ source: "bee",
1188
+ conversations: [
1189
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
1190
+ {
1191
+ text: "Lunch with the design team.",
1192
+ isWearer: true,
1193
+ startIso: "2026-06-10T09:00:31.000Z",
1194
+ },
1195
+ ]),
1196
+ ],
1197
+ },
1198
+ ),
1199
+ );
1200
+ const conv = fused.conversations[0]!;
1201
+ assert.equal(
1202
+ conv.segments.length,
1203
+ 2,
1204
+ "distinct same-window utterances stay separate, not collapsed",
1205
+ );
1206
+ const texts = conv.segments.map((s) => s.text).sort();
1207
+ assert.deepEqual(texts, [
1208
+ "Lunch with the design team.",
1209
+ "Meeting with Sarah at noon.",
1210
+ ]);
1211
+ // Provenance is preserved per segment — no source's content is lost.
1212
+ assert.deepEqual(
1213
+ conv.segments.map((s) => s.provenance.source).sort(),
1214
+ ["bee", "limitless"],
1215
+ );
1216
+ // The cross-source conflict is surfaced for review (not silently dropped).
1217
+ assert.equal(conv.disagreements.length, 1);
1218
+ assert.equal(conv.disagreements[0]!.candidates.length, 2);
1219
+ });
1220
+
1221
+ test("cross-segment ASR disagreement detected for differently-labeled self speakers (#1849)", () => {
1222
+ // Two sources both record the WEARER but label them differently:
1223
+ // limitless uses the default "Me (you)" while bee has an override
1224
+ // naming the wearer "Alex" -> "Alex (you)". Both normalize to the SAME
1225
+ // speakerKey ("self"), so grouping treats them as one identity. The
1226
+ // cross-segment ASR-disagreement pass MUST use that same identity — not
1227
+ // the raw display label — or a real same-window ASR conflict between
1228
+ // them is silently missed and both confidences wrongly stay high.
1229
+ const beeRegistry = {
1230
+ ...REGISTRY,
1231
+ speakers: {
1232
+ "bee:user": {
1233
+ name: "Alex",
1234
+ isSelf: true,
1235
+ updatedAt: "2026-06-10T00:00:00.000Z",
1236
+ },
1237
+ },
1238
+ };
1239
+ const limitless = fusionInputsFromConversations(
1240
+ "limitless",
1241
+ [
1242
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
1243
+ {
1244
+ text: "Meeting with Sarah at noon.",
1245
+ isWearer: true,
1246
+ startIso: "2026-06-10T09:00:30.000Z",
1247
+ },
1248
+ ]),
1249
+ ],
1250
+ REGISTRY,
1251
+ );
1252
+ const bee = fusionInputsFromConversations(
1253
+ "bee",
1254
+ [
1255
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
1256
+ {
1257
+ text: "Lunch with the design team.",
1258
+ isWearer: true,
1259
+ startIso: "2026-06-10T09:00:31.000Z",
1260
+ },
1261
+ ]),
1262
+ ],
1263
+ beeRegistry,
1264
+ );
1265
+ const fused = fuseDay(DATE, [...limitless, ...bee]);
1266
+ const conv = fused.conversations[0]!;
1267
+ // The two utterances disagree on text, so they stay separate segments.
1268
+ assert.equal(conv.segments.length, 2);
1269
+ // Precondition: different display labels but the same self identity.
1270
+ assert.ok(conv.segments.every((s) => s.isSelf), "both are the wearer");
1271
+ assert.notEqual(
1272
+ conv.segments[0]!.speaker,
1273
+ conv.segments[1]!.speaker,
1274
+ "the wearer is labeled differently across the two sources",
1275
+ );
1276
+ // The cross-segment ASR-text disagreement IS recorded (was skipped).
1277
+ const disagreement = conv.disagreements.find((d) => d.kind === "asr-text");
1278
+ assert.ok(
1279
+ disagreement,
1280
+ "a same-self different-label ASR disagreement is recorded, not skipped",
1281
+ );
1282
+ assert.equal(disagreement!.candidates.length, 2);
1283
+ // Both involved segments carry lowered confidence.
1284
+ assert.ok(
1285
+ conv.segments.every((s) => s.confidence < 0.8),
1286
+ "both disagreeing segments carry lowered confidence",
1287
+ );
1288
+ });
1289
+
1290
+ test("differently-labeled self speakers that agree still merge into one segment (#1849)", () => {
1291
+ // Same two-source setup as above, but the text AGREES. Since both
1292
+ // normalize to speakerKey "self" and the text corroborates, they merge
1293
+ // into ONE segment (no cross-segment disagreement) — the fix must not
1294
+ // over-trigger and split an agreeing utterance.
1295
+ const beeRegistry = {
1296
+ ...REGISTRY,
1297
+ speakers: {
1298
+ "bee:user": {
1299
+ name: "Alex",
1300
+ isSelf: true,
1301
+ updatedAt: "2026-06-10T00:00:00.000Z",
1302
+ },
1303
+ },
1304
+ };
1305
+ const text = "Let's ship the launch on Friday.";
1306
+ const limitless = fusionInputsFromConversations(
1307
+ "limitless",
1308
+ [
1309
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
1310
+ { text, isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
1311
+ ]),
1312
+ ],
1313
+ REGISTRY,
1314
+ );
1315
+ const bee = fusionInputsFromConversations(
1316
+ "bee",
1317
+ [
1318
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
1319
+ { text, isWearer: true, startIso: "2026-06-10T09:00:31.000Z" },
1320
+ ]),
1321
+ ],
1322
+ beeRegistry,
1323
+ );
1324
+ const fused = fuseDay(DATE, [...limitless, ...bee]);
1325
+ const conv = fused.conversations[0]!;
1326
+ // The agreeing utterances merge into ONE segment, not two.
1327
+ assert.equal(conv.segments.length, 1);
1328
+ assert.equal(conv.segments[0]!.text, text);
1329
+ assert.equal(conv.segments[0]!.isSelf, true);
1330
+ // No cross-segment ASR disagreement for corroborating text.
1331
+ const asrDisagreement = conv.disagreements.find((d) => d.kind === "asr-text");
1332
+ assert.equal(
1333
+ asrDisagreement,
1334
+ undefined,
1335
+ "agreeing utterances produce no cross-segment ASR disagreement",
1336
+ );
1337
+ // Corroboration BOOSTS confidence — it is not lowered.
1338
+ assert.ok(
1339
+ conv.segments[0]!.confidence > 0.8,
1340
+ "corroborated utterance keeps boosted confidence",
1341
+ );
1342
+ });
1343
+
1344
+ test("equal-time same-source utterances keep original transcript order, not label order", () => {
1345
+ // From stored transcripts, segment times are minute-precision, so several
1346
+ // utterances from one source can share an identical anchorMs. The
1347
+ // alignment tie-break must preserve the ORIGINAL transcript sequence:
1348
+ // sorting equal-time segments by speaker label would scramble them
1349
+ // ("Amy" before "Zoe" even though Zoe spoke first).
1350
+ const fused = fuseDay(
1351
+ DATE,
1352
+ inputs(
1353
+ {
1354
+ source: "limitless",
1355
+ conversations: [
1356
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
1357
+ {
1358
+ text: "Zoe spoke first.",
1359
+ speakerName: "Zoe",
1360
+ startIso: "2026-06-10T09:00:00.000Z",
1361
+ },
1362
+ {
1363
+ text: "Amy spoke second.",
1364
+ speakerName: "Amy",
1365
+ startIso: "2026-06-10T09:00:00.000Z",
1366
+ },
1367
+ ]),
1368
+ ],
1369
+ },
1370
+ ),
1371
+ );
1372
+ const conv = fused.conversations[0]!;
1373
+ assert.equal(conv.segments.length, 2);
1374
+ assert.deepEqual(
1375
+ conv.segments.map((s) => s.text),
1376
+ ["Zoe spoke first.", "Amy spoke second."],
1377
+ "equal-time utterances keep original transcript order, not label order",
1378
+ );
1379
+ assert.deepEqual(conv.segments.map((s) => s.speaker), ["Zoe", "Amy"]);
1380
+ });
1381
+
1382
+ test("untimestamped same-source utterances keep original transcript order", () => {
1383
+ // Missing times all share the missing-anchor group; the tie-break must
1384
+ // still preserve original transcript sequence rather than speaker label.
1385
+ const fused = fuseDay(
1386
+ DATE,
1387
+ inputs(
1388
+ {
1389
+ source: "bee",
1390
+ conversations: [
1391
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
1392
+ { text: "Zoe untimestamped first.", speakerName: "Zoe" },
1393
+ { text: "Amy untimestamped second.", speakerName: "Amy" },
1394
+ ]),
1395
+ ],
1396
+ },
1397
+ ),
1398
+ );
1399
+ const conv = fused.conversations[0]!;
1400
+ assert.equal(conv.segments.length, 2);
1401
+ assert.deepEqual(
1402
+ conv.segments.map((s) => s.text),
1403
+ ["Zoe untimestamped first.", "Amy untimestamped second."],
1404
+ );
1405
+ assert.deepEqual(conv.segments.map((s) => s.speaker), ["Zoe", "Amy"]);
1406
+ });
1407
+
1408
+ test("raw diarization keys like SPEAKER_00 are treated as generic speakers", () => {
1409
+ const fused = fuseDay(
1410
+ DATE,
1411
+ inputs(
1412
+ {
1413
+ source: "omi",
1414
+ conversations: [
1415
+ conversation("omi", "c1", "2026-06-10T09:00:00.000Z", [
1416
+ {
1417
+ text: "Hello there.",
1418
+ speakerKey: "SPEAKER_00",
1419
+ startIso: "2026-06-10T09:00:30.000Z",
1420
+ },
1421
+ ]),
1422
+ ],
1423
+ },
1424
+ ),
1425
+ );
1426
+ const conv = fused.conversations[0]!;
1427
+ const speaker = conv.speakers.find((s) => !s.isSelf);
1428
+ assert.ok(speaker, "a non-self speaker is present");
1429
+ assert.equal(speaker!.label, "SPEAKER_00");
1430
+ assert.ok(
1431
+ speaker!.confidence <= 0.5,
1432
+ "raw diarization key is generic (<=0.5), not a confident attribution",
1433
+ );
1434
+ });
1435
+
1436
+ test("reconstruct round-trips through the real composeDayTranscriptBody renderer", () => {
1437
+ // Feed the actual renderer's output through reconstruct (not a hand-
1438
+ // written body) so renderer/parser drift is caught. The renderer emits
1439
+ // minute-precision clocks, so reconstructed ISOs zero the seconds.
1440
+ const conversations = [
1441
+ conversation(
1442
+ "limitless",
1443
+ "c1",
1444
+ "2026-06-10T09:00:00.000Z",
1445
+ [
1446
+ { text: "Hello world.", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
1447
+ { text: "Good to see you.", startIso: "2026-06-10T09:01:00.000Z" },
1448
+ ],
1449
+ { title: "Morning sync", endIso: "2026-06-10T09:10:00.000Z" },
1450
+ ),
1451
+ ];
1452
+ const body = composeDayTranscriptBody("limitless", DATE, "UTC", conversations, REGISTRY);
1453
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body, escaped: true }]);
1454
+ assert.equal(parsed.length, 1);
1455
+ const conv = parsed[0]!;
1456
+ assert.equal(conv.conversationId, "c1");
1457
+ assert.equal(conv.startIso, "2026-06-10T09:00:00.000Z");
1458
+ assert.equal(conv.endIso, "2026-06-10T09:10:00.000Z");
1459
+ assert.equal(conv.title, "Morning sync");
1460
+ assert.equal(conv.segments.length, 2);
1461
+ assert.equal(conv.segments[0]!.text, "Hello world.");
1462
+ assert.equal(conv.segments[0]!.isSelf, true);
1463
+ assert.equal(conv.segments[0]!.startIso, "2026-06-10T09:00:00.000Z");
1464
+ assert.equal(conv.segments[1]!.text, "Good to see you.");
1465
+ assert.equal(conv.segments[1]!.isSelf, false);
1466
+ assert.equal(conv.segments[1]!.startIso, "2026-06-10T09:01:00.000Z");
1467
+ });
1468
+
1469
+ test("equal-key comparator inputs keep stable input order (deterministic across runs)", () => {
1470
+ // Two cross-source conflicts where the bee source contributes TWO same-
1471
+ // speaker, same-length cands. bee1 and bee2 tie on every comparator key
1472
+ // (trust, text length, source) — only a STABLE secondary key keeps them
1473
+ // in input order. A comparator that returns nonzero for equal items (the
1474
+ // `a < b ? -1 : 1` antipattern) leaves their order undefined.
1475
+ const textSeed = "Limitless seed text.";
1476
+ const textBee1 = "Bee conflict one xyz.";
1477
+ const textBee2 = "Bee conflict two xyz.";
1478
+ const build = () =>
1479
+ fuseDay(
1480
+ DATE,
1481
+ inputs(
1482
+ {
1483
+ source: "limitless",
1484
+ conversations: [
1485
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
1486
+ {
1487
+ text: textSeed,
1488
+ speakerName: "Jane",
1489
+ startIso: "2026-06-10T09:00:30.000Z",
1490
+ },
1491
+ ]),
1492
+ ],
1493
+ },
1494
+ {
1495
+ source: "bee",
1496
+ conversations: [
1497
+ conversation("bee", "c1", "2026-06-10T09:00:05.000Z", [
1498
+ {
1499
+ text: textBee1,
1500
+ speakerName: "Jane",
1501
+ startIso: "2026-06-10T09:00:30.000Z",
1502
+ },
1503
+ {
1504
+ text: textBee2,
1505
+ speakerName: "Jane",
1506
+ startIso: "2026-06-10T09:00:30.000Z",
1507
+ },
1508
+ ]),
1509
+ ],
1510
+ },
1511
+ ),
1512
+ );
1513
+
1514
+ const fused = build();
1515
+ // Deterministic: re-running produces identical output.
1516
+ assert.deepEqual(build(), fused);
1517
+
1518
+ const conv = fused.conversations[0]!;
1519
+ // The three distinct same-window utterances stay separate and surface one
1520
+ // cross-segment ASR conflict.
1521
+ assert.equal(conv.segments.length, 3);
1522
+ const disagreement = conv.disagreements.find((d) => d.kind === "asr-text");
1523
+ assert.ok(disagreement, "a cross-segment asr-text disagreement is recorded");
1524
+ const values = disagreement!.candidates.map((c) => c.value);
1525
+ assert.equal(values.length, 3);
1526
+ // Stable: the two equal-key bee cands keep input order (bee1 before bee2),
1527
+ // never swapped by an unstable comparator.
1528
+ assert.ok(
1529
+ values.indexOf(textBee1) < values.indexOf(textBee2),
1530
+ "equal-key cands keep stable input order",
1531
+ );
1532
+ });
1533
+
1534
+ test("same window + same text + different speaker fuses to one segment with a recorded speaker conflict", () => {
1535
+ // Two sources capture the same utterance in the same window but DISAGREE
1536
+ // on the speaker (Jane vs John). The same-speaker gate must not split
1537
+ // these into two segments: they corroborate on time+text, so they fuse
1538
+ // into ONE utterance and the speaker disagreement is recorded with
1539
+ // provenance for each label. (The r2 separation is about DIFFERENT text;
1540
+ // this is SAME text, different speaker.)
1541
+ const text = "Let's ship the launch on Friday.";
1542
+ const fused = fuseDay(
1543
+ DATE,
1544
+ inputs(
1545
+ {
1546
+ source: "limitless",
1547
+ conversations: [
1548
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
1549
+ { text, speakerName: "Jane", startIso: "2026-06-10T09:00:30.000Z" },
1550
+ ]),
1551
+ ],
1552
+ },
1553
+ {
1554
+ source: "bee",
1555
+ conversations: [
1556
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
1557
+ { text, speakerName: "John", startIso: "2026-06-10T09:00:30.000Z" },
1558
+ ]),
1559
+ ],
1560
+ },
1561
+ ),
1562
+ { sourceTrust: { limitless: 0.9 } },
1563
+ );
1564
+ const conv = fused.conversations[0]!;
1565
+ // ONE fused segment — not two separate ones.
1566
+ assert.equal(conv.segments.length, 1);
1567
+ const segment = conv.segments[0]!;
1568
+ assert.equal(segment.text, text);
1569
+ // The higher-trust source's attribution wins provisionally.
1570
+ assert.equal(segment.speaker, "Jane");
1571
+ assert.equal(segment.isSelf, false);
1572
+ // A speaker conflict is recorded (not silently dropped).
1573
+ const speakerDisagreement = conv.disagreements.find(
1574
+ (d) => d.kind === "speaker",
1575
+ );
1576
+ assert.ok(speakerDisagreement, "a speaker disagreement is recorded");
1577
+ const labeled = speakerDisagreement!.candidates.map(
1578
+ (c) => `${c.source}=${c.value}`,
1579
+ );
1580
+ assert.deepEqual([...labeled].sort(), ["bee=John", "limitless=Jane"]);
1581
+ assert.deepEqual(speakerDisagreement!.provisional, {
1582
+ source: "limitless",
1583
+ value: "Jane",
1584
+ });
1585
+ // Confidence is lowered by the unresolved speaker conflict.
1586
+ assert.ok(segment.confidence < 0.8);
1587
+ // No attribution is lost: both labels still appear in the speaker list.
1588
+ const labels = conv.speakers.filter((s) => !s.isSelf).map((s) => s.label);
1589
+ assert.ok(
1590
+ labels.includes("Jane") && labels.includes("John"),
1591
+ "both speaker labels are retained",
1592
+ );
1593
+ });
1594
+
1595
+ test("cluster interval spans to the latest segment start when the conversation end is missing", () => {
1596
+ // A stored transcript renders a missing conversation end as "--:--".
1597
+ // reconstructFusionInputs rebuilds segments with only a startIso, so the
1598
+ // cluster interval must DERIVE its end from the latest segment start —
1599
+ // not collapse to a zero-length point at the conversation start. A later
1600
+ // conversation that sits within the proximity gap of the LAST segment
1601
+ // (but far past the start) must join the same cluster.
1602
+ const body = [
1603
+ "# limitless transcript — 2026-06-10",
1604
+ "",
1605
+ "## 09:00–--:-- · Morning (conversation c1)",
1606
+ "",
1607
+ "**Me (you)** [09:00]: First.",
1608
+ "**Jane** [09:15]: Second.",
1609
+ "**Jane** [09:30]: Third.",
1610
+ "",
1611
+ ].join("\n");
1612
+ const reconstructed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
1613
+ assert.equal(reconstructed.length, 1);
1614
+ assert.equal(reconstructed[0]!.endIso, undefined, "no end clock -> no endIso");
1615
+ // bee starts 3 min after the last limitless segment (09:33) — within the
1616
+ // 5-minute gap of 09:30, so it clusters. The old zero-length bug measured
1617
+ // the gap from 09:00 (33 min) and split them into two clusters.
1618
+ const bee: FusionConversationInput = {
1619
+ source: "bee",
1620
+ conversationId: "c1",
1621
+ startIso: "2026-06-10T09:33:00.000Z",
1622
+ endIso: "2026-06-10T09:40:00.000Z",
1623
+ segments: [
1624
+ { speaker: "Bee", isSelf: false, text: "Follow up.", startIso: "2026-06-10T09:33:00.000Z" },
1625
+ ],
1626
+ };
1627
+ const clusters = clusterConversations([...reconstructed, bee]);
1628
+ assert.equal(
1629
+ clusters.length,
1630
+ 1,
1631
+ "missing-end conversation clusters by its last segment start, not its conversation start",
1632
+ );
1633
+ });
1634
+
1635
+ test("missing-end interval does not over-extend: a neighbor past the gap of the last segment stays separate", () => {
1636
+ // The derived interval must be exactly [start, last segment start], not
1637
+ // unbounded. A neighbor 10 min past the last segment (> 5 min gap) must
1638
+ // NOT cluster with the missing-end conversation.
1639
+ const body = [
1640
+ "# limitless transcript — 2026-06-10",
1641
+ "",
1642
+ "## 09:00–--:-- · Morning (conversation c1)",
1643
+ "",
1644
+ "**Me (you)** [09:00]: First.",
1645
+ "**Jane** [09:30]: Last segment.",
1646
+ "",
1647
+ ].join("\n");
1648
+ const reconstructed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
1649
+ const bee: FusionConversationInput = {
1650
+ source: "bee",
1651
+ conversationId: "c1",
1652
+ startIso: "2026-06-10T09:40:00.000Z",
1653
+ endIso: "2026-06-10T09:50:00.000Z",
1654
+ segments: [
1655
+ { speaker: "Bee", isSelf: false, text: "Later.", startIso: "2026-06-10T09:40:00.000Z" },
1656
+ ],
1657
+ };
1658
+ const clusters = clusterConversations([...reconstructed, bee]);
1659
+ assert.equal(clusters.length, 2, "10 min past the last segment is outside the gap");
1660
+ });
1661
+
1662
+ test("cluster interval spans rolled cross-midnight segments when the conversation end is missing", () => {
1663
+ // Cross-midnight segments in a missing-end conversation are rolled to the
1664
+ // next calendar day by reconstruct; the cluster interval end must reach
1665
+ // the rolled last segment so a post-midnight neighbor clusters correctly.
1666
+ const body = [
1667
+ "# limitless transcript — 2026-06-10",
1668
+ "",
1669
+ "## 23:55–--:-- · Late call (conversation c1)",
1670
+ "",
1671
+ "**Me (you)** [23:58]: Still talking.",
1672
+ "**Jane** [00:05]: After midnight.",
1673
+ "",
1674
+ ].join("\n");
1675
+ const reconstructed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
1676
+ assert.equal(reconstructed.length, 1);
1677
+ assert.equal(reconstructed[0]!.endIso, undefined);
1678
+ // The latest segment rolled to 2026-06-11T00:05; a bee conversation at
1679
+ // 00:08 (3 min later) must cluster with it.
1680
+ const bee: FusionConversationInput = {
1681
+ source: "bee",
1682
+ conversationId: "c1",
1683
+ startIso: "2026-06-11T00:08:00.000Z",
1684
+ endIso: "2026-06-11T00:15:00.000Z",
1685
+ segments: [
1686
+ { speaker: "Bee", isSelf: false, text: "Late.", startIso: "2026-06-11T00:08:00.000Z" },
1687
+ ],
1688
+ };
1689
+ const clusters = clusterConversations([...reconstructed, bee]);
1690
+ assert.equal(
1691
+ clusters.length,
1692
+ 1,
1693
+ "cross-midnight missing-end conversation clusters by its rolled last segment",
1694
+ );
1695
+ });
1696
+
1697
+ test("derived interval uses max(segment ends or segment starts) for mixed-segment inputs", () => {
1698
+ // Directly constructed input proving the coherent model: when the
1699
+ // conversation end is absent and segments carry a MIX of endIso and
1700
+ // startIso, the window end is the maximum segment EXTENT (each segment's
1701
+ // end when known, else its start). Here the second segment's start
1702
+ // (09:20) must beat the first segment's end (09:10).
1703
+ const mixed: FusionConversationInput = {
1704
+ source: "limitless",
1705
+ conversationId: "c1",
1706
+ startIso: "2026-06-10T09:00:00.000Z",
1707
+ segments: [
1708
+ {
1709
+ speaker: "Jane",
1710
+ isSelf: false,
1711
+ text: "First.",
1712
+ startIso: "2026-06-10T09:00:00.000Z",
1713
+ endIso: "2026-06-10T09:10:00.000Z",
1714
+ },
1715
+ { speaker: "Jane", isSelf: false, text: "Second.", startIso: "2026-06-10T09:20:00.000Z" },
1716
+ ],
1717
+ };
1718
+ const bee: FusionConversationInput = {
1719
+ source: "bee",
1720
+ conversationId: "c1",
1721
+ startIso: "2026-06-10T09:23:00.000Z",
1722
+ endIso: "2026-06-10T09:30:00.000Z",
1723
+ segments: [
1724
+ { speaker: "Bee", isSelf: false, text: "After.", startIso: "2026-06-10T09:23:00.000Z" },
1725
+ ],
1726
+ };
1727
+ // bee at 09:23 is within the 5-min gap of the derived end 09:20 -> one
1728
+ // cluster. If the interval had stopped at the first segment's END (09:10)
1729
+ // the gap would be 13 min and they would split.
1730
+ const clusters = clusterConversations([mixed, bee]);
1731
+ assert.equal(clusters.length, 1, "max segment extent (start beats earlier end) spans the window");
1732
+ });
1733
+
1734
+ test("derived interval is clamped to end >= start for a missing-end conversation", () => {
1735
+ // Guarantee the [start, end] window is always valid: a conversation with
1736
+ // no end and only segments that somehow parse before the start still
1737
+ // yields end >= start (a point interval), never a negative-length window.
1738
+ const anomalous: FusionConversationInput = {
1739
+ source: "limitless",
1740
+ conversationId: "c1",
1741
+ startIso: "2026-06-10T09:00:00.000Z",
1742
+ segments: [
1743
+ { speaker: "Jane", isSelf: false, text: "Early.", startIso: "2026-06-10T08:30:00.000Z" },
1744
+ ],
1745
+ };
1746
+ const clusters = clusterConversations([anomalous]);
1747
+ assert.equal(clusters.length, 1);
1748
+ // The conversation is emitted (not dropped) and forms a valid cluster.
1749
+ assert.equal(clusters[0]!.length, 1);
1750
+ });
1751
+
1752
+ test("fused conversation end spans a missing-end source's later segments, not the short explicit end (issue #1849)", () => {
1753
+ // Source A (bee) carries an explicit SHORT end (09:05); source B
1754
+ // (limitless) is a stored transcript whose heading end renders as
1755
+ // "--:--" (no endIso) with later utterances — its last segment starts at
1756
+ // 09:30. Reconstructed segments carry only a startIso, so a fused end
1757
+ // derived from conversation-level ends ALONE would stop at A's 09:05,
1758
+ // clipping the conversation before B's later segments. The fused end must
1759
+ // span B's last segment start (issue #1849: segment-extent derivation).
1760
+ const bBody = [
1761
+ "# limitless transcript — 2026-06-10",
1762
+ "",
1763
+ "## 09:00–--:-- · Sync (conversation c1)",
1764
+ "",
1765
+ "**Me (you)** [09:00]: Plan A.",
1766
+ "**Jane** [09:30]: Later topic.",
1767
+ "",
1768
+ ].join("\n");
1769
+ const bInputs = reconstructFusionInputs(DATE, [{ source: "limitless", body: bBody }]);
1770
+ assert.equal(bInputs.length, 1);
1771
+ assert.equal(bInputs[0]!.endIso, undefined, "B has no heading end");
1772
+ const a: FusionConversationInput = {
1773
+ source: "bee",
1774
+ conversationId: "c1",
1775
+ startIso: "2026-06-10T09:00:00.000Z",
1776
+ endIso: "2026-06-10T09:05:00.000Z",
1777
+ segments: [
1778
+ { speaker: "Me (you)", isSelf: true, text: "Plan A.", startIso: "2026-06-10T09:00:00.000Z" },
1779
+ ],
1780
+ };
1781
+ const fused = fuseDay(DATE, [...bInputs, a]);
1782
+ assert.equal(fused.conversations.length, 1, "A + B cluster into one conversation");
1783
+ // B's "Later topic." (09:30) is B-only and must survive as a segment.
1784
+ assert.ok(
1785
+ fused.conversations[0]!.segments.some((s) => s.text === "Later topic."),
1786
+ "B's later segment is preserved",
1787
+ );
1788
+ assert.equal(
1789
+ fused.conversations[0]!.endIso,
1790
+ "2026-06-10T09:30:00.000Z",
1791
+ "fused end spans B's last segment start, not A's short explicit end",
1792
+ );
1793
+ });
1794
+
1795
+ test("fused conversation end keeps the latest explicit conversation end when no missing-end source extends past it (issue #1849)", () => {
1796
+ // No over-extension: when every source carries an explicit conversation
1797
+ // end and all segments sit within those ends, the segment-extent fallback
1798
+ // must NOT override the latest explicit conversation end. Here A ends at
1799
+ // 09:40 and B at 09:35; segment ends are far earlier (09:00:30 / 09:05:30),
1800
+ // so the fused end stays 09:40.
1801
+ const a: FusionConversationInput = {
1802
+ source: "bee",
1803
+ conversationId: "c1",
1804
+ startIso: "2026-06-10T09:00:00.000Z",
1805
+ endIso: "2026-06-10T09:40:00.000Z",
1806
+ segments: [
1807
+ {
1808
+ speaker: "Me (you)",
1809
+ isSelf: true,
1810
+ text: "Hello.",
1811
+ startIso: "2026-06-10T09:00:00.000Z",
1812
+ endIso: "2026-06-10T09:00:30.000Z",
1813
+ },
1814
+ ],
1815
+ };
1816
+ const b: FusionConversationInput = {
1817
+ source: "limitless",
1818
+ conversationId: "c1",
1819
+ startIso: "2026-06-10T09:05:00.000Z",
1820
+ endIso: "2026-06-10T09:35:00.000Z",
1821
+ segments: [
1822
+ {
1823
+ speaker: "Jane",
1824
+ isSelf: false,
1825
+ text: "Hi back.",
1826
+ startIso: "2026-06-10T09:05:00.000Z",
1827
+ endIso: "2026-06-10T09:05:30.000Z",
1828
+ },
1829
+ ],
1830
+ };
1831
+ const fused = fuseDay(DATE, [a, b]);
1832
+ assert.equal(fused.conversations.length, 1);
1833
+ assert.equal(
1834
+ fused.conversations[0]!.endIso,
1835
+ "2026-06-10T09:40:00.000Z",
1836
+ "latest explicit conversation end wins; segment extents do not shrink it",
1837
+ );
1838
+ });
1839
+
1840
+ test("fused conversation end is clamped to >= start when a missing-end source's segments precede the start (issue #1849)", () => {
1841
+ // Mirrors the cluster interval clamp (cluster.ts): a missing-end
1842
+ // conversation whose only segment parses BEFORE the conversation start
1843
+ // must still yield a fused end >= start — never a negative-length window.
1844
+ const anomalous: FusionConversationInput = {
1845
+ source: "limitless",
1846
+ conversationId: "c1",
1847
+ startIso: "2026-06-10T09:00:00.000Z",
1848
+ segments: [
1849
+ { speaker: "Jane", isSelf: false, text: "Early.", startIso: "2026-06-10T08:30:00.000Z" },
1850
+ ],
1851
+ };
1852
+ const fused = fuseDay(DATE, [anomalous]);
1853
+ assert.equal(fused.conversations.length, 1);
1854
+ const conv = fused.conversations[0]!;
1855
+ assert.equal(conv.endIso, "2026-06-10T09:00:00.000Z", "end clamped to the cluster start");
1856
+ assert.ok(Date.parse(conv.endIso!) >= Date.parse(conv.startIso!));
1857
+ });
1858
+
1859
+ test("fused conversation end spans rolled cross-midnight segments of a missing-end source (issue #1849)", () => {
1860
+ // A missing-end ("--:--") conversation whose segments wrap past midnight
1861
+ // are rolled to the next calendar day by reconstruct. The fused end must
1862
+ // reach the rolled last segment (2026-06-11T00:05), not a short explicit
1863
+ // end from a corroborating source (bee ends at 00:00).
1864
+ const bBody = [
1865
+ "# limitless transcript — 2026-06-10",
1866
+ "",
1867
+ "## 23:55–--:-- · Late call (conversation c1)",
1868
+ "",
1869
+ "**Me (you)** [23:58]: Still talking.",
1870
+ "**Jane** [00:05]: After midnight.",
1871
+ "",
1872
+ ].join("\n");
1873
+ const bInputs = reconstructFusionInputs(DATE, [{ source: "limitless", body: bBody }]);
1874
+ assert.equal(bInputs[0]!.endIso, undefined);
1875
+ const a: FusionConversationInput = {
1876
+ source: "bee",
1877
+ conversationId: "c1",
1878
+ startIso: "2026-06-10T23:55:00.000Z",
1879
+ endIso: "2026-06-11T00:00:00.000Z",
1880
+ segments: [
1881
+ { speaker: "Me (you)", isSelf: true, text: "Still talking.", startIso: "2026-06-10T23:58:00.000Z" },
1882
+ ],
1883
+ };
1884
+ const fused = fuseDay(DATE, [...bInputs, a]);
1885
+ assert.equal(fused.conversations.length, 1);
1886
+ assert.equal(
1887
+ fused.conversations[0]!.endIso,
1888
+ "2026-06-11T00:05:00.000Z",
1889
+ "fused end spans B's rolled post-midnight segment start",
1890
+ );
1891
+ });
1892
+
1893
+ /** True when every timestamped segment precedes any later-timestamped one
1894
+ * and untimestamped segments trail (non-decreasing startIso). The fused
1895
+ * output must always satisfy this — the group-anchor emission + defensive
1896
+ * final sort guarantee it. */
1897
+ function isChronologicallySorted(
1898
+ segments: { startIso?: string }[],
1899
+ ): boolean {
1900
+ let last = -Infinity;
1901
+ for (const seg of segments) {
1902
+ const ms = seg.startIso !== undefined ? Date.parse(seg.startIso) : NaN;
1903
+ if (!Number.isFinite(ms)) continue; // untimestamped trails; checked elsewhere
1904
+ if (ms < last) return false;
1905
+ last = ms;
1906
+ }
1907
+ return true;
1908
+ }
1909
+
1910
+ test("fused segment keeps the group anchor clock, not the higher-trust source's later clock (issue #1849 chronology)", () => {
1911
+ // bee (low-trust) captures "yes" at 09:00:00; limitless (high-trust)
1912
+ // captures a DIFFERENT utterance "standup notes" at 09:00:10, then
1913
+ // corroborates "yes" at 09:00:25 (within the 30s tolerance of 09:00:00).
1914
+ // The fused "yes" group's anchor is 09:00:00; the high-trust chosen
1915
+ // member's clock is 09:00:25. Emitting chosen.startIso would place the
1916
+ // "yes" segment at 09:00:25 — AFTER the 09:00:10 "standup notes" segment
1917
+ // — yet the group sorts at the 09:00:00 anchor (first), so the printed
1918
+ // timeline would read 09:00:25 before 09:00:10: corrupted chronology.
1919
+ // The fix emits the anchor clock for the timeline position while the
1920
+ // high-trust source still provides the text; its original clock is kept
1921
+ // in provenance for traceability.
1922
+ const fused = fuseDay(
1923
+ DATE,
1924
+ inputs(
1925
+ {
1926
+ source: "bee",
1927
+ conversations: [
1928
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
1929
+ { text: "yes", isWearer: true, startIso: "2026-06-10T09:00:00.000Z" },
1930
+ ]),
1931
+ ],
1932
+ },
1933
+ {
1934
+ source: "limitless",
1935
+ conversations: [
1936
+ conversation("limitless", "c2", "2026-06-10T09:00:00.000Z", [
1937
+ {
1938
+ text: "standup notes",
1939
+ isWearer: true,
1940
+ startIso: "2026-06-10T09:00:10.000Z",
1941
+ },
1942
+ { text: "yes", isWearer: true, startIso: "2026-06-10T09:00:25.000Z" },
1943
+ ]),
1944
+ ],
1945
+ },
1946
+ ),
1947
+ { sourceTrust: { bee: 0.6, limitless: 0.9 } },
1948
+ );
1949
+
1950
+ const conv = fused.conversations[0]!;
1951
+ // The two "yes" utterances fuse; "standup notes" stays separate.
1952
+ assert.equal(conv.segments.length, 2);
1953
+
1954
+ const yes = conv.segments.find((s) => s.text === "yes")!;
1955
+ const notes = conv.segments.find((s) => s.text === "standup notes")!;
1956
+ assert.ok(yes, "the fused yes segment is present");
1957
+ assert.ok(notes, "the standup notes segment is present");
1958
+
1959
+ // Timeline position is the GROUP ANCHOR (09:00:00), not the chosen
1960
+ // source's 09:00:25 clock.
1961
+ assert.equal(yes.startIso, "2026-06-10T09:00:00.000Z");
1962
+ assert.equal(notes.startIso, "2026-06-10T09:00:10.000Z");
1963
+
1964
+ // The higher-trust source (limitless) still provides the TEXT — provenance
1965
+ // records it — but the emitted chronology uses the anchor, so the
1966
+ // high-trust "yes" does NOT jump ahead of the 09:00:10 segment.
1967
+ assert.equal(yes.provenance.source, "limitless");
1968
+ assert.equal(yes.provenance.reason, "higher-trust");
1969
+ // The source's ORIGINAL recording clock is preserved in provenance.
1970
+ assert.equal(
1971
+ yes.provenance.sourceStartIso,
1972
+ "2026-06-10T09:00:25.000Z",
1973
+ "provenance keeps the higher-trust source's original time",
1974
+ );
1975
+
1976
+ // The fused "yes" sits at the anchor (09:00:00) which is BEFORE the
1977
+ // 09:00:10 "standup notes" — chronologically correct. Before the fix the
1978
+ // high-trust segment's 09:00:25 clock would have printed it after 09:10.
1979
+ assert.ok(
1980
+ conv.segments.indexOf(yes) < conv.segments.indexOf(notes),
1981
+ "the anchor-clock fused segment precedes the later intervening segment",
1982
+ );
1983
+ assert.ok(
1984
+ Date.parse(yes.startIso!) <= Date.parse(notes.startIso!),
1985
+ "emitted timestamps are non-decreasing across the two segments",
1986
+ );
1987
+
1988
+ // Final-output-sorted assertion: the whole fused conversation is
1989
+ // chronologically ordered by startIso.
1990
+ assert.ok(
1991
+ isChronologicallySorted(conv.segments),
1992
+ "the fused output is sorted by timestamp (non-decreasing startIso)",
1993
+ );
1994
+ });
1995
+
1996
+ test("equal-anchor fused segments keep their source/cluster sequence, not trust order (final sort stability)", () => {
1997
+ // Two distinct utterances from two sources at the SAME timestamp land at
1998
+ // the same anchor. Clustering orders conversations by (start, source, id),
1999
+ // so bee precedes limitless here regardless of the inputs() call order.
2000
+ // The defensive final sort must keep that deterministic sequence via the
2001
+ // pre-sort index tie-break — NOT reorder by trust. bee is the LOWER-trust
2002
+ // source, so a trust-based tie-break would put limitless first; the test
2003
+ // asserts bee stays first, proving the tie-break is sequence, not trust.
2004
+ const build = () =>
2005
+ fuseDay(
2006
+ DATE,
2007
+ inputs(
2008
+ {
2009
+ source: "limitless",
2010
+ conversations: [
2011
+ conversation("limitless", "c1", "2026-06-10T09:00:00.000Z", [
2012
+ {
2013
+ text: "Limitless same-anchor utterance.",
2014
+ isWearer: true,
2015
+ startIso: "2026-06-10T09:00:00.000Z",
2016
+ },
2017
+ ]),
2018
+ ],
2019
+ },
2020
+ {
2021
+ source: "bee",
2022
+ conversations: [
2023
+ conversation("bee", "c2", "2026-06-10T09:00:00.000Z", [
2024
+ {
2025
+ text: "Bee same-anchor utterance.",
2026
+ isWearer: true,
2027
+ startIso: "2026-06-10T09:00:00.000Z",
2028
+ },
2029
+ ]),
2030
+ ],
2031
+ },
2032
+ ),
2033
+ { sourceTrust: { bee: 0.6, limitless: 0.9 } },
2034
+ );
2035
+
2036
+ const fused = build();
2037
+ // Deterministic: re-running produces identical output.
2038
+ assert.deepEqual(build(), fused);
2039
+
2040
+ const conv = fused.conversations[0]!;
2041
+ assert.equal(conv.segments.length, 2);
2042
+ // bee precedes limitless by the (start, source) cluster sequence — NOT by
2043
+ // trust, which would put the higher-trust limitless first.
2044
+ assert.deepEqual(
2045
+ conv.segments.map((s) => s.text),
2046
+ ["Bee same-anchor utterance.", "Limitless same-anchor utterance."],
2047
+ "equal-anchor segments keep source/cluster sequence, not trust order",
2048
+ );
2049
+ assert.deepEqual(
2050
+ conv.segments.map((s) => s.provenance.source),
2051
+ ["bee", "limitless"],
2052
+ "bee (lower-trust) precedes limitless (higher-trust) — sequence wins",
2053
+ );
2054
+ // Both share the anchor; equal timestamps did not reorder them.
2055
+ assert.equal(conv.segments[0]!.startIso, "2026-06-10T09:00:00.000Z");
2056
+ assert.equal(conv.segments[1]!.startIso, "2026-06-10T09:00:00.000Z");
2057
+ assert.ok(isChronologicallySorted(conv.segments));
2058
+ });
2059
+
2060
+ test("missing-timestamp segments keep their position after the timed ones (final sort)", () => {
2061
+ // A MIX of timestamped and untimestamped utterances. The defensive final
2062
+ // sort must place timed segments first (in time order) and let
2063
+ // untimestamped ones trail in their original relative order — never
2064
+ // interspersing or scrambling the missing-time entries.
2065
+ const fused = fuseDay(
2066
+ DATE,
2067
+ inputs(
2068
+ {
2069
+ source: "bee",
2070
+ conversations: [
2071
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
2072
+ { text: "Timed one.", isWearer: true, startIso: "2026-06-10T09:00:00.000Z" },
2073
+ { text: "Untimed one.", isWearer: true },
2074
+ { text: "Untimed two.", isWearer: true },
2075
+ ]),
2076
+ ],
2077
+ },
2078
+ ),
2079
+ );
2080
+ const conv = fused.conversations[0]!;
2081
+ assert.equal(conv.segments.length, 3);
2082
+ // The timed segment leads; the two untimestamped segments follow in
2083
+ // their original transcript order (one before two).
2084
+ assert.equal(conv.segments[0]!.text, "Timed one.");
2085
+ assert.notEqual(conv.segments[0]!.startIso, undefined);
2086
+ assert.equal(conv.segments[1]!.text, "Untimed one.");
2087
+ assert.equal(conv.segments[1]!.startIso, undefined);
2088
+ assert.equal(conv.segments[2]!.text, "Untimed two.");
2089
+ assert.equal(conv.segments[2]!.startIso, undefined);
2090
+ assert.ok(isChronologicallySorted(conv.segments));
2091
+ });
2092
+
2093
+ test("cross-source clock skew does not scramble same-anchor utterances (final sort)", () => {
2094
+ // Two distinct utterances, each corroborated across two sources whose
2095
+ // clocks are skewed (bee at 09:00:00, limitless +4s at 09:00:04). Each
2096
+ // utterance fuses to one segment at the bee anchor (09:00:00). The
2097
+ // defensive final sort sees two segments sharing the anchor timestamp;
2098
+ // it must keep their input order (first topic before second topic), not
2099
+ // let the skew or any source key reorder them.
2100
+ const fused = fuseDay(
2101
+ DATE,
2102
+ inputs(
2103
+ {
2104
+ source: "bee",
2105
+ conversations: [
2106
+ conversation("bee", "c1", "2026-06-10T09:00:00.000Z", [
2107
+ { text: "First topic.", isWearer: true, startIso: "2026-06-10T09:00:00.000Z" },
2108
+ { text: "Second topic.", isWearer: true, startIso: "2026-06-10T09:00:00.000Z" },
2109
+ ]),
2110
+ ],
2111
+ },
2112
+ {
2113
+ source: "limitless",
2114
+ conversations: [
2115
+ conversation("limitless", "c2", "2026-06-10T09:00:00.000Z", [
2116
+ { text: "First topic.", isWearer: true, startIso: "2026-06-10T09:00:04.000Z" },
2117
+ { text: "Second topic.", isWearer: true, startIso: "2026-06-10T09:00:04.000Z" },
2118
+ ]),
2119
+ ],
2120
+ },
2121
+ ),
2122
+ );
2123
+ const conv = fused.conversations[0]!;
2124
+ // Two distinct utterances, each corroborated -> two fused segments.
2125
+ assert.equal(conv.segments.length, 2);
2126
+ // Both emitted at the bee anchor (earliest); the +4s skew is absorbed.
2127
+ assert.equal(conv.segments[0]!.startIso, "2026-06-10T09:00:00.000Z");
2128
+ assert.equal(conv.segments[1]!.startIso, "2026-06-10T09:00:00.000Z");
2129
+ // Input order preserved across the shared anchor — not scrambled by skew.
2130
+ assert.deepEqual(
2131
+ conv.segments.map((s) => s.text),
2132
+ ["First topic.", "Second topic."],
2133
+ "same-anchor utterances keep input order despite cross-source skew",
2134
+ );
2135
+ assert.ok(isChronologicallySorted(conv.segments));
2136
+ });
2137
+
2138
+ // ─── Multiline segment round-trip regression (#1810) ────────────────────
2139
+
2140
+ test("multiline segment text round-trips losslessly through the real renderer", () => {
2141
+ const multi = "First line.\nSecond line.\nThird line.";
2142
+ const conversations = [
2143
+ conversation(
2144
+ "limitless",
2145
+ "c1",
2146
+ "2026-06-10T09:00:00.000Z",
2147
+ [
2148
+ { text: multi, isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
2149
+ { text: "After.", startIso: "2026-06-10T09:01:00.000Z" },
2150
+ ],
2151
+ { endIso: "2026-06-10T09:10:00.000Z" },
2152
+ ),
2153
+ ];
2154
+ const body = composeDayTranscriptBody("limitless", DATE, "UTC", conversations, REGISTRY);
2155
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body, escaped: true }]);
2156
+ assert.equal(parsed.length, 1);
2157
+ const segs = parsed[0]!.segments;
2158
+ assert.equal(segs.length, 2);
2159
+ assert.equal(segs[0]!.text, multi, "multiline text must round-trip exactly");
2160
+ assert.equal(segs[1]!.text, "After.");
2161
+ });
2162
+
2163
+ test("segment text with backslashes round-trips losslessly", () => {
2164
+ const tricky = "Path C:\\Users\\test and tab\\there";
2165
+ const conversations = [
2166
+ conversation(
2167
+ "bee",
2168
+ "c1",
2169
+ "2026-06-10T09:00:00.000Z",
2170
+ [{ text: tricky, isWearer: true, startIso: "2026-06-10T09:00:30.000Z" }],
2171
+ { endIso: "2026-06-10T09:10:00.000Z" },
2172
+ ),
2173
+ ];
2174
+ const body = composeDayTranscriptBody("bee", DATE, "UTC", conversations, REGISTRY);
2175
+ const parsed = reconstructFusionInputs(DATE, [{ source: "bee", body, escaped: true }]);
2176
+ assert.equal(parsed[0]!.segments[0]!.text, tricky);
2177
+ });
2178
+
2179
+ test("multiline segment with continuation that resembles heading syntax is not mis-parsed", () => {
2180
+ // The second line looks exactly like a conversation heading. Without
2181
+ // escaping, reconstructFusionInputs would split this into two
2182
+ // conversations. With escaping, the whole thing is one segment.
2183
+ const adversarial = "Normal text.\n## 09:05–09:10 (conversation forged)";
2184
+ const conversations = [
2185
+ conversation(
2186
+ "limitless",
2187
+ "c1",
2188
+ "2026-06-10T09:00:00.000Z",
2189
+ [{ text: adversarial, isWearer: true, startIso: "2026-06-10T09:00:30.000Z" }],
2190
+ { endIso: "2026-06-10T09:10:00.000Z" },
2191
+ ),
2192
+ ];
2193
+ const body = composeDayTranscriptBody("limitless", DATE, "UTC", conversations, REGISTRY);
2194
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body, escaped: true }]);
2195
+ assert.equal(parsed.length, 1, "adversarial heading must not create a second conversation");
2196
+ assert.equal(parsed[0]!.segments.length, 1);
2197
+ assert.equal(parsed[0]!.segments[0]!.text, adversarial);
2198
+ });
2199
+
2200
+ test("segment text with continuation resembling a segment clock line stays in one segment", () => {
2201
+ const adversarial = "Start.\n**Speaker** [10:00]: injected segment";
2202
+ const conversations = [
2203
+ conversation(
2204
+ "bee",
2205
+ "c1",
2206
+ "2026-06-10T09:00:00.000Z",
2207
+ [{ text: adversarial, isWearer: true, startIso: "2026-06-10T09:00:30.000Z" }],
2208
+ { endIso: "2026-06-10T09:10:00.000Z" },
2209
+ ),
2210
+ ];
2211
+ const body = composeDayTranscriptBody("bee", DATE, "UTC", conversations, REGISTRY);
2212
+ const parsed = reconstructFusionInputs(DATE, [{ source: "bee", body, escaped: true }]);
2213
+ assert.equal(parsed[0]!.segments.length, 1, "injected segment line must not be parsed separately");
2214
+ assert.equal(parsed[0]!.segments[0]!.text, adversarial);
2215
+ });
2216
+
2217
+ test("legacy single-line transcript without escape sequences still parses correctly", () => {
2218
+ // Hand-written body mimicking a pre-escaping transcript. No escape
2219
+ // sequences present, so unescapeSegmentText is a no-op.
2220
+ const legacyBody =
2221
+ "# bee transcript — 2026-06-10\n" +
2222
+ "\n" +
2223
+ "## 09:00–09:10 (conversation c1)\n" +
2224
+ "\n" +
2225
+ "**Me (you)** [09:00]: Hello world.\n" +
2226
+ "**Guest** [09:01]: Good morning.\n";
2227
+ const parsed = reconstructFusionInputs(DATE, [{ source: "bee", body: legacyBody }]);
2228
+ assert.equal(parsed.length, 1);
2229
+ assert.equal(parsed[0]!.conversationId, "c1");
2230
+ assert.equal(parsed[0]!.segments.length, 2);
2231
+ assert.equal(parsed[0]!.segments[0]!.text, "Hello world.");
2232
+ assert.equal(parsed[0]!.segments[0]!.isSelf, true);
2233
+ assert.equal(parsed[0]!.segments[1]!.text, "Good morning.");
2234
+ assert.equal(parsed[0]!.segments[1]!.isSelf, false);
2235
+ });
2236
+
2237
+ test("carriage returns in segment text round-trip losslessly", () => {
2238
+ const crText = "Line one.\r\nLine two.";
2239
+ const conversations = [
2240
+ conversation(
2241
+ "limitless",
2242
+ "c1",
2243
+ "2026-06-10T09:00:00.000Z",
2244
+ [{ text: crText, isWearer: true, startIso: "2026-06-10T09:00:30.000Z" }],
2245
+ { endIso: "2026-06-10T09:10:00.000Z" },
2246
+ ),
2247
+ ];
2248
+ const body = composeDayTranscriptBody("limitless", DATE, "UTC", conversations, REGISTRY);
2249
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body, escaped: true }]);
2250
+ assert.equal(parsed[0]!.segments[0]!.text, crText);
2251
+ });
2252
+
2253
+ test("unicode and mixed special characters in segment text round-trip losslessly", () => {
2254
+ const unicode = "日本語テスト emoji 🎉 and newline\nplus backslash \\ and more";
2255
+ const conversations = [
2256
+ conversation(
2257
+ "bee",
2258
+ "c1",
2259
+ "2026-06-10T09:00:00.000Z",
2260
+ [{ text: unicode, isWearer: true, startIso: "2026-06-10T09:00:30.000Z" }],
2261
+ { endIso: "2026-06-10T09:10:00.000Z" },
2262
+ ),
2263
+ ];
2264
+ const body = composeDayTranscriptBody("bee", DATE, "UTC", conversations, REGISTRY);
2265
+ const parsed = reconstructFusionInputs(DATE, [{ source: "bee", body, escaped: true }]);
2266
+ assert.equal(parsed[0]!.segments[0]!.text, unicode);
2267
+ });
2268
+
2269
+ test("legacy transcript with unknown backslash sequences preserves them losslessly", () => {
2270
+ // Pre-escaper transcripts may contain arbitrary backslash sequences
2271
+ // (Windows paths, regex) that the escaper would never emit. These
2272
+ // must round-trip: unescapeSegmentText keeps the backslash for any
2273
+ // escape it does not recognise, while \n / \r / \\ still decode.
2274
+ const legacyBody =
2275
+ "# bee transcript — 2026-06-10\n" +
2276
+ "\n" +
2277
+ "## 09:00–09:10 (conversation c1)\n" +
2278
+ "\n" +
2279
+ '**Me (you)** [09:00]: Path is C:\\Users\\josh and config at D:\\data\n' +
2280
+ "**Guest** [09:01]: Regex \\d+ matches digits, \\w+ matches words\n";
2281
+ const parsed = reconstructFusionInputs(DATE, [{ source: "bee", body: legacyBody }]);
2282
+ assert.equal(parsed[0]!.segments[0]!.text, "Path is C:\\Users\\josh and config at D:\\data");
2283
+ assert.equal(parsed[0]!.segments[1]!.text, "Regex \\d+ matches digits, \\w+ matches words");
2284
+ });
2285
+
2286
+
2287
+ test("reconstruct rolls a long-but-plausible cross-midnight wrap into the next day (#1849)", () => {
2288
+ // start 22:00 -> end 02:00 wraps 4h (240 min) — well within the
2289
+ // plausible-conversation bound, so the end still rolls to the next day.
2290
+ const body = [
2291
+ "# limitless transcript — 2026-06-10",
2292
+ "",
2293
+ "## 22:00–02:00 · Overnight (conversation c1)",
2294
+ "",
2295
+ "**Me (you)** [23:30]: Still awake.",
2296
+ "",
2297
+ ].join("\n");
2298
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
2299
+ assert.equal(parsed.length, 1);
2300
+ const conv = parsed[0]!;
2301
+ assert.equal(conv.startIso, "2026-06-10T22:00:00.000Z");
2302
+ assert.equal(conv.endIso, "2026-06-11T02:00:00.000Z");
2303
+ assert.ok(conv.endIso! >= conv.startIso!, "plausible wrap rolls end forward");
2304
+ });
2305
+
2306
+ test("reconstruct does NOT roll an implausible near-24h earlier end clock (#1849)", () => {
2307
+ // start 14:00 -> end 13:00 implies a ~23h wrap, almost certainly a
2308
+ // malformed/ordinary earlier clock, not a midnight crossing. The end must
2309
+ // STAY on the same date (earlier than the start) so the downstream cluster
2310
+ // clamp collapses it to the start instead of spanning the whole day and
2311
+ // broadly clustering unrelated neighbors.
2312
+ const body = [
2313
+ "# limitless transcript — 2026-06-10",
2314
+ "",
2315
+ "## 14:00–13:00 · Sync (conversation c1)",
2316
+ "",
2317
+ "**Me (you)** [14:05]: Discussed the roadmap.",
2318
+ "",
2319
+ ].join("\n");
2320
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
2321
+ assert.equal(parsed.length, 1);
2322
+ const conv = parsed[0]!;
2323
+ assert.equal(conv.startIso, "2026-06-10T14:00:00.000Z");
2324
+ // NOT rolled: end stays on the same date, preceding the start.
2325
+ assert.equal(conv.endIso, "2026-06-10T13:00:00.000Z");
2326
+ assert.ok(conv.endIso! < conv.startIso!, "implausible earlier end is left for the clamp");
2327
+ // The segment clock (14:05 >= start 14:00) is unaffected and stays put.
2328
+ assert.equal(conv.segments[0]!.startIso, "2026-06-10T14:05:00.000Z");
2329
+ });
2330
+
2331
+ test("implausible earlier end does not span the day: cluster clamps it to the start (#1849)", () => {
2332
+ // Reconstructed from a 14:00->13:00 heading (implausible wrap, not rolled),
2333
+ // the conversation end precedes its start exactly like a direct malformed
2334
+ // input, so effectiveInterval clamps it to [14:00, 14:00] instead of a
2335
+ // ~23h window. A different-source neighbor at 14:03 (within the 5-min gap)
2336
+ // therefore clusters with it rather than being swallowed by a day-long span.
2337
+ const body = [
2338
+ "# limitless transcript — 2026-06-10",
2339
+ "",
2340
+ "## 14:00–13:00 · Sync (conversation c1)",
2341
+ "",
2342
+ "**Me (you)** [14:00]: Roadmap.",
2343
+ "",
2344
+ ].join("\n");
2345
+ const limitless = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
2346
+ const bee: FusionConversationInput = {
2347
+ source: "bee",
2348
+ conversationId: "c1",
2349
+ startIso: "2026-06-10T14:03:00.000Z",
2350
+ endIso: "2026-06-10T14:08:00.000Z",
2351
+ segments: [{ speaker: "Me (you)", isSelf: true, text: "Neighbor." }],
2352
+ };
2353
+ const clusters = clusterConversations([...limitless, bee]);
2354
+ assert.equal(
2355
+ clusters.length,
2356
+ 1,
2357
+ "clamped point-window keeps the within-gap neighbor in one cluster (no broad clustering)",
2358
+ );
2359
+ });
2360
+
2361
+ test("reconstruct never attributes a non-self speaker whose name ends in (you) as self (issue #1849)", () => {
2362
+ // A non-self override stored with a name that already ends in the
2363
+ // reserved `(you)` marker. The renderer (resolveSpeaker) reserves the
2364
+ // marker, so the stored transcript line carries NO marker for Pat, and
2365
+ // reconstruct reads self status only from the authoritative marker.
2366
+ const registry = emptySpeakerRegistry();
2367
+ registry.selfName = "Jordan";
2368
+ registry.speakers["bee:SPEAKER_01"] = {
2369
+ name: "Pat (you)",
2370
+ updatedAt: "2026-06-10T00:00:00Z",
2371
+ };
2372
+ const conversations = [
2373
+ conversation(
2374
+ "bee",
2375
+ "c1",
2376
+ "2026-06-10T09:00:00.000Z",
2377
+ [
2378
+ { text: "I am the wearer.", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
2379
+ { text: "I am Pat.", speakerKey: "SPEAKER_01", startIso: "2026-06-10T09:01:00.000Z" },
2380
+ ],
2381
+ { endIso: "2026-06-10T09:10:00.000Z" },
2382
+ ),
2383
+ ];
2384
+ const body = composeDayTranscriptBody("bee", DATE, "UTC", conversations, registry);
2385
+
2386
+ // The rendered body must NOT place a `(you)` marker on the non-self
2387
+ // Pat line — the marker is reserved for the wearer only.
2388
+ assert.ok(/\*\*Pat\*\* \[09:01\]: I am Pat\./.test(body), "Pat's label has no (you) marker: " + body);
2389
+
2390
+ const parsed = reconstructFusionInputs(DATE, [{ source: "bee", body, escaped: true }]);
2391
+ assert.equal(parsed.length, 1);
2392
+ const segs = parsed[0]!.segments;
2393
+ assert.equal(segs.length, 2);
2394
+ // Wearer keeps the authoritative marker -> self.
2395
+ assert.equal(segs[0]!.speaker, "Jordan (you)");
2396
+ assert.equal(segs[0]!.isSelf, true);
2397
+ // Pat is NOT attributed as self despite the stored name ending in (you).
2398
+ assert.equal(segs[1]!.speaker, "Pat");
2399
+ assert.equal(segs[1]!.isSelf, false);
2400
+ });
2401
+
2402
+ test("reconstruct does NOT roll an implausible earlier SEGMENT clock into the next day (#1849)", () => {
2403
+ // start 14:00, a segment clocked at 13:00 (provider skew / a clock that
2404
+ // ran backwards). The implied wrap (14:00 -> midnight -> 13:00) is ~23h,
2405
+ // far past the plausible-conversation bound. The segment must STAY on the
2406
+ // same date so it precedes the start and the downstream cluster clamp
2407
+ // collapses it — NOT roll to the next day and stretch a ~23h interval
2408
+ // that broadly clusters unrelated neighbors.
2409
+ const body = [
2410
+ "# limitless transcript — 2026-06-10",
2411
+ "",
2412
+ "## 14:00–15:00 · Sync (conversation c1)",
2413
+ "",
2414
+ "**Me (you)** [14:00]: Opening remark.",
2415
+ "**Jane** [13:00]: Provider-skewed earlier clock.",
2416
+ "",
2417
+ ].join("\n");
2418
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
2419
+ assert.equal(parsed.length, 1);
2420
+ const conv = parsed[0]!;
2421
+ assert.equal(conv.startIso, "2026-06-10T14:00:00.000Z");
2422
+ // The 13:00 segment is NOT rolled to 2026-06-11; it stays on 2026-06-10.
2423
+ const skewed = conv.segments.find((s) => s.text === "Provider-skewed earlier clock.");
2424
+ assert.ok(skewed, "provider-skewed segment present");
2425
+ assert.equal(
2426
+ skewed!.startIso,
2427
+ "2026-06-10T13:00:00.000Z",
2428
+ "implausible earlier segment stays on the same date",
2429
+ );
2430
+ // It precedes the conversation start, leaving it for the cluster clamp.
2431
+ assert.ok(
2432
+ Date.parse(skewed!.startIso!) < Date.parse(conv.startIso!),
2433
+ "same-date earlier segment precedes the start (ready for clamping)",
2434
+ );
2435
+ });
2436
+
2437
+ test("reconstruct still rolls a plausible post-midnight SEGMENT clock into the next day (#1849)", () => {
2438
+ // start 23:55, a segment at 00:05 wraps only 10 min — well within the
2439
+ // plausible bound. The segment MUST roll forward despite the heading end
2440
+ // clock also being plausible; the segment-vs-start plausibility gate must
2441
+ // not over-tighten and suppress genuine midnight wraps.
2442
+ const body = [
2443
+ "# limitless transcript — 2026-06-10",
2444
+ "",
2445
+ "## 23:55–00:10 · Late (conversation c1)",
2446
+ "",
2447
+ "**Me (you)** [23:58]: Still talking.",
2448
+ "**Jane** [00:05]: After midnight.",
2449
+ "",
2450
+ ].join("\n");
2451
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
2452
+ assert.equal(parsed.length, 1);
2453
+ const conv = parsed[0]!;
2454
+ assert.equal(conv.startIso, "2026-06-10T23:55:00.000Z");
2455
+ assert.equal(conv.endIso, "2026-06-11T00:10:00.000Z");
2456
+ const isos = conv.segments.map((s) => s.startIso);
2457
+ assert.ok(
2458
+ isos.includes("2026-06-10T23:58:00.000Z"),
2459
+ "pre-midnight segment stays on the rendered date",
2460
+ );
2461
+ assert.ok(
2462
+ isos.includes("2026-06-11T00:05:00.000Z"),
2463
+ "plausible post-midnight segment rolls to the next day",
2464
+ );
2465
+ });
2466
+
2467
+ test("reconstruct rolls a long-but-plausible post-midnight segment (boundary of the wrap bound) (#1849)", () => {
2468
+ // start 20:00, segment 01:00: implied wrap = 240 + 60 = 300 min, well
2469
+ // within the 12h bound. Must still roll.
2470
+ const body = [
2471
+ "# limitless transcript — 2026-06-10",
2472
+ "",
2473
+ "## 20:00–--:-- · Evening (conversation c1)",
2474
+ "",
2475
+ "**Jane** [01:00]: Past midnight, long evening.",
2476
+ "",
2477
+ ].join("\n");
2478
+ const parsed = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
2479
+ assert.equal(parsed.length, 1);
2480
+ const conv = parsed[0]!;
2481
+ assert.equal(conv.startIso, "2026-06-10T20:00:00.000Z");
2482
+ assert.equal(
2483
+ conv.segments[0]!.startIso,
2484
+ "2026-06-11T01:00:00.000Z",
2485
+ "long-but-plausible wrap (5h) still rolls to the next day",
2486
+ );
2487
+ });
2488
+
2489
+ test("implausible earlier segment does not span the day: neighbor clusters, not swallows (#1849)", () => {
2490
+ // A 14:00-start conversation with a 13:00 provider-skewed segment. The
2491
+ // segment stays same-date (13:00 < start 14:00) and is clamped by the
2492
+ // interval to [14:00, 14:00]. A different-source neighbor at 14:03 (within
2493
+ // the 5-min gap) clusters with it. If the segment had wrongly rolled to
2494
+ // 2026-06-11T13:00 the window would span ~23h and swallow far-flung
2495
+ // neighbors instead of clustering only nearby ones.
2496
+ const body = [
2497
+ "# limitless transcript — 2026-06-10",
2498
+ "",
2499
+ "## 14:00–15:00 · Sync (conversation c1)",
2500
+ "",
2501
+ "**Me (you)** [14:00]: Roadmap.",
2502
+ "**Jane** [13:00]: Skew.",
2503
+ "",
2504
+ ].join("\n");
2505
+ const limitless = reconstructFusionInputs(DATE, [{ source: "limitless", body }]);
2506
+ const bee: FusionConversationInput = {
2507
+ source: "bee",
2508
+ conversationId: "c1",
2509
+ startIso: "2026-06-10T14:03:00.000Z",
2510
+ endIso: "2026-06-10T14:08:00.000Z",
2511
+ segments: [{ speaker: "Me (you)", isSelf: true, text: "Neighbor." }],
2512
+ };
2513
+ const clusters = clusterConversations([...limitless, bee]);
2514
+ assert.equal(clusters.length, 1, "clamped window clusters the nearby neighbor");
2515
+ });
2516
+
2517
+ test("speaker label containing markdown delimiters round-trips through compose → reconstruct (#1849)", () => {
2518
+ // A user-defined speaker label with `**`, `[`, and `]` — the exact
2519
+ // characters that delimit a segment line. Without safe serialization the
2520
+ // `**` (or `[clock]`) inside the label can break parseTranscriptSegmentLine
2521
+ // parsing; with escape/unescape the label survives losslessly.
2522
+ const registry = emptySpeakerRegistry();
2523
+ registry.speakers["bee:SPEAKER_01"] = {
2524
+ name: 'A**B [weird]: label',
2525
+ updatedAt: "2026-06-10T00:00:00Z",
2526
+ };
2527
+ const conversations = [
2528
+ conversation(
2529
+ "bee",
2530
+ "c1",
2531
+ "2026-06-10T09:00:00.000Z",
2532
+ [
2533
+ { text: "I am the wearer.", isWearer: true, startIso: "2026-06-10T09:00:30.000Z" },
2534
+ { text: "I am tricky.", speakerKey: "SPEAKER_01", startIso: "2026-06-10T09:01:00.000Z" },
2535
+ ],
2536
+ { endIso: "2026-06-10T09:10:00.000Z" },
2537
+ ),
2538
+ ];
2539
+ const body = composeDayTranscriptBody("bee", DATE, "UTC", conversations, registry);
2540
+ // The stored body MUST serialize the delimiters safely (no raw ** [ ]: ).
2541
+ assert.ok(
2542
+ !/\*\*A\*\*B/.test(body),
2543
+ "label delimiters are escaped in the stored body: " + body,
2544
+ );
2545
+ // Reconstruct decodes the label back to the original form.
2546
+ const parsed = reconstructFusionInputs(DATE, [{ source: "bee", body, escaped: true }]);
2547
+ assert.equal(parsed.length, 1);
2548
+ const segs = parsed[0]!.segments;
2549
+ assert.equal(segs.length, 2);
2550
+ assert.equal(segs[0]!.speaker, "Me (you)");
2551
+ assert.equal(segs[0]!.isSelf, true);
2552
+ assert.equal(segs[1]!.speaker, 'A**B [weird]: label', "delimited label round-trips losslessly");
2553
+ assert.equal(segs[1]!.isSelf, false);
2554
+ });
2555
+
2556
+ test("speaker label A**B alone round-trips through compose → reconstruct (#1849)", () => {
2557
+ // Minimal repro from the contract: a label that is exactly `A**B`.
2558
+ const registry = emptySpeakerRegistry();
2559
+ registry.speakers["bee:SPEAKER_01"] = {
2560
+ name: "A**B",
2561
+ updatedAt: "2026-06-10T00:00:00Z",
2562
+ };
2563
+ const conversations = [
2564
+ conversation(
2565
+ "bee",
2566
+ "c1",
2567
+ "2026-06-10T09:00:00.000Z",
2568
+ [{ text: "Hello.", speakerKey: "SPEAKER_01", startIso: "2026-06-10T09:00:30.000Z" }],
2569
+ { endIso: "2026-06-10T09:10:00.000Z" },
2570
+ ),
2571
+ ];
2572
+ const body = composeDayTranscriptBody("bee", DATE, "UTC", conversations, registry);
2573
+ const parsed = reconstructFusionInputs(DATE, [{ source: "bee", body, escaped: true }]);
2574
+ assert.equal(parsed[0]!.segments[0]!.speaker, "A**B");
2575
+ });