@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.
- package/dist/access-admin-ops-surface.d.ts +6 -6
- package/dist/access-admin-ops-surface.js +28 -28
- package/dist/access-boundary.d.ts +27 -8
- package/dist/access-boundary.js +33 -29
- package/dist/access-cli.js +83 -81
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +52 -6
- package/dist/access-http.js +40 -38
- package/dist/access-identity-continuity-surface.d.ts +5 -5
- package/dist/access-identity-continuity-surface.js +28 -28
- package/dist/access-lcm-surface.d.ts +6 -6
- package/dist/access-lcm-surface.js +28 -28
- package/dist/access-mcp.d.ts +16 -6
- package/dist/access-mcp.js +37 -35
- package/dist/access-observe-write-surface.d.ts +6 -6
- package/dist/access-observe-write-surface.js +28 -28
- package/dist/access-operations-batch.js +33 -32
- package/dist/access-operations.d.ts +9 -9
- package/dist/access-operations.js +35 -34
- package/dist/access-recall-surface.d.ts +6 -6
- package/dist/access-recall-surface.js +28 -28
- package/dist/access-schema.d.ts +4 -4
- package/dist/access-schema.js +4 -4
- package/dist/{access-service-m-2VO_Ig.d.ts → access-service-CXJaql3A.d.ts} +4 -4
- package/dist/access-service.d.ts +6 -6
- package/dist/access-service.js +28 -28
- package/dist/access-surface-catalog.d.ts +6 -6
- package/dist/access-surface-catalog.js +1 -0
- package/dist/access-surface-catalog.js.map +1 -1
- package/dist/access-token-capabilities.d.ts +174 -0
- package/dist/access-token-capabilities.js +33 -0
- package/dist/action-confidence.d.ts +1 -1
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +2 -2
- package/dist/adapters/index.js +4 -4
- package/dist/adapters/registry.js +2 -2
- package/dist/{auto-sync-HMHQHUAG.js → auto-sync-4A5YBWYO.js} +4 -4
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +5 -5
- package/dist/briefing.d.ts +2 -2
- package/dist/briefing.js +9 -9
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +2 -2
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/{capsule-crypto-CZJSLEFG.js → capsule-crypto-RAARDY3M.js} +2 -2
- package/dist/capsule-crypto-RAARDY3M.js.map +1 -0
- package/dist/{catalog-BxWokdC2.d.ts → catalog-C9cu67Ig.d.ts} +1 -1
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-behavior.js +5 -5
- package/dist/causal-chain.js +3 -3
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +13 -13
- package/dist/causal-retrieval.js +3 -3
- package/dist/causal-trajectory-graph.d.ts +1 -1
- package/dist/causal-trajectory.js +1 -1
- package/dist/{chunk-6ZIETXZ2.js → chunk-23JFRB73.js} +2 -2
- package/dist/{chunk-IC6QX5TC.js → chunk-3XIY7MDQ.js} +6 -6
- package/dist/{chunk-FZGGZAAU.js → chunk-4GNEDXDI.js} +146 -2
- package/dist/chunk-4GNEDXDI.js.map +1 -0
- package/dist/{chunk-LOR6ZM5R.js → chunk-4LV23CHK.js} +2 -2
- package/dist/{chunk-E2SPGGUI.js → chunk-4OKLXWDZ.js} +13 -6
- package/dist/chunk-4OKLXWDZ.js.map +1 -0
- package/dist/{chunk-AER6MT24.js → chunk-6SXVCD7W.js} +2 -2
- package/dist/{chunk-OHDJ34CC.js → chunk-72WG5QKN.js} +2 -2
- package/dist/{chunk-CZAZX7JP.js → chunk-7MW3CVLD.js} +4 -4
- package/dist/{chunk-CXLNZXG5.js → chunk-7XGSIUP5.js} +1 -1
- package/dist/{chunk-CZAA5N5S.js → chunk-AYZJID4S.js} +2 -2
- package/dist/{chunk-M7XQSUBB.js → chunk-B3ABVQ36.js} +116 -9
- package/dist/chunk-B3ABVQ36.js.map +1 -0
- package/dist/{chunk-6BISDEN6.js → chunk-BEM4D2QG.js} +14 -14
- package/dist/{chunk-ZPOROUTX.js → chunk-CYWA2CXR.js} +5 -5
- package/dist/{chunk-M5QKGHCR.js → chunk-CYWCKFCL.js} +5 -5
- package/dist/chunk-D7FOKIY5.js +173 -0
- package/dist/chunk-D7FOKIY5.js.map +1 -0
- package/dist/{chunk-4BFRBP45.js → chunk-DEG5ULFJ.js} +2 -2
- package/dist/{chunk-JLR3JSTL.js → chunk-DS7MM2ES.js} +3 -3
- package/dist/{chunk-2BFLDH5F.js → chunk-EEIROWEG.js} +2 -2
- package/dist/{chunk-5QZDMSSN.js → chunk-FKCRGLRD.js} +237 -82
- package/dist/{chunk-5QZDMSSN.js.map → chunk-FKCRGLRD.js.map} +1 -1
- package/dist/{chunk-X4A62ET7.js → chunk-H2DXDQCQ.js} +17 -17
- package/dist/{chunk-27FLVMDC.js → chunk-HLN7RROI.js} +7 -7
- package/dist/chunk-IC2TCZJ2.js +54 -0
- package/dist/chunk-IC2TCZJ2.js.map +1 -0
- package/dist/{chunk-2U2A5HF4.js → chunk-J3XHPFEU.js} +6 -6
- package/dist/{chunk-AKUCU7CI.js → chunk-JI5AVXYO.js} +5 -5
- package/dist/{chunk-KCJ5H6XQ.js → chunk-JSNVWNGX.js} +2 -2
- package/dist/{chunk-7Z2DZNWN.js → chunk-KL4UAXUY.js} +43 -2
- package/dist/{chunk-7Z2DZNWN.js.map → chunk-KL4UAXUY.js.map} +1 -1
- package/dist/{chunk-FLVL4L3L.js → chunk-KTULX4YO.js} +4 -4
- package/dist/{chunk-ILFJ7AVS.js → chunk-LADFWYHY.js} +35 -21
- package/dist/chunk-LADFWYHY.js.map +1 -0
- package/dist/{chunk-437S3G37.js → chunk-LNQUAJNC.js} +1 -1
- package/dist/{chunk-EPGWWCZF.js → chunk-LR5DZ56F.js} +6 -6
- package/dist/{chunk-YDSWRD33.js → chunk-N3JDIRBW.js} +4 -4
- package/dist/{chunk-OUMTBFSQ.js → chunk-NKCPUHCI.js} +5 -5
- package/dist/{chunk-7QWMTOIF.js → chunk-P454KXD6.js} +112 -34
- package/dist/chunk-P454KXD6.js.map +1 -0
- package/dist/{chunk-DR6PEJU3.js → chunk-PCLIWBGD.js} +44 -59
- package/dist/chunk-PCLIWBGD.js.map +1 -0
- package/dist/{chunk-BGTAL46Y.js → chunk-PNLFN2PZ.js} +2 -2
- package/dist/{chunk-P6SYWE4G.js → chunk-PVUJ6WVV.js} +10 -10
- package/dist/{chunk-YPEMNNNY.js → chunk-RGK27ELN.js} +6 -6
- package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
- package/dist/{chunk-66FLU5BT.js → chunk-SIRPLF54.js} +6 -6
- package/dist/{chunk-M7GGMGMZ.js → chunk-SVOHGUMI.js} +1202 -126
- package/dist/chunk-SVOHGUMI.js.map +1 -0
- package/dist/{chunk-HRCACVHL.js → chunk-TLUDFPZL.js} +2 -2
- package/dist/{chunk-2FOD2YY3.js → chunk-UFUKHGZD.js} +236 -103
- package/dist/chunk-UFUKHGZD.js.map +1 -0
- package/dist/{chunk-2MR3MFQB.js → chunk-UPSAT4PY.js} +2 -2
- package/dist/{chunk-4P4626RY.js → chunk-UTRJFDP2.js} +4 -4
- package/dist/{chunk-CGS7UKWM.js → chunk-VCW74PNH.js} +2 -2
- package/dist/{chunk-5GPPACXK.js → chunk-VSMRDBFT.js} +2 -1
- package/dist/{chunk-NG7YBCJO.js → chunk-XCL57M6Q.js} +4 -4
- package/dist/{chunk-CLX5VDNY.js → chunk-Y4RKAQZ7.js} +4 -4
- package/dist/{chunk-DGYCWCQ7.js → chunk-Y7UNNAZD.js} +2 -2
- package/dist/{chunk-EZ25VE3G.js → chunk-YNDLCWXS.js} +4 -4
- package/dist/{chunk-XN3TUUX2.js → chunk-YQB32INI.js} +2 -2
- package/dist/{cli-EiBBOt3r.d.ts → cli-CqdPA6z9.d.ts} +3 -3
- package/dist/cli.d.ts +7 -7
- package/dist/cli.js +61 -59
- package/dist/compounding/engine.d.ts +2 -2
- package/dist/compounding/engine.js +9 -9
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +2 -2
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +9 -9
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +13 -11
- package/dist/consolidation-provenance-check.d.ts +2 -2
- package/dist/consolidation-undo.d.ts +2 -2
- package/dist/contradiction/index.d.ts +2 -2
- package/dist/contradiction/index.js +4 -4
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/backend.js +2 -2
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/dashboard-runtime.js +2 -2
- package/dist/day-summary.d.ts +1 -1
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-retrieval.d.ts +2 -2
- package/dist/entity-retrieval.js +9 -9
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +5 -5
- package/dist/extraction-faithfulness.d.ts +1 -1
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/{first-start-migration-DY6YCRC2.js → first-start-migration-VBJQL6OI.js} +5 -5
- package/dist/graph-dashboard-diff.d.ts +1 -1
- package/dist/graph-dashboard-key.d.ts +1 -1
- package/dist/graph-dashboard-parser.d.ts +1 -1
- package/dist/graph-edge-reinforcement.d.ts +1 -1
- package/dist/graph-snapshot.d.ts +1 -1
- package/dist/graph.d.ts +1 -1
- package/dist/identity-continuity.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/index.d.ts +271 -17
- package/dist/index.js +163 -99
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/index.js +2 -2
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/local-model-endpoint.d.ts +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +10 -10
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +9 -9
- package/dist/maintenance/rebuild-memory-projection.js +11 -11
- package/dist/mcp-memory-inspector-app.d.ts +6 -6
- package/dist/mcp-read-only-tools.d.ts +16 -0
- package/dist/mcp-read-only-tools.js +8 -0
- package/dist/mcp-read-only-tools.js.map +1 -0
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-worth-outcomes.d.ts +2 -2
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +3 -3
- package/dist/namespaces/migrate.js +19 -19
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/search.js +10 -10
- package/dist/namespaces/storage.d.ts +3 -3
- package/dist/namespaces/storage.js +9 -9
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +2 -2
- package/dist/operator-toolkit.js +25 -25
- package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
- package/dist/orchestration/maintenance.d.ts +2 -2
- package/dist/orchestration/maintenance.js +11 -11
- package/dist/{orchestrator-B2Y28Z9t.d.ts → orchestrator-CWFrT8AF.d.ts} +91 -60
- package/dist/orchestrator.d.ts +5 -5
- package/dist/orchestrator.js +85 -83
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/provenance.d.ts +1 -1
- package/dist/{qmd-Db9mOkzB.d.ts → qmd--fnPMK60.d.ts} +1 -1
- package/dist/qmd-preflight.d.ts +2 -2
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +2 -2
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-timings.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray.d.ts +1 -1
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +4 -4
- package/dist/retrieval-agents.d.ts +2 -2
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/schemas.d.ts +22 -22
- package/dist/search/document-scanner.js +2 -2
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/factory.js +9 -9
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.js +13 -13
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/lancedb-backend.js +3 -3
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.js +3 -3
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/orama-backend.js +3 -3
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-BDVIbLRw.d.ts → semantic-consolidation-O5lMZ4LG.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +11 -11
- package/dist/semantic-rule-promotion.js +9 -9
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +10 -10
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +1 -1
- package/dist/signal.d.ts +1 -1
- package/dist/storage-C1maCNbc.d.ts +1591 -0
- package/dist/storage.d.ts +5 -1239
- package/dist/storage.js +8 -8
- package/dist/summarizer.d.ts +1 -1
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +2 -2
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +2 -2
- package/dist/tier-routing.d.ts +1 -1
- package/dist/{tier-stats-TG6GUZ6N.js → tier-stats-5XF4UBRQ.js} +4 -4
- package/dist/tokens.d.ts +17 -2
- package/dist/tokens.js +3 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +3 -3
- package/dist/transfer/capsule-import.js +3 -3
- package/dist/transfer/types.d.ts +12 -12
- package/dist/trust-score-stage.d.ts +1 -1
- package/dist/trust-score.d.ts +1 -1
- package/dist/{types-ljkd82rn.d.ts → types-CGgcNWCG.d.ts} +31 -1
- package/dist/types.d.ts +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +10 -10
- package/package.json +2 -2
- package/src/access-boundary.test.ts +62 -0
- package/src/access-boundary.ts +165 -126
- package/src/access-http.test.ts +781 -0
- package/src/access-http.ts +334 -108
- package/src/access-mcp.test.ts +264 -0
- package/src/access-mcp.ts +45 -70
- package/src/access-operations-batch.ts +35 -12
- package/src/access-surface-catalog.test.ts +22 -14
- package/src/access-surface-catalog.ts +6 -3
- package/src/access-token-capabilities.test.ts +844 -0
- package/src/access-token-capabilities.ts +391 -0
- package/src/chat/chat-mcp.test.ts +232 -0
- package/src/chat/chat-session.ts +34 -0
- package/src/cli-wearables-fuse.test.ts +197 -0
- package/src/cli.ts +25 -0
- package/src/index.ts +14 -0
- package/src/mcp-read-only-tools.ts +61 -0
- package/src/storage.ts +16 -0
- package/src/tokens.ts +32 -3
- package/src/wearables/cli.test.ts +102 -0
- package/src/wearables/cli.ts +65 -0
- package/src/wearables/config.test.ts +32 -0
- package/src/wearables/config.ts +52 -0
- package/src/wearables/day-store.test.ts +246 -0
- package/src/wearables/day-store.ts +272 -3
- package/src/wearables/fusion/cluster.ts +230 -0
- package/src/wearables/fusion/fuse.ts +218 -0
- package/src/wearables/fusion/fusion.test.ts +2575 -0
- package/src/wearables/fusion/index.ts +50 -0
- package/src/wearables/fusion/reconcile.ts +831 -0
- package/src/wearables/fusion/reconstruct.ts +251 -0
- package/src/wearables/fusion/store.ts +480 -0
- package/src/wearables/fusion/types.ts +228 -0
- package/src/wearables/index.ts +41 -0
- package/src/wearables/service.test.ts +1030 -2
- package/src/wearables/service.ts +258 -16
- package/src/wearables/speakers.test.ts +128 -0
- package/src/wearables/speakers.ts +58 -4
- package/src/wearables/storage-io.test.ts +146 -0
- package/src/wearables/types.ts +31 -0
- package/dist/chunk-2FOD2YY3.js.map +0 -1
- package/dist/chunk-7QWMTOIF.js.map +0 -1
- package/dist/chunk-DR6PEJU3.js.map +0 -1
- package/dist/chunk-E2SPGGUI.js.map +0 -1
- package/dist/chunk-FZGGZAAU.js.map +0 -1
- package/dist/chunk-ILFJ7AVS.js.map +0 -1
- package/dist/chunk-M7GGMGMZ.js.map +0 -1
- package/dist/chunk-M7XQSUBB.js.map +0 -1
- /package/dist/{capsule-crypto-CZJSLEFG.js.map → access-token-capabilities.js.map} +0 -0
- /package/dist/{auto-sync-HMHQHUAG.js.map → auto-sync-4A5YBWYO.js.map} +0 -0
- /package/dist/{chunk-6ZIETXZ2.js.map → chunk-23JFRB73.js.map} +0 -0
- /package/dist/{chunk-IC6QX5TC.js.map → chunk-3XIY7MDQ.js.map} +0 -0
- /package/dist/{chunk-LOR6ZM5R.js.map → chunk-4LV23CHK.js.map} +0 -0
- /package/dist/{chunk-AER6MT24.js.map → chunk-6SXVCD7W.js.map} +0 -0
- /package/dist/{chunk-OHDJ34CC.js.map → chunk-72WG5QKN.js.map} +0 -0
- /package/dist/{chunk-CZAZX7JP.js.map → chunk-7MW3CVLD.js.map} +0 -0
- /package/dist/{chunk-CXLNZXG5.js.map → chunk-7XGSIUP5.js.map} +0 -0
- /package/dist/{chunk-CZAA5N5S.js.map → chunk-AYZJID4S.js.map} +0 -0
- /package/dist/{chunk-6BISDEN6.js.map → chunk-BEM4D2QG.js.map} +0 -0
- /package/dist/{chunk-ZPOROUTX.js.map → chunk-CYWA2CXR.js.map} +0 -0
- /package/dist/{chunk-M5QKGHCR.js.map → chunk-CYWCKFCL.js.map} +0 -0
- /package/dist/{chunk-4BFRBP45.js.map → chunk-DEG5ULFJ.js.map} +0 -0
- /package/dist/{chunk-JLR3JSTL.js.map → chunk-DS7MM2ES.js.map} +0 -0
- /package/dist/{chunk-2BFLDH5F.js.map → chunk-EEIROWEG.js.map} +0 -0
- /package/dist/{chunk-X4A62ET7.js.map → chunk-H2DXDQCQ.js.map} +0 -0
- /package/dist/{chunk-27FLVMDC.js.map → chunk-HLN7RROI.js.map} +0 -0
- /package/dist/{chunk-2U2A5HF4.js.map → chunk-J3XHPFEU.js.map} +0 -0
- /package/dist/{chunk-AKUCU7CI.js.map → chunk-JI5AVXYO.js.map} +0 -0
- /package/dist/{chunk-KCJ5H6XQ.js.map → chunk-JSNVWNGX.js.map} +0 -0
- /package/dist/{chunk-FLVL4L3L.js.map → chunk-KTULX4YO.js.map} +0 -0
- /package/dist/{chunk-437S3G37.js.map → chunk-LNQUAJNC.js.map} +0 -0
- /package/dist/{chunk-EPGWWCZF.js.map → chunk-LR5DZ56F.js.map} +0 -0
- /package/dist/{chunk-YDSWRD33.js.map → chunk-N3JDIRBW.js.map} +0 -0
- /package/dist/{chunk-OUMTBFSQ.js.map → chunk-NKCPUHCI.js.map} +0 -0
- /package/dist/{chunk-BGTAL46Y.js.map → chunk-PNLFN2PZ.js.map} +0 -0
- /package/dist/{chunk-P6SYWE4G.js.map → chunk-PVUJ6WVV.js.map} +0 -0
- /package/dist/{chunk-YPEMNNNY.js.map → chunk-RGK27ELN.js.map} +0 -0
- /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
- /package/dist/{chunk-66FLU5BT.js.map → chunk-SIRPLF54.js.map} +0 -0
- /package/dist/{chunk-HRCACVHL.js.map → chunk-TLUDFPZL.js.map} +0 -0
- /package/dist/{chunk-2MR3MFQB.js.map → chunk-UPSAT4PY.js.map} +0 -0
- /package/dist/{chunk-4P4626RY.js.map → chunk-UTRJFDP2.js.map} +0 -0
- /package/dist/{chunk-CGS7UKWM.js.map → chunk-VCW74PNH.js.map} +0 -0
- /package/dist/{chunk-5GPPACXK.js.map → chunk-VSMRDBFT.js.map} +0 -0
- /package/dist/{chunk-NG7YBCJO.js.map → chunk-XCL57M6Q.js.map} +0 -0
- /package/dist/{chunk-CLX5VDNY.js.map → chunk-Y4RKAQZ7.js.map} +0 -0
- /package/dist/{chunk-DGYCWCQ7.js.map → chunk-Y7UNNAZD.js.map} +0 -0
- /package/dist/{chunk-EZ25VE3G.js.map → chunk-YNDLCWXS.js.map} +0 -0
- /package/dist/{chunk-XN3TUUX2.js.map → chunk-YQB32INI.js.map} +0 -0
- /package/dist/{first-start-migration-DY6YCRC2.js.map → first-start-migration-VBJQL6OI.js.map} +0 -0
- /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
|
+
});
|