@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,831 @@
1
+ /**
2
+ * Wearable cross-source fusion — deterministic reconciliation.
3
+ *
4
+ * Given one cluster (conversations from N sources that overlapped in
5
+ * time), produce a single reconciled timeline where each utterance
6
+ * carries the best source's text plus per-segment provenance, and
7
+ * conflicts that could not be settled deterministically are recorded as
8
+ * disagreements.
9
+ *
10
+ * Reconciliation rules (all deterministic, no LLM):
11
+ * - text: prefer higher source trust, then the more-complete (longer)
12
+ * transcript over a truncated one; otherwise a stable tie-break.
13
+ * - disagreement: when two sources offer genuinely different text for
14
+ * the same window/speaker (neither contains the other), record every
15
+ * candidate and keep a provisional winner marked uncertain — we never
16
+ * silently pick.
17
+ * - speaker: resolved labels unify the wearer across sources; generic
18
+ * labels that cannot be cross-matched keep a lowered confidence.
19
+ *
20
+ * Full segment-level alignment beyond time windows is a deferred
21
+ * follow-up (see PR body). This pass aligns by (speaker, time window).
22
+ */
23
+
24
+ import { resolveSpeaker, type SpeakerRegistry } from "../speakers.js";
25
+ import { maxSegmentExtent } from "./cluster.js";
26
+ import type { WearableConversation } from "../types.js";
27
+ import type {
28
+ FusionConversationInput,
29
+ FusionOptions,
30
+ FusionSegmentInput,
31
+ FusedDisagreement,
32
+ FusedSegment,
33
+ FusedSpeaker,
34
+ SegmentPickReason,
35
+ } from "./types.js";
36
+
37
+ /** Default max drift (ms) for two segments to align across sources. */
38
+ export const DEFAULT_WINDOW_TOLERANCE_MS = 30_000;
39
+ /** Default trust weight for a source with no configured prior. */
40
+ export const DEFAULT_SOURCE_TRUST = 0.8;
41
+ /** Trust prior at which a source is considered "more trusted" than another. */
42
+ const TRUST_DECISION_EPSILON = 1e-9;
43
+ /** Labels that cannot be confidently cross-matched between sources.
44
+ * Raw provider diarization keys (e.g. Omi `SPEAKER_00`) are generic too:
45
+ * they carry no cross-source identity, so they get the same lowered
46
+ * confidence as bare "Speaker N". */
47
+ const GENERIC_LABEL_PATTERN = /^(Speaker \d+|Unknown speaker|SPEAKER_\d+)$/i;
48
+
49
+ export interface FuseClusterResult {
50
+ sources: string[];
51
+ speakers: FusedSpeaker[];
52
+ title?: string;
53
+ summary?: string;
54
+ startIso: string;
55
+ endIso?: string;
56
+ segments: FusedSegment[];
57
+ disagreements: FusedDisagreement[];
58
+ }
59
+
60
+ interface TaggedSegment {
61
+ source: string;
62
+ conversationId: string;
63
+ speakerLabel: string;
64
+ isSelf: boolean;
65
+ text: string;
66
+ startMs: number;
67
+ startIso?: string;
68
+ endIso?: string;
69
+ sourceTrust: number;
70
+ /** Original position in the flattened input (sources in first-appearance
71
+ * order, then segment order within each source). Carried through
72
+ * alignment so equal-time segments keep their transcript sequence. */
73
+ originalIndex: number;
74
+ }
75
+
76
+ interface AlignGroup {
77
+ /** Match key: "self" for the wearer, else the resolved label. */
78
+ key: string;
79
+ speakerLabel: string;
80
+ isSelf: boolean;
81
+ members: TaggedSegment[];
82
+ anchorMs: number;
83
+ /** The anchor member's ORIGINAL start ISO — the canonical utterance
84
+ * time the fused segment is emitted at (groups are sorted by anchorMs,
85
+ * so the emitted startIso must come from here, not from the chosen
86
+ * higher-trust source's later clock, or pre-sorted chronology breaks).
87
+ * Undefined for untimestamped (missing-start) groups. */
88
+ anchorStartIso?: string;
89
+ /** The anchor member's ORIGINAL end ISO, when known. */
90
+ anchorEndIso?: string;
91
+ /** Earliest original sequence index among members — the secondary sort
92
+ * key so equal-anchorMs groups keep transcript/source order. */
93
+ originalIndex: number;
94
+ }
95
+
96
+ /** Match key that unifies the wearer across sources. */
97
+ function speakerKey(label: string, isSelf: boolean): string {
98
+ return isSelf ? "self" : label;
99
+ }
100
+
101
+ function resolveTrust(
102
+ source: string,
103
+ trustMap: Record<string, number> | undefined,
104
+ ): number {
105
+ const value = trustMap?.[source];
106
+ if (typeof value === "number" && Number.isFinite(value)) {
107
+ return Math.min(1, Math.max(0, value));
108
+ }
109
+ return DEFAULT_SOURCE_TRUST;
110
+ }
111
+
112
+ /** Lowercased, punctuation-stripped word sequence — order-sensitive. */
113
+ function normalizeWords(text: string): string[] {
114
+ return text
115
+ .toLowerCase()
116
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
117
+ .split(/\s+/)
118
+ .filter((word) => word.length > 0);
119
+ }
120
+
121
+ function wordsEqual(a: string[], b: string[]): boolean {
122
+ if (a.length !== b.length) return false;
123
+ for (let i = 0; i < a.length; i++) {
124
+ if (a[i] !== b[i]) return false;
125
+ }
126
+ return true;
127
+ }
128
+
129
+ /** True when `short` is an exact leading prefix of `long` (truncation). */
130
+ function isWordPrefix(short: string[], long: string[]): boolean {
131
+ if (short.length === 0 || short.length > long.length) return false;
132
+ for (let i = 0; i < short.length; i++) {
133
+ if (short[i] !== long[i]) return false;
134
+ }
135
+ return true;
136
+ }
137
+
138
+ /** Whether two normalized word sequences corroborate the SAME utterance.
139
+ * Exact match always corroborates; a leading-word-prefix (truncation)
140
+ * corroborates only when `allowPrefix` is set (timestamped alignment),
141
+ * so the more-complete-wins override can reunite a clipped transcript
142
+ * with its full wording. Untimed alignment passes `allowPrefix = false`
143
+ * and demands an exact word match. */
144
+ function wordsCorroborate(
145
+ a: string[],
146
+ b: string[],
147
+ allowPrefix: boolean,
148
+ ): boolean {
149
+ if (wordsEqual(a, b)) return true;
150
+ if (!allowPrefix) return false;
151
+ return isWordPrefix(a, b) || isWordPrefix(b, a);
152
+ }
153
+
154
+ function clamp01(value: number): number {
155
+ if (!Number.isFinite(value)) return 0;
156
+ return Math.min(1, Math.max(0, value));
157
+ }
158
+
159
+ /**
160
+ * Build fusion inputs from raw provider conversations + the shared
161
+ * speaker registry. This is the precise connector path: full ISO
162
+ * timestamps and resolved speaker labels. (The on-demand service path
163
+ * instead reconstructs inputs from stored transcripts — see
164
+ * `reconstruct.ts`.)
165
+ */
166
+ export function fusionInputsFromConversations(
167
+ sourceId: string,
168
+ conversations: readonly WearableConversation[],
169
+ registry: SpeakerRegistry,
170
+ ): FusionConversationInput[] {
171
+ return conversations.map((conversation) => {
172
+ const segments: FusionSegmentInput[] = conversation.segments.map((raw) => {
173
+ const resolved = resolveSpeaker(sourceId, raw, registry);
174
+ return {
175
+ speaker: resolved.label,
176
+ isSelf: resolved.isSelf,
177
+ text: raw.text,
178
+ ...(raw.startIso !== undefined ? { startIso: raw.startIso } : {}),
179
+ ...(raw.endIso !== undefined ? { endIso: raw.endIso } : {}),
180
+ };
181
+ });
182
+ return {
183
+ source: sourceId,
184
+ conversationId: conversation.id,
185
+ startIso: conversation.startIso,
186
+ ...(conversation.endIso !== undefined ? { endIso: conversation.endIso } : {}),
187
+ ...(conversation.title !== undefined ? { title: conversation.title } : {}),
188
+ ...(conversation.summary !== undefined ? { summary: conversation.summary } : {}),
189
+ segments,
190
+ };
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Fuse one cluster into a reconciled conversation (without id/date).
196
+ */
197
+ export function fuseCluster(
198
+ cluster: readonly FusionConversationInput[],
199
+ options: FusionOptions = {},
200
+ ): FuseClusterResult {
201
+ const trustMap = options.sourceTrust;
202
+ const toleranceMs = options.windowToleranceMs ?? DEFAULT_WINDOW_TOLERANCE_MS;
203
+
204
+ // Sources in first-appearance order.
205
+ const sources: string[] = [];
206
+ const sourcesSeen = new Set<string>();
207
+ for (const conv of cluster) {
208
+ if (!sourcesSeen.has(conv.source)) {
209
+ sourcesSeen.add(conv.source);
210
+ sources.push(conv.source);
211
+ }
212
+ }
213
+
214
+ // Earliest start / latest end across the cluster. The fused END considers
215
+ // BOTH every conversation's explicit endIso AND every segment's own extent
216
+ // (endIso ?? startIso) across ALL members: a stored transcript whose
217
+ // heading end renders as "--:--" rebuilds segments with only a start, so
218
+ // its later segment starts must still extend the fused window — otherwise
219
+ // a source with an explicit SHORT end would clip the fused conversation
220
+ // before the missing-end source's last utterance (issue #1849). Segment
221
+ // extents use the SAME `maxSegmentExtent` primitive as the cluster
222
+ // interval, so there is one coherent end derivation in the pipeline.
223
+ let startMs = Number.POSITIVE_INFINITY;
224
+ let endMs = Number.NEGATIVE_INFINITY;
225
+ let startIso: string | undefined;
226
+ let endIso: string | undefined;
227
+ for (const conv of cluster) {
228
+ const ms = Date.parse(conv.startIso);
229
+ if (Number.isFinite(ms) && ms < startMs) {
230
+ startMs = ms;
231
+ startIso = conv.startIso;
232
+ }
233
+ if (conv.endIso !== undefined) {
234
+ const endM = Date.parse(conv.endIso);
235
+ if (Number.isFinite(endM) && endM > endMs) {
236
+ endMs = endM;
237
+ endIso = conv.endIso;
238
+ }
239
+ }
240
+ }
241
+ // Fall back to / extend with the latest segment extent across ALL members
242
+ // so a missing-end ("--:--") source's later segment starts are not lost.
243
+ const segmentExtent = maxSegmentExtent(
244
+ cluster.flatMap((conv) => conv.segments),
245
+ );
246
+ if (segmentExtent !== undefined && segmentExtent.ms > endMs) {
247
+ endMs = segmentExtent.ms;
248
+ endIso = segmentExtent.iso;
249
+ }
250
+ // Clamp the fused window end to >= start so it is always valid, mirroring
251
+ // cluster.ts. A malformed explicit/derived end preceding the cluster start
252
+ // would otherwise yield a negative-length window.
253
+ if (endIso !== undefined && Number.isFinite(startMs) && endMs < startMs) {
254
+ endIso = startIso;
255
+ endMs = startMs;
256
+ }
257
+ if (startIso === undefined) {
258
+ // No parseable start in the cluster — fall back to the first conv's
259
+ // start string so the required field stays present downstream.
260
+ startIso = cluster[0]?.startIso ?? new Date(0).toISOString();
261
+ }
262
+
263
+ // Title / summary: first non-empty in cluster order.
264
+ let title: string | undefined;
265
+ let summary: string | undefined;
266
+ for (const conv of cluster) {
267
+ if (title === undefined && conv.title !== undefined && conv.title.trim().length > 0) {
268
+ title = conv.title.trim();
269
+ }
270
+ if (
271
+ summary === undefined &&
272
+ conv.summary !== undefined &&
273
+ conv.summary.trim().length > 0
274
+ ) {
275
+ summary = conv.summary.trim();
276
+ }
277
+ if (title !== undefined && summary !== undefined) break;
278
+ }
279
+
280
+ // Flatten + tag every segment with its source provenance and trust.
281
+ const tagged: TaggedSegment[] = [];
282
+ for (const conv of cluster) {
283
+ const sourceTrust = resolveTrust(conv.source, trustMap);
284
+ for (const segment of conv.segments) {
285
+ const startMsValue =
286
+ segment.startIso !== undefined ? Date.parse(segment.startIso) : NaN;
287
+ tagged.push({
288
+ source: conv.source,
289
+ conversationId: conv.conversationId,
290
+ speakerLabel: segment.speaker,
291
+ isSelf: segment.isSelf,
292
+ text: segment.text,
293
+ startMs: Number.isFinite(startMsValue)
294
+ ? (startMsValue as number)
295
+ : Number.POSITIVE_INFINITY,
296
+ ...(segment.startIso !== undefined ? { startIso: segment.startIso } : {}),
297
+ ...(segment.endIso !== undefined ? { endIso: segment.endIso } : {}),
298
+ sourceTrust,
299
+ originalIndex: tagged.length,
300
+ });
301
+ }
302
+ }
303
+
304
+ // Deterministic order: time first. Equal-time (and both-missing)
305
+ // segments keep their ORIGINAL transcript/source sequence instead of
306
+ // being scrambled by source or speaker label, so minute-precision and
307
+ // untimestamped utterances are not reordered relative to their input.
308
+ tagged.sort((a, b) => {
309
+ const aFinite = Number.isFinite(a.startMs);
310
+ const bFinite = Number.isFinite(b.startMs);
311
+ if (aFinite && bFinite && a.startMs !== b.startMs) return a.startMs - b.startMs;
312
+ if (aFinite !== bFinite) return aFinite ? -1 : 1;
313
+ return a.originalIndex - b.originalIndex;
314
+ });
315
+
316
+ // Greedy cross-source alignment: a group holds at most one segment per
317
+ // source (so two sequential same-source utterances never collapse). A
318
+ // segment joins an existing group when their time windows match (or both
319
+ // are untimestamped) AND the text corroborates the SAME utterance.
320
+ // Speaker is matched in two tiers: first an exact same-speaker group;
321
+ // only when none exists does a segment join a group whose utterance
322
+ // corroborates but whose speaker label DISAGREES. That is a speaker
323
+ // conflict on the same utterance — fused into one segment and recorded
324
+ // as a disagreement (see reconcileGroup) rather than emitted as two
325
+ // separate segments. This does NOT collapse distinct utterances: the
326
+ // text must corroborate, so different-worded same-window segments
327
+ // (the r2 separation) still stay apart.
328
+ const groups: AlignGroup[] = [];
329
+ for (const seg of tagged) {
330
+ const key = speakerKey(seg.speakerLabel, seg.isSelf);
331
+ const segMissing = !Number.isFinite(seg.startMs);
332
+ const segWords = normalizeWords(seg.text);
333
+
334
+ // True when `group` has room for `seg`'s source and corroborates the
335
+ // same utterance (matching time window + corroborating text). Speaker
336
+ // is intentionally NOT tested here — the caller decides whether a
337
+ // same-speaker match or a speaker-conflict match is acceptable.
338
+ const corroborates = (group: AlignGroup): boolean => {
339
+ if (group.members.some((member) => member.source === seg.source)) {
340
+ return false;
341
+ }
342
+ // Missing-start segments only align with other missing-start groups.
343
+ const groupMissing = !Number.isFinite(group.anchorMs);
344
+ if (groupMissing !== segMissing) return false;
345
+ if (!groupMissing && Math.abs(seg.startMs - group.anchorMs) > toleranceMs) {
346
+ return false;
347
+ }
348
+ // Cross-source corroboration gate: distinct utterances that merely
349
+ // share a speaker and a time window (timestamped) — or a speaker key
350
+ // alone (untimed) — must NOT collapse. Timestamped alignment accepts
351
+ // an exact word match OR a leading-word prefix (truncation) so the
352
+ // more-complete-wins override can reunite a clipped transcript with
353
+ // its full wording; untimed alignment has no anchor, so exact only.
354
+ return group.members.some((member) =>
355
+ wordsCorroborate(normalizeWords(member.text), segWords, !groupMissing),
356
+ );
357
+ };
358
+
359
+ // Choose the CLOSEST corroborating group instead of the first match
360
+ // (issue #1849). Within windowToleranceMs several groups can corroborate
361
+ // the same short utterance — e.g. a speaker repeating "yes" at 10:00 and
362
+ // 10:20 with a 30s window — so a later source segment at 10:21
363
+ // corroborates BOTH. First-match would attach it to the earliest (10:00)
364
+ // group even though 10:20 is nearer, misattributing provenance and
365
+ // potentially reordering the fused timeline. Scan every candidate and
366
+ // keep the one whose anchor is nearest to this segment by
367
+ // |anchorMs - startMs|; ties break on the smaller anchor, then on the
368
+ // order the group was created (stable, never dependent on key order).
369
+ // Single-occurrence behavior is unchanged: the lone corroborating group
370
+ // is trivially the closest.
371
+ const closestCorroborating = (
372
+ wantSameSpeaker: boolean,
373
+ ): AlignGroup | undefined => {
374
+ let best: AlignGroup | undefined;
375
+ let bestDelta = Infinity;
376
+ for (const group of groups) {
377
+ if (
378
+ (group.key === key) !== wantSameSpeaker ||
379
+ !corroborates(group)
380
+ ) {
381
+ continue;
382
+ }
383
+ const delta =
384
+ !segMissing && Number.isFinite(group.anchorMs)
385
+ ? Math.abs(seg.startMs - group.anchorMs)
386
+ : 0;
387
+ if (
388
+ best === undefined ||
389
+ delta < bestDelta ||
390
+ (delta === bestDelta && group.anchorMs < best.anchorMs)
391
+ ) {
392
+ best = group;
393
+ bestDelta = delta;
394
+ }
395
+ }
396
+ return best;
397
+ };
398
+
399
+ // Prefer an exact same-speaker corroborating group.
400
+ let chosen = closestCorroborating(true);
401
+ if (chosen === undefined) {
402
+ // Fall back: same utterance (time+text corroborate) but a different
403
+ // speaker label -> speaker conflict, aligned into one segment.
404
+ chosen = closestCorroborating(false);
405
+ }
406
+
407
+ if (chosen !== undefined) {
408
+ chosen.members.push(seg);
409
+ if (seg.originalIndex < chosen.originalIndex) {
410
+ chosen.originalIndex = seg.originalIndex;
411
+ }
412
+ } else {
413
+ groups.push({
414
+ key,
415
+ speakerLabel: seg.speakerLabel,
416
+ isSelf: seg.isSelf,
417
+ members: [seg],
418
+ anchorMs: seg.startMs,
419
+ // The creating segment is the EARLIEST member (tagged is
420
+ // time-sorted), so its clock IS the group anchor that groups are
421
+ // sorted by and that the fused segment must be emitted at.
422
+ anchorStartIso: seg.startIso,
423
+ anchorEndIso: seg.endIso,
424
+ originalIndex: seg.originalIndex,
425
+ });
426
+ }
427
+ }
428
+
429
+ // Output groups in time order; missing-start groups last. Equal-anchorMs
430
+ // (and all-missing) groups tie-break on original transcript/source
431
+ // sequence — not source/speaker label — so equal-time utterances keep
432
+ // the order they appeared in their input transcripts.
433
+ groups.sort((a, b) => {
434
+ const aMissing = !Number.isFinite(a.anchorMs);
435
+ const bMissing = !Number.isFinite(b.anchorMs);
436
+ if (aMissing !== bMissing) return aMissing ? 1 : -1;
437
+ if (!aMissing && a.anchorMs !== b.anchorMs) return a.anchorMs - b.anchorMs;
438
+ return a.originalIndex - b.originalIndex;
439
+ });
440
+
441
+ const segments: FusedSegment[] = [];
442
+ const disagreements: FusedDisagreement[] = [];
443
+ for (const group of groups) {
444
+ const reconciled = reconcileGroup(group);
445
+ segments.push(reconciled.segment);
446
+ disagreements.push(...reconciled.disagreements);
447
+ }
448
+ // Belt-and-suspenders: segments are built from anchor-sorted groups with
449
+ // each segment's startIso pinned to its group anchor, so the array is
450
+ // already chronological. This explicit, STABLE re-sort guarantees that
451
+ // invariant end to end — no trust-based text/content swap can ever
452
+ // reorder the timeline, even if a future change alters how a fused
453
+ // segment's timestamp is derived. Stability contract:
454
+ // - timestamped segments sort by startMs ascending;
455
+ // - missing/invalid timestamps keep their position (timestamped before
456
+ // missing, preserving original relative order among missing ones);
457
+ // - equal timestamps preserve prior order via a deterministic secondary
458
+ // key (the pre-sort index), never reordering by source/speaker — so
459
+ // cross-source clock skew does not scramble same-anchor utterances.
460
+ const preSortOrder = new Map(segments.map((seg, i) => [seg, i]));
461
+ segments.sort((a, b) => {
462
+ const aMs = a.startIso !== undefined ? Date.parse(a.startIso) : Number.NaN;
463
+ const bMs = b.startIso !== undefined ? Date.parse(b.startIso) : Number.NaN;
464
+ const aFinite = Number.isFinite(aMs);
465
+ const bFinite = Number.isFinite(bMs);
466
+ if (aFinite && bFinite) {
467
+ if (aMs !== bMs) return aMs - bMs;
468
+ } else if (aFinite !== bFinite) {
469
+ return aFinite ? -1 : 1;
470
+ }
471
+ // Equal timestamp (or both missing): preserve prior order — never let
472
+ // a naive ts-only comparator scramble equal/missing-time entries.
473
+ return (preSortOrder.get(a) ?? 0) - (preSortOrder.get(b) ?? 0);
474
+ });
475
+ // Cross-segment ASR disagreements: distinct utterances kept as separate
476
+ // segments (never collapsed) yet sharing a speaker + time window and
477
+ // disagreeing on wording. Neither side's content is lost — both remain
478
+ // visible segments — but the unresolved conflict is still surfaced for
479
+ // review and each involved segment's confidence is lowered, matching
480
+ // how an in-group conflict is flagged.
481
+ detectCrossSegmentDisagreements(segments, toleranceMs, disagreements);
482
+
483
+ const result: FuseClusterResult = {
484
+ sources,
485
+ speakers: collectSpeakers(tagged),
486
+ startIso,
487
+ segments,
488
+ disagreements,
489
+ };
490
+ if (endIso !== undefined) result.endIso = endIso;
491
+ if (title !== undefined) result.title = title;
492
+ if (summary !== undefined) result.summary = summary;
493
+ return result;
494
+ }
495
+
496
+ interface ReconciledGroup {
497
+ segment: FusedSegment;
498
+ disagreements: FusedDisagreement[];
499
+ }
500
+
501
+ function reconcileGroup(group: AlignGroup): ReconciledGroup {
502
+ const members = group.members;
503
+ // Rank: higher trust, then more-complete (longer), then stable source
504
+ // order, finally the original transcript index — a total order so the
505
+ // comparator returns 0 only for an identical item (never nonzero on a
506
+ // tie), keeping the sort stable and contract-compliant.
507
+ const ranked = [...members].sort((a, b) => {
508
+ if (Math.abs(b.sourceTrust - a.sourceTrust) > TRUST_DECISION_EPSILON) {
509
+ return b.sourceTrust - a.sourceTrust;
510
+ }
511
+ if (b.text.length !== a.text.length) return b.text.length - a.text.length;
512
+ if (a.source !== b.source) return a.source < b.source ? -1 : 1;
513
+ if (a.conversationId !== b.conversationId) {
514
+ return a.conversationId < b.conversationId ? -1 : 1;
515
+ }
516
+ return a.originalIndex - b.originalIndex;
517
+ });
518
+ const top = ranked[0];
519
+ const topWords = normalizeWords(top.text);
520
+
521
+ // Truncation override: the top-ranked (highest-trust) member may be a
522
+ // CLIPPED version of a corroborating candidate — e.g. a summary source
523
+ // clipped at "The deploy window opens at" while a verbatim source has
524
+ // the full sentence. Trust dominates the rank, so without this the
525
+ // truncated high-trust text would win despite the documented
526
+ // "more-complete wins" rule. When the top's words are a strict prefix of
527
+ // another member's words, adopt the LONGEST such more-complete member's
528
+ // text + provenance.
529
+ let chosen = top;
530
+ if (topWords.length > 0) {
531
+ let bestLen = -1;
532
+ for (const candidate of ranked) {
533
+ if (candidate === top) continue;
534
+ const candidateWords = normalizeWords(candidate.text);
535
+ if (
536
+ isWordPrefix(topWords, candidateWords) &&
537
+ candidateWords.length > topWords.length &&
538
+ candidateWords.length > bestLen
539
+ ) {
540
+ chosen = candidate;
541
+ bestLen = candidateWords.length;
542
+ }
543
+ }
544
+ }
545
+ const truncatedOverride = chosen !== top;
546
+ const others = ranked.filter((member) => member !== chosen);
547
+
548
+ const chosenWords = normalizeWords(chosen.text);
549
+
550
+ const reason: SegmentPickReason =
551
+ members.length === 1
552
+ ? "only-source"
553
+ : truncatedOverride
554
+ ? "more-complete"
555
+ : others.some(
556
+ (r) => chosen.sourceTrust - r.sourceTrust > TRUST_DECISION_EPSILON,
557
+ )
558
+ ? "higher-trust"
559
+ : others.some((r) => chosen.text.length - r.text.length > 0)
560
+ ? "more-complete"
561
+ : "tie-break";
562
+
563
+ // Conflict detection: a candidate whose word sequence differs from the
564
+ // chosen text AND is not a containment (one a prefix/truncation of the
565
+ // other). Containment is "more-complete wins" with no disagreement.
566
+ const disagree: Array<{ source: string; value: string }> = [];
567
+ let conflict = false;
568
+ for (const candidate of others) {
569
+ const candidateWords = normalizeWords(candidate.text);
570
+ if (chosenWords.length === 0 && candidateWords.length === 0) continue;
571
+ if (wordsEqual(chosenWords, candidateWords)) continue;
572
+ if (
573
+ isWordPrefix(candidateWords, chosenWords) ||
574
+ isWordPrefix(chosenWords, candidateWords)
575
+ ) {
576
+ continue;
577
+ }
578
+ conflict = true;
579
+ disagree.push({ source: candidate.source, value: candidate.text });
580
+ }
581
+
582
+ // Speaker-attribution conflict: members of the same utterance disagree
583
+ // on who spoke (same time window + corroborating text, different label).
584
+ // Detected from member labels — not the group key — so a group that
585
+ // absorbed a conflicting speaker is handled identically. The chosen
586
+ // (top-ranked) member's attribution wins the segment provisionally;
587
+ // every source's label is surfaced as a disagreement with full
588
+ // provenance so no attribution is silently lost.
589
+ const distinctSpeakerKeys = new Set(
590
+ members.map((m) => speakerKey(m.speakerLabel, m.isSelf)),
591
+ );
592
+ const speakerConflict = distinctSpeakerKeys.size > 1;
593
+
594
+ const alternatives = others.map((candidate) => ({
595
+ source: candidate.source,
596
+ text: candidate.text,
597
+ }));
598
+
599
+ // Confidence: single source -> its trust; corroboration boosts; a
600
+ // recorded text OR speaker conflict lowers it.
601
+ const confidence =
602
+ conflict || speakerConflict
603
+ ? clamp01(chosen.sourceTrust * 0.7)
604
+ : members.length > 1
605
+ ? clamp01(chosen.sourceTrust + 0.1 * (members.length - 1))
606
+ : clamp01(chosen.sourceTrust);
607
+
608
+ // TIMELINE POSITION (startIso/endIso) comes from the group/window ANCHOR
609
+ // — the canonical utterance time the groups are sorted by — NOT from the
610
+ // chosen (higher-trust) source's clock. The chosen source may be LATER
611
+ // than the anchor (a low-trust utterance at 10:00 corroborated by a
612
+ // high-trust source at 10:25 within tolerance); emitting chosen.startIso
613
+ // would print the fused segment at 10:25 even though the group sits at
614
+ // the 10:00 anchor, jumping it past an intervening 10:10 utterance and
615
+ // corrupting the chronology. The chosen source still provides the TEXT;
616
+ // only the timeline position is anchored. Its original clock is kept in
617
+ // provenance so the source's recording time stays traceable.
618
+ const segment: FusedSegment = {
619
+ speaker: chosen.speakerLabel,
620
+ isSelf: chosen.isSelf,
621
+ text: chosen.text,
622
+ confidence,
623
+ provenance: {
624
+ source: chosen.source,
625
+ conversationId: chosen.conversationId,
626
+ sourceTrust: chosen.sourceTrust,
627
+ reason,
628
+ alternatives,
629
+ ...(chosen.startIso !== undefined
630
+ ? { sourceStartIso: chosen.startIso }
631
+ : {}),
632
+ ...(chosen.endIso !== undefined ? { sourceEndIso: chosen.endIso } : {}),
633
+ },
634
+ ...(group.anchorStartIso !== undefined
635
+ ? { startIso: group.anchorStartIso }
636
+ : {}),
637
+ ...(group.anchorEndIso !== undefined ? { endIso: group.anchorEndIso } : {}),
638
+ };
639
+
640
+ const disagreements: FusedDisagreement[] = [];
641
+ // The disagreement subject is the EMITTED timeline position (the group
642
+ // anchor), coherent with segment.startIso — not the chosen source's
643
+ // possibly-later clock.
644
+ const anchorIso =
645
+ group.anchorStartIso ??
646
+ (Number.isFinite(group.anchorMs)
647
+ ? new Date(group.anchorMs).toISOString()
648
+ : undefined);
649
+ if (conflict) {
650
+ disagreements.push({
651
+ kind: "asr-text",
652
+ subject: anchorIso ?? "(no timestamp)",
653
+ candidates: [{ source: chosen.source, value: chosen.text }, ...disagree],
654
+ provisional: { source: chosen.source, value: chosen.text },
655
+ });
656
+ }
657
+ if (speakerConflict) {
658
+ // One candidate per source (a group holds one member per source),
659
+ // sorted by source for determinism; the chosen source is provisional.
660
+ const speakerCandidates = members
661
+ .map((m) => ({ source: m.source, value: m.speakerLabel }))
662
+ .sort((a, b) =>
663
+ a.source < b.source ? -1 : a.source > b.source ? 1 : 0,
664
+ );
665
+ disagreements.push({
666
+ kind: "speaker",
667
+ subject: anchorIso ?? "(no timestamp)",
668
+ candidates: speakerCandidates,
669
+ provisional: { source: chosen.source, value: chosen.speakerLabel },
670
+ });
671
+ }
672
+
673
+ return { segment, disagreements };
674
+ }
675
+
676
+ /**
677
+ * Detect ASR-text disagreements BETWEEN segments that were intentionally
678
+ * kept separate (distinct utterances) yet share a speaker and a time
679
+ * window and disagree on wording. Unlike an in-group conflict (same
680
+ * utterance bridged by a truncation), these never merged — so neither
681
+ * side's content is lost — but the unresolved disagreement is still
682
+ * surfaced for review and each involved segment's confidence is lowered.
683
+ *
684
+ * Timestamped only: untimestamped segments have no window anchor to compare
685
+ * against. Mutates `confidence` on involved segments and appends to `out`.
686
+ */
687
+ function detectCrossSegmentDisagreements(
688
+ segments: FusedSegment[],
689
+ toleranceMs: number,
690
+ out: FusedDisagreement[],
691
+ ): void {
692
+ const n = segments.length;
693
+ if (n < 2) return;
694
+ const claimed = new Array<boolean>(n).fill(false);
695
+ for (let i = 0; i < n; i++) {
696
+ if (claimed[i]) continue;
697
+ const seed = segments[i]!;
698
+ const seedMs =
699
+ seed.startIso !== undefined ? Date.parse(seed.startIso) : NaN;
700
+ if (!Number.isFinite(seedMs)) continue;
701
+ const seedWords = normalizeWords(seed.text);
702
+ const clusterIdx: number[] = [];
703
+ for (let j = i + 1; j < n; j++) {
704
+ if (claimed[j]) continue;
705
+ const cand = segments[j]!;
706
+ // Speaker identity is matched via speakerKey() — the SAME
707
+ // normalization grouping uses (line 305) — not the raw display
708
+ // label. The wearer is often labeled differently per source (e.g.
709
+ // "Me (you)" vs an override "Alex (you)"); both normalize to
710
+ // "self". Matching on the raw label would skip them, leaving a real
711
+ // same-window ASR-text disagreement undetected and both confidences
712
+ // wrongly high. (Issue #1849.)
713
+ if (
714
+ speakerKey(cand.speaker, cand.isSelf) !==
715
+ speakerKey(seed.speaker, seed.isSelf)
716
+ ) {
717
+ continue;
718
+ }
719
+ if (cand.provenance.source === seed.provenance.source) continue;
720
+ const candMs =
721
+ cand.startIso !== undefined ? Date.parse(cand.startIso) : NaN;
722
+ if (!Number.isFinite(candMs)) continue;
723
+ if (Math.abs(candMs - seedMs) > toleranceMs) continue;
724
+ if (wordsCorroborate(seedWords, normalizeWords(cand.text), true)) {
725
+ continue;
726
+ }
727
+ clusterIdx.push(j);
728
+ }
729
+ if (clusterIdx.length === 0) continue;
730
+ clusterIdx.unshift(i);
731
+ for (const idx of clusterIdx) claimed[idx] = true;
732
+
733
+ const involved = clusterIdx.map((idx) => segments[idx]!);
734
+ // Stable secondary key: each involved segment's position in the
735
+ // reconciled segment array (clusterIdx is ascending). Two cands can
736
+ // share a source, so source alone is NOT a total order here; the
737
+ // position tie-break makes the comparator return 0 only for an
738
+ // identical item and keeps the ranking deterministic — mirroring
739
+ // reconcileGroup's originalIndex tie-break.
740
+ const involvedOrder = new Map(involved.map((seg, i) => [seg, i]));
741
+ involved.sort((a, b) => {
742
+ if (
743
+ Math.abs(b.provenance.sourceTrust - a.provenance.sourceTrust) >
744
+ TRUST_DECISION_EPSILON
745
+ ) {
746
+ return b.provenance.sourceTrust - a.provenance.sourceTrust;
747
+ }
748
+ if (b.text.length !== a.text.length) return b.text.length - a.text.length;
749
+ if (a.provenance.source !== b.provenance.source) {
750
+ return a.provenance.source < b.provenance.source ? -1 : 1;
751
+ }
752
+ return involvedOrder.get(a)! - involvedOrder.get(b)!;
753
+ });
754
+ const provisional = involved[0]!;
755
+ const anchorIso = involved
756
+ .map((s) => s.startIso)
757
+ .filter((v): v is string => v !== undefined)
758
+ .sort()[0];
759
+
760
+ for (const seg of involved) {
761
+ seg.confidence = clamp01(seg.provenance.sourceTrust * 0.7);
762
+ }
763
+
764
+ out.push({
765
+ kind: "asr-text",
766
+ subject: anchorIso ?? "(no timestamp)",
767
+ candidates: involved.map((s) => ({
768
+ source: s.provenance.source,
769
+ value: s.text,
770
+ })),
771
+ provisional: {
772
+ source: provisional.provenance.source,
773
+ value: provisional.text,
774
+ },
775
+ });
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Build the reconciled speaker list. The wearer unifies across all
781
+ * sources; generic labels (bare "Speaker N" or a raw key) seen in only
782
+ * one source get a lowered confidence since they could not be
783
+ * confidently matched across sources.
784
+ */
785
+ function collectSpeakers(tagged: readonly TaggedSegment[]): FusedSpeaker[] {
786
+ interface Acc {
787
+ label: string;
788
+ isSelf: boolean;
789
+ sources: Set<string>;
790
+ generic: boolean;
791
+ }
792
+ const order: string[] = [];
793
+ const acc = new Map<string, Acc>();
794
+ for (const seg of tagged) {
795
+ const key = speakerKey(seg.speakerLabel, seg.isSelf);
796
+ let entry = acc.get(key);
797
+ if (entry === undefined) {
798
+ entry = {
799
+ label: seg.speakerLabel,
800
+ isSelf: seg.isSelf,
801
+ sources: new Set<string>(),
802
+ generic: GENERIC_LABEL_PATTERN.test(seg.speakerLabel),
803
+ };
804
+ acc.set(key, entry);
805
+ order.push(key);
806
+ }
807
+ entry.sources.add(seg.source);
808
+ }
809
+ return order.map((key) => {
810
+ const entry = acc.get(key);
811
+ if (entry === undefined) throw new Error("fusion speaker accumulator miss");
812
+ const sourceCount = entry.sources.size;
813
+ const confidence = entry.isSelf
814
+ ? sourceCount > 1
815
+ ? 0.95
816
+ : 0.85
817
+ : entry.generic
818
+ ? sourceCount > 1
819
+ ? 0.7
820
+ : 0.5
821
+ : sourceCount > 1
822
+ ? 0.9
823
+ : 0.7;
824
+ return {
825
+ label: entry.label,
826
+ isSelf: entry.isSelf,
827
+ confidence,
828
+ sources: [...entry.sources].sort(),
829
+ };
830
+ });
831
+ }