@ephia/dova-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +89 -0
  2. package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
  3. package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
  4. package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
  5. package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
  6. package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
  7. package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
  8. package/dist/Transport-zdeA4Pou.d.ts +63 -0
  9. package/dist/audio-state-kZ3KSvux.d.ts +39 -0
  10. package/dist/chunk-35AJK2IO.js +1 -0
  11. package/dist/chunk-35AJK2IO.js.map +1 -0
  12. package/dist/chunk-3LXZODL4.js +886 -0
  13. package/dist/chunk-3LXZODL4.js.map +1 -0
  14. package/dist/chunk-5IK5TLSK.js +67 -0
  15. package/dist/chunk-5IK5TLSK.js.map +1 -0
  16. package/dist/chunk-7E43RY75.js +9 -0
  17. package/dist/chunk-7E43RY75.js.map +1 -0
  18. package/dist/chunk-A5UEXJ5R.js +183 -0
  19. package/dist/chunk-A5UEXJ5R.js.map +1 -0
  20. package/dist/chunk-AEE554FT.js +51 -0
  21. package/dist/chunk-AEE554FT.js.map +1 -0
  22. package/dist/chunk-DIEWY3IT.js +1332 -0
  23. package/dist/chunk-DIEWY3IT.js.map +1 -0
  24. package/dist/chunk-EGIAN7FH.js +18 -0
  25. package/dist/chunk-EGIAN7FH.js.map +1 -0
  26. package/dist/chunk-EMOEAPVU.js +486 -0
  27. package/dist/chunk-EMOEAPVU.js.map +1 -0
  28. package/dist/chunk-IDC7FHIZ.js +40 -0
  29. package/dist/chunk-IDC7FHIZ.js.map +1 -0
  30. package/dist/chunk-ITJFN3VM.js +601 -0
  31. package/dist/chunk-ITJFN3VM.js.map +1 -0
  32. package/dist/chunk-K24GNU27.js +22 -0
  33. package/dist/chunk-K24GNU27.js.map +1 -0
  34. package/dist/chunk-LXMCRXXF.js +778 -0
  35. package/dist/chunk-LXMCRXXF.js.map +1 -0
  36. package/dist/chunk-MJCEOOLW.js +122 -0
  37. package/dist/chunk-MJCEOOLW.js.map +1 -0
  38. package/dist/chunk-N7U5M3VZ.js +33 -0
  39. package/dist/chunk-N7U5M3VZ.js.map +1 -0
  40. package/dist/chunk-PSYX674B.js +27 -0
  41. package/dist/chunk-PSYX674B.js.map +1 -0
  42. package/dist/chunk-RFQRV7ML.js +33 -0
  43. package/dist/chunk-RFQRV7ML.js.map +1 -0
  44. package/dist/chunk-THNHRV2B.js +18 -0
  45. package/dist/chunk-THNHRV2B.js.map +1 -0
  46. package/dist/chunk-VSLGR64U.js +62 -0
  47. package/dist/chunk-VSLGR64U.js.map +1 -0
  48. package/dist/chunk-W2ZP674X.js +346 -0
  49. package/dist/chunk-W2ZP674X.js.map +1 -0
  50. package/dist/chunk-YWZUMUYE.js +695 -0
  51. package/dist/chunk-YWZUMUYE.js.map +1 -0
  52. package/dist/client-options-Uo6jXO8k.d.ts +64 -0
  53. package/dist/connection-state-Bk33YprE.d.ts +32 -0
  54. package/dist/core/bindings/index.d.ts +24 -0
  55. package/dist/core/bindings/index.js +1025 -0
  56. package/dist/core/bindings/index.js.map +1 -0
  57. package/dist/core/index.d.ts +383 -0
  58. package/dist/core/index.js +1284 -0
  59. package/dist/core/index.js.map +1 -0
  60. package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
  61. package/dist/devices/speechmike/index.d.ts +148 -0
  62. package/dist/devices/speechmike/index.js +40 -0
  63. package/dist/devices/speechmike/index.js.map +1 -0
  64. package/dist/headless/index.d.ts +10 -0
  65. package/dist/headless/index.js +25 -0
  66. package/dist/headless/index.js.map +1 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +119 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/react/index.d.ts +38 -0
  71. package/dist/react/index.js +70 -0
  72. package/dist/react/index.js.map +1 -0
  73. package/dist/rich-editor/index.d.ts +46 -0
  74. package/dist/rich-editor/index.js +13 -0
  75. package/dist/rich-editor/index.js.map +1 -0
  76. package/dist/schema-B2ycPlNB.d.ts +87 -0
  77. package/dist/session-APaXR48R.d.ts +12 -0
  78. package/dist/shared/index.d.ts +16 -0
  79. package/dist/shared/index.js +30 -0
  80. package/dist/shared/index.js.map +1 -0
  81. package/dist/style.css +1093 -0
  82. package/dist/testing/index.d.ts +84 -0
  83. package/dist/testing/index.js +36 -0
  84. package/dist/testing/index.js.map +1 -0
  85. package/dist/types-D5SXPSwR.d.ts +32 -0
  86. package/dist/ui/index.d.ts +30 -0
  87. package/dist/ui/index.js +34 -0
  88. package/dist/ui/index.js.map +1 -0
  89. package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
  90. package/package.json +110 -0
  91. package/src/core/audio/audio-worklet-source.ts +30 -0
  92. package/src/core/audio/index.ts +3 -0
  93. package/src/core/audio/voice-level-meter.test.ts +27 -0
  94. package/src/core/audio/voice-level-meter.ts +270 -0
  95. package/src/core/bindings/EphiaBinding.ts +41 -0
  96. package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
  97. package/src/core/bindings/SegmentBindingBridge.ts +377 -0
  98. package/src/core/bindings/TargetBinding.ts +142 -0
  99. package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
  100. package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
  101. package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
  102. package/src/core/bindings/adapters/index.ts +2 -0
  103. package/src/core/bindings/binding-factory.ts +78 -0
  104. package/src/core/bindings/detect-editor-type.ts +87 -0
  105. package/src/core/bindings/index.ts +13 -0
  106. package/src/core/bindings/insertion-boundary.test.ts +38 -0
  107. package/src/core/bindings/insertion-boundary.ts +26 -0
  108. package/src/core/bindings/native/NativeBinding.test.ts +277 -0
  109. package/src/core/bindings/native/NativeBinding.ts +239 -0
  110. package/src/core/bindings/resolver.ts +18 -0
  111. package/src/core/bindings/targets/codemirror.binding.ts +293 -0
  112. package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
  113. package/src/core/bindings/targets/index.ts +10 -0
  114. package/src/core/bindings/targets/monaco.binding.ts +315 -0
  115. package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
  116. package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
  117. package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
  118. package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
  119. package/src/core/bindings/types.ts +41 -0
  120. package/src/core/client/EphiaAudioClient.ts +654 -0
  121. package/src/core/client/audio-capture.ts +263 -0
  122. package/src/core/client/client-options.ts +39 -0
  123. package/src/core/client/client-state.ts +18 -0
  124. package/src/core/client/constants.ts +23 -0
  125. package/src/core/client/session-api.ts +415 -0
  126. package/src/core/connection/connection-state.ts +78 -0
  127. package/src/core/connection/index.ts +6 -0
  128. package/src/core/index.ts +47 -0
  129. package/src/core/operations/textToDocumentOperations.test.ts +69 -0
  130. package/src/core/operations/textToDocumentOperations.ts +92 -0
  131. package/src/core/runtime/DictationRuntime.test.ts +578 -0
  132. package/src/core/runtime/DictationRuntime.ts +434 -0
  133. package/src/core/runtime/TranscriptApplier.test.ts +355 -0
  134. package/src/core/runtime/TranscriptApplier.ts +229 -0
  135. package/src/core/runtime/index.ts +18 -0
  136. package/src/core/session/index.ts +2 -0
  137. package/src/core/session/session-machine.test.ts +16 -0
  138. package/src/core/session/session-machine.ts +59 -0
  139. package/src/core/targets/EditorContextCollector.ts +71 -0
  140. package/src/core/targets/TargetManager.test.ts +194 -0
  141. package/src/core/targets/TargetManager.ts +194 -0
  142. package/src/core/targets/index.ts +10 -0
  143. package/src/core/text-processing/index.ts +11 -0
  144. package/src/core/text-processing/overlap.test.ts +35 -0
  145. package/src/core/text-processing/overlap.ts +101 -0
  146. package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
  147. package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
  148. package/src/core/transcript/client-transcript.reducer.ts +366 -0
  149. package/src/core/transcript/client-transcript.state.ts +25 -0
  150. package/src/core/transcript/index.ts +19 -0
  151. package/src/core/transcript/transcript.assembler.test.ts +205 -0
  152. package/src/core/transcript/transcript.assembler.ts +152 -0
  153. package/src/core/transcript/transcript.reducer.test.ts +199 -0
  154. package/src/core/transcript/transcript.reducer.ts +771 -0
  155. package/src/core/transcript/transcript.state.ts +123 -0
  156. package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
  157. package/src/core/transport/LiveKitTransport.ts +459 -0
  158. package/src/core/transport/MockTransport.ts +231 -0
  159. package/src/core/transport/Transport.ts +82 -0
  160. package/src/debug/sdk-debug-collector.ts +79 -0
  161. package/src/devices/index.ts +2 -0
  162. package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
  163. package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
  164. package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
  165. package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
  166. package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
  167. package/src/devices/speechmike/browser.ts +80 -0
  168. package/src/devices/speechmike/constants.ts +74 -0
  169. package/src/devices/speechmike/dictation-support-loader.ts +81 -0
  170. package/src/devices/speechmike/index.ts +11 -0
  171. package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
  172. package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
  173. package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
  174. package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
  175. package/src/devices/speechmike/speechmike-button-router.ts +73 -0
  176. package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
  177. package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
  178. package/src/devices/speechmike/types.ts +96 -0
  179. package/src/dictation_support.d.ts +31 -0
  180. package/src/global.d.ts +10 -0
  181. package/src/headless/createEphiaClient.ts +220 -0
  182. package/src/headless/index.ts +18 -0
  183. package/src/index.ts +89 -0
  184. package/src/react/EphiaAuto.tsx +87 -0
  185. package/src/react/components/EphiaDictationButton.tsx +88 -0
  186. package/src/react/components/EphiaStatusBar.tsx +59 -0
  187. package/src/react/components/EphiaTextarea.tsx +295 -0
  188. package/src/react/ephia-react.css +318 -0
  189. package/src/react/hooks/targets/index.ts +3 -0
  190. package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
  191. package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
  192. package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
  193. package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
  194. package/src/react/hooks/useEphia.ts +367 -0
  195. package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
  196. package/src/react/hooks/useEphiaServerEvent.ts +33 -0
  197. package/src/react/hooks/useEphiaTarget.ts +47 -0
  198. package/src/react/hooks/useEphiaTranscript.ts +22 -0
  199. package/src/react/index.ts +58 -0
  200. package/src/react/provider/EphiaContext.ts +63 -0
  201. package/src/react/provider/EphiaInternalContext.ts +32 -0
  202. package/src/react/provider/EphiaProvider.tsx +373 -0
  203. package/src/react/registry/binding-factory.ts +7 -0
  204. package/src/react/registry/detect-editor-type.ts +2 -0
  205. package/src/react/registry/events.ts +37 -0
  206. package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
  207. package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
  208. package/src/react/registry/registries/TargetRegistry.ts +327 -0
  209. package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
  210. package/src/react/registry/registries/index.ts +5 -0
  211. package/src/react/store/create-ephia-store.ts +36 -0
  212. package/src/react/store/types.ts +41 -0
  213. package/src/react/utils/flash-range.ts +24 -0
  214. package/src/react/utils/index.ts +1 -0
  215. package/src/rich-editor/adapters/tiptap.test.ts +86 -0
  216. package/src/rich-editor/adapters/tiptap.ts +23 -0
  217. package/src/rich-editor/index.ts +3 -0
  218. package/src/rich-editor/types.ts +24 -0
  219. package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
  220. package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
  221. package/src/shared/config/endpoint.test.ts +45 -0
  222. package/src/shared/config/endpoint.ts +39 -0
  223. package/src/shared/config/schema.ts +32 -0
  224. package/src/shared/effective-text.ts +13 -0
  225. package/src/shared/errors/EphiaSdkError.ts +54 -0
  226. package/src/shared/errors/messages.ts +40 -0
  227. package/src/shared/index.ts +27 -0
  228. package/src/shared/state/audio-state.ts +45 -0
  229. package/src/shared/state/index.ts +2 -0
  230. package/src/shared/store/document-store.ts +32 -0
  231. package/src/shared/store/index.ts +2 -0
  232. package/src/shared/types/editors.ts +28 -0
  233. package/src/shared/types/session.ts +12 -0
  234. package/src/style.css +2 -0
  235. package/src/testing/index.tsx +60 -0
  236. package/src/ui/assets/ephia-logo.svg +4 -0
  237. package/src/ui/components/EphiaLogo.tsx +77 -0
  238. package/src/ui/index.ts +24 -0
  239. package/src/ui/primitives/Button.tsx +53 -0
  240. package/src/ui/primitives/Spinner.tsx +21 -0
  241. package/src/ui/primitives/index.ts +5 -0
  242. package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
  243. package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
  244. package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
  245. package/src/ui/recorder/appearance.ts +9 -0
  246. package/src/ui/recorder/index.ts +8 -0
  247. package/src/ui/theme.css +775 -0
@@ -0,0 +1,355 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { EphiaServerEvent } from "ephia-protocol";
3
+ import type { EphiaBinding } from "../bindings/EphiaBinding";
4
+ import { TranscriptApplier } from "./TranscriptApplier";
5
+
6
+ function binding(): EphiaBinding {
7
+ return {
8
+ kind: "test",
9
+ attach: vi.fn(),
10
+ detach: vi.fn(),
11
+ getText: vi.fn(() => ""),
12
+ getEditorContext: vi.fn(() => ({
13
+ targetId: "report",
14
+ documentEmpty: true,
15
+ insertionMode: "append" as const,
16
+ leftContext: "",
17
+ rightContext: "",
18
+ selectedText: null,
19
+ cursorOffset: 0,
20
+ })),
21
+ previewSegment: vi.fn(),
22
+ upsertSegment: vi.fn(),
23
+ removeSegment: vi.fn(),
24
+ removeSegments: vi.fn(),
25
+ };
26
+ }
27
+
28
+ function operation(
29
+ overrides: Partial<Extract<EphiaServerEvent, { type: "segment.operation" }>["payload"]> = {},
30
+ eventId = "evt-op"
31
+ ): EphiaServerEvent {
32
+ return {
33
+ protocolVersion: 2,
34
+ type: "segment.operation",
35
+ eventId,
36
+ sessionId: "session",
37
+ serverSeq: 1,
38
+ sentAt: "2026-06-17T00:00:00+00:00",
39
+ payload: {
40
+ segmentId: "seg-1",
41
+ segmentSeq: 1,
42
+ segmentRevision: 1,
43
+ targetId: "report",
44
+ operation: "upsert",
45
+ stage: "canonical",
46
+ text: "texte",
47
+ textForInsertion: "texte",
48
+ source: "dova-medical",
49
+ ...overrides,
50
+ },
51
+ };
52
+ }
53
+
54
+ function targetManager(report?: EphiaBinding, active?: EphiaBinding) {
55
+ return {
56
+ get: (id: string) =>
57
+ id === "report" && report ? { id, binding: report } : undefined,
58
+ getActiveTarget: () =>
59
+ active ? { id: "active", binding: active } : undefined,
60
+ getActiveTargetId: () => (active ? "active" : null),
61
+ };
62
+ }
63
+
64
+ describe("TranscriptApplier", () => {
65
+ it("routes preview to previewSegment on the explicit target", () => {
66
+ const report = binding();
67
+ const active = binding();
68
+ const applier = new TranscriptApplier({ targetManager: targetManager(report, active) });
69
+
70
+ applier.handleEvent({
71
+ protocolVersion: 2,
72
+ type: "segment.preview",
73
+ eventId: "evt-preview",
74
+ sessionId: "session",
75
+ serverSeq: 1,
76
+ sentAt: "2026-06-17T00:00:00+00:00",
77
+ payload: {
78
+ segmentId: "seg-1",
79
+ segmentSeq: 1,
80
+ segmentRevision: 1,
81
+ targetId: "report",
82
+ text: "prev",
83
+ },
84
+ });
85
+
86
+ expect(report.previewSegment).toHaveBeenCalledWith({ id: "seg-1", text: "prev" });
87
+ expect(active.previewSegment).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("routes upsert to upsertSegment on the explicit target", () => {
91
+ const report = binding();
92
+ const active = binding();
93
+ const applier = new TranscriptApplier({ targetManager: targetManager(report, active) });
94
+
95
+ applier.handleEvent(operation());
96
+
97
+ expect(report.upsertSegment).toHaveBeenCalledWith({
98
+ id: "seg-1",
99
+ text: "texte",
100
+ stage: "canonical",
101
+ source: "dova-medical",
102
+ });
103
+ expect(active.upsertSegment).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("ignores segment.operation without targetId", () => {
107
+ const active = binding();
108
+ const warn = vi.fn();
109
+ const applier = new TranscriptApplier({ targetManager: targetManager(undefined, active), warn });
110
+
111
+ applier.handleEvent(operation({ targetId: undefined }));
112
+
113
+ expect(active.upsertSegment).not.toHaveBeenCalled();
114
+ expect(warn).toHaveBeenCalledWith(
115
+ "[ephia] segment.operation missing targetId; event ignored",
116
+ expect.objectContaining({ segmentId: "seg-1" })
117
+ );
118
+ });
119
+
120
+ it("uses textForInsertion over text", () => {
121
+ const target = binding();
122
+ const applier = new TranscriptApplier({
123
+ targetManager: {
124
+ get: () => ({ id: "report", binding: target }),
125
+ getActiveTarget: () => undefined,
126
+ getActiveTargetId: () => null,
127
+ },
128
+ });
129
+
130
+ applier.handleEvent(operation({ text: "raw", textForInsertion: "formatted" }));
131
+
132
+ expect(target.upsertSegment).toHaveBeenCalledWith(
133
+ expect.objectContaining({ text: "formatted" })
134
+ );
135
+ });
136
+
137
+ it("absorb: upserts primary then removes absorbed", () => {
138
+ const target = binding();
139
+ const applier = new TranscriptApplier({
140
+ targetManager: {
141
+ get: () => ({ id: "report", binding: target }),
142
+ getActiveTarget: () => undefined,
143
+ getActiveTargetId: () => null,
144
+ },
145
+ });
146
+
147
+ applier.handleEvent(operation({ operation: "absorb", absorbedSegmentIds: ["seg-0"] }));
148
+
149
+ expect(target.upsertSegment).toHaveBeenCalledWith(expect.objectContaining({ id: "seg-1" }));
150
+ expect(target.removeSegments).toHaveBeenCalledWith(["seg-0"]);
151
+ });
152
+
153
+ it("delete: calls removeSegment and removeSegments for absorbed", () => {
154
+ const target = binding();
155
+ const applier = new TranscriptApplier({
156
+ targetManager: {
157
+ get: () => ({ id: "report", binding: target }),
158
+ getActiveTarget: () => undefined,
159
+ getActiveTargetId: () => null,
160
+ },
161
+ });
162
+
163
+ applier.handleEvent(
164
+ operation({ operation: "delete", text: "", textForInsertion: "", absorbedSegmentIds: ["seg-x"] })
165
+ );
166
+
167
+ expect(target.removeSegment).toHaveBeenCalledWith("seg-1");
168
+ expect(target.removeSegments).toHaveBeenCalledWith(["seg-x"]);
169
+ expect(target.upsertSegment).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it("noop: does nothing on the binding", () => {
173
+ const target = binding();
174
+ const applier = new TranscriptApplier({
175
+ targetManager: {
176
+ get: () => ({ id: "report", binding: target }),
177
+ getActiveTarget: () => undefined,
178
+ getActiveTargetId: () => null,
179
+ },
180
+ });
181
+
182
+ applier.handleEvent(operation({ operation: "noop" }));
183
+
184
+ expect(target.upsertSegment).not.toHaveBeenCalled();
185
+ expect(target.removeSegment).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it("warns when target not found", () => {
189
+ const warn = vi.fn();
190
+ const applier = new TranscriptApplier({
191
+ targetManager: {
192
+ get: () => undefined,
193
+ getActiveTarget: () => undefined,
194
+ getActiveTargetId: () => null,
195
+ },
196
+ warn,
197
+ });
198
+
199
+ applier.handleEvent(operation());
200
+
201
+ expect(warn).toHaveBeenCalledWith(
202
+ "[ephia] target not found; transcript event ignored",
203
+ expect.objectContaining({ targetId: "report", segmentId: "seg-1" })
204
+ );
205
+ });
206
+
207
+ it("ignores duplicate eventId", () => {
208
+ const target = binding();
209
+ const applier = new TranscriptApplier({
210
+ targetManager: {
211
+ get: () => ({ id: "report", binding: target }),
212
+ getActiveTarget: () => undefined,
213
+ getActiveTargetId: () => null,
214
+ },
215
+ });
216
+
217
+ applier.handleEvent(operation({}, "evt-1"));
218
+ applier.handleEvent(operation({ segmentRevision: 2 }, "evt-1"));
219
+
220
+ expect(target.upsertSegment).toHaveBeenCalledTimes(1);
221
+ });
222
+
223
+ it("ignores stale segmentRevision", () => {
224
+ const target = binding();
225
+ const applier = new TranscriptApplier({
226
+ targetManager: {
227
+ get: () => ({ id: "report", binding: target }),
228
+ getActiveTarget: () => undefined,
229
+ getActiveTargetId: () => null,
230
+ },
231
+ });
232
+
233
+ applier.handleEvent(operation({ segmentRevision: 2 }, "evt-2"));
234
+ applier.handleEvent(operation({ segmentRevision: 1 }, "evt-3"));
235
+
236
+ expect(target.upsertSegment).toHaveBeenCalledTimes(1);
237
+ });
238
+
239
+ it("applies higher segmentRevision", () => {
240
+ const target = binding();
241
+ const applier = new TranscriptApplier({
242
+ targetManager: {
243
+ get: () => ({ id: "report", binding: target }),
244
+ getActiveTarget: () => undefined,
245
+ getActiveTargetId: () => null,
246
+ },
247
+ });
248
+
249
+ applier.handleEvent(operation({ segmentRevision: 1, text: "un", textForInsertion: "un" }, "evt-4"));
250
+ applier.handleEvent(operation({ segmentRevision: 2, text: "deux", textForInsertion: "deux" }, "evt-5"));
251
+
252
+ expect(target.upsertSegment).toHaveBeenCalledTimes(2);
253
+ expect(target.upsertSegment).toHaveBeenLastCalledWith(
254
+ expect.objectContaining({ id: "seg-1", text: "deux" })
255
+ );
256
+ });
257
+
258
+ it("does not mark event seen when target is absent", () => {
259
+ let callCount = 0;
260
+ const applier = new TranscriptApplier({
261
+ targetManager: {
262
+ get: () => {
263
+ callCount++;
264
+ return callCount >= 2 ? { id: "report", binding: binding() } : undefined;
265
+ },
266
+ getActiveTarget: () => undefined,
267
+ getActiveTargetId: () => null,
268
+ },
269
+ warn: () => {},
270
+ });
271
+
272
+ // First call — target absent → event NOT marked as seen.
273
+ applier.handleEvent(operation({}, "evt-retry"));
274
+ // Second call same eventId — target now present → should be applied.
275
+ applier.handleEvent(operation({}, "evt-retry"));
276
+
277
+ // The second call should reach upsertSegment (event was not marked as seen on first pass).
278
+ // We can't directly assert on the binding from the outer scope, but the test verifies
279
+ // no error is thrown and we can instrument via the targetManager.
280
+ expect(callCount).toBeGreaterThanOrEqual(2);
281
+ });
282
+
283
+ it("ignores late preview after canonical applied", () => {
284
+ const target = binding();
285
+ const applier = new TranscriptApplier({
286
+ targetManager: {
287
+ get: () => ({ id: "report", binding: target }),
288
+ getActiveTarget: () => undefined,
289
+ getActiveTargetId: () => null,
290
+ },
291
+ });
292
+
293
+ // Apply canonical first.
294
+ applier.handleEvent(operation({ stage: "canonical", segmentRevision: 2 }, "evt-canonical"));
295
+
296
+ // Late preview for same segment should be ignored.
297
+ applier.handleEvent({
298
+ protocolVersion: 2,
299
+ type: "segment.preview",
300
+ eventId: "evt-late-preview",
301
+ sessionId: "session",
302
+ serverSeq: 3,
303
+ sentAt: "2026-06-17T00:00:00+00:00",
304
+ payload: {
305
+ segmentId: "seg-1",
306
+ segmentSeq: 1,
307
+ segmentRevision: 1,
308
+ targetId: "report",
309
+ text: "stale preview",
310
+ },
311
+ });
312
+
313
+ expect(target.previewSegment).not.toHaveBeenCalled();
314
+ expect(target.upsertSegment).toHaveBeenCalledTimes(1);
315
+ });
316
+
317
+ it("does not mark preview seen if target absent (allows replay)", () => {
318
+ let targetAvailable = false;
319
+ const targetBinding = binding();
320
+ const applier = new TranscriptApplier({
321
+ targetManager: {
322
+ get: () =>
323
+ targetAvailable ? { id: "report", binding: targetBinding } : undefined,
324
+ getActiveTarget: () => undefined,
325
+ getActiveTargetId: () => null,
326
+ },
327
+ warn: () => {},
328
+ });
329
+
330
+ const previewEvent: EphiaServerEvent = {
331
+ protocolVersion: 2,
332
+ type: "segment.preview",
333
+ eventId: "evt-preview-retry",
334
+ sessionId: "session",
335
+ serverSeq: 1,
336
+ sentAt: "2026-06-17T00:00:00+00:00",
337
+ payload: {
338
+ segmentId: "seg-1",
339
+ segmentSeq: 1,
340
+ segmentRevision: 1,
341
+ targetId: "report",
342
+ text: "partial",
343
+ },
344
+ };
345
+
346
+ // Target absent — preview not applied, not marked as seen.
347
+ applier.handleEvent(previewEvent);
348
+ expect(targetBinding.previewSegment).not.toHaveBeenCalled();
349
+
350
+ // Target now available — same event replayed.
351
+ targetAvailable = true;
352
+ applier.handleEvent(previewEvent);
353
+ expect(targetBinding.previewSegment).toHaveBeenCalledWith({ id: "seg-1", text: "partial" });
354
+ });
355
+ });
@@ -0,0 +1,229 @@
1
+ import type { EphiaServerEvent, SegmentOperationPayload } from "ephia-protocol";
2
+ import type { EphiaBinding } from "../bindings/EphiaBinding";
3
+ import type { TargetManager } from "../targets/TargetManager";
4
+
5
+ export type TranscriptApplierOptions = {
6
+ targetManager: Pick<TargetManager, "get" | "getActiveTarget" | "getActiveTargetId">;
7
+ warn?: (message: string, details?: Record<string, unknown>) => void;
8
+ debug?: (message: string, details?: Record<string, unknown>) => void;
9
+ };
10
+
11
+ const MAX_SEEN_EVENTS = 1000;
12
+ const MAX_PENDING_EVENTS = 50;
13
+
14
+ /**
15
+ * Applique les events serveur V2 aux bindings éditeurs enregistrés.
16
+ *
17
+ * Règles de stricte V2 :
18
+ * - targetId obligatoire sur segment.operation
19
+ * - eventId déjà vu → ignore
20
+ * - segmentRevision inférieure ou égale à la dernière appliquée → ignore
21
+ * - upsert/absorb atomique (upsert puis remove absorbed)
22
+ * - segment.preview ignoré si un canonical a déjà été appliqué pour ce segment
23
+ * - event/revision non marqués comme vus si le binding n'est pas encore disponible
24
+ */
25
+ export class TranscriptApplier {
26
+ private readonly targetManager: TranscriptApplierOptions["targetManager"];
27
+ private readonly warn: NonNullable<TranscriptApplierOptions["warn"]>;
28
+ private readonly debug: NonNullable<TranscriptApplierOptions["debug"]>;
29
+
30
+ /** eventId → déjà traité (idempotence). */
31
+ private readonly seenEventIds = new Set<string>();
32
+ private readonly seenEventQueue: string[] = [];
33
+
34
+ /** segmentId → dernière segmentRevision appliquée. */
35
+ private readonly latestRevisionBySegment = new Map<string, number>();
36
+
37
+ /** segmentId → targetId (cache défensif). */
38
+ private readonly segmentTargetMap = new Map<string, string>();
39
+
40
+ /** segmentIds pour lesquels un canonical a été appliqué avec succès. */
41
+ private readonly canonicalSegments = new Set<string>();
42
+
43
+ /** Events en attente de montage de leur cible (targetId → events[]). */
44
+ private readonly pendingEvents = new Map<string, EphiaServerEvent[]>();
45
+
46
+ constructor(options: TranscriptApplierOptions) {
47
+ this.targetManager = options.targetManager;
48
+ this.warn = options.warn ?? ((message, details) => console.warn(message, details));
49
+ this.debug = options.debug ?? (() => {});
50
+ }
51
+
52
+ handleEvent(event: EphiaServerEvent): void {
53
+ // Check for duplicate WITHOUT marking yet — we only mark after successful application.
54
+ if (this.seenEventIds.has(event.eventId)) {
55
+ this.debug("duplicate event ignored", { eventId: event.eventId, type: event.type });
56
+ return;
57
+ }
58
+
59
+ if (event.type === "segment.preview") {
60
+ // Ignore previews that arrive after a canonical was already applied.
61
+ if (this.canonicalSegments.has(event.payload.segmentId)) {
62
+ this.debug("late preview ignored after canonical", {
63
+ segmentId: event.payload.segmentId,
64
+ });
65
+ this._markEventSeen(event.eventId);
66
+ return;
67
+ }
68
+
69
+ const binding = this.resolveBinding(event.payload.targetId, event.payload.segmentId);
70
+ // If binding not found, enqueue event for later replay.
71
+ if (!binding) {
72
+ this._enqueuePendingEvent(event, event.payload.targetId);
73
+ return;
74
+ }
75
+
76
+ this._markEventSeen(event.eventId);
77
+ binding.previewSegment?.({ id: event.payload.segmentId, text: event.payload.text });
78
+ return;
79
+ }
80
+
81
+ if (event.type === "segment.operation") {
82
+ const applied = this.applyOperation(event.payload, event);
83
+ // Only mark as seen if the event was processed (binding found).
84
+ // If binding missing, event is enqueued for retry.
85
+ if (applied) this._markEventSeen(event.eventId);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Rejoue les events en attente pour une cible donnée.
91
+ * À appeler après l'enregistrement d'une nouvelle target.
92
+ */
93
+ flushPendingEvents(targetId: string): void {
94
+ const pending = this.pendingEvents.get(targetId);
95
+ if (!pending || pending.length === 0) return;
96
+
97
+ this.debug("flushing pending events", { targetId, count: pending.length });
98
+
99
+ const toRetry = [...pending];
100
+ this.pendingEvents.delete(targetId);
101
+
102
+ for (const event of toRetry) {
103
+ this.handleEvent(event);
104
+ }
105
+ }
106
+
107
+ private applyOperation(payload: SegmentOperationPayload, event?: EphiaServerEvent): boolean {
108
+ // Resolve binding FIRST — before marking the revision. If binding is absent,
109
+ // return false so the event stays unmarked and can be enqueued for retry.
110
+ const binding = this.resolveBinding(payload.targetId, payload.segmentId);
111
+ if (!binding) {
112
+ if (event && payload.targetId) {
113
+ this._enqueuePendingEvent(event, payload.targetId);
114
+ }
115
+ return false;
116
+ }
117
+
118
+ if (!this.shouldApplySegmentRevision(payload)) {
119
+ // Revision already surpassed — event is stale but binding exists; mark as seen.
120
+ return true;
121
+ }
122
+
123
+ if (payload.operation === "noop") return true;
124
+
125
+ if (payload.operation === "delete") {
126
+ binding.removeSegment(payload.segmentId);
127
+ if (payload.absorbedSegmentIds?.length) {
128
+ binding.removeSegments(payload.absorbedSegmentIds);
129
+ }
130
+ return true;
131
+ }
132
+
133
+ // "upsert" and "absorb" both upsert the primary segment then remove absorbed.
134
+ binding.upsertSegment({
135
+ id: payload.segmentId,
136
+ text: payload.textForInsertion || payload.text,
137
+ stage: payload.stage,
138
+ source: payload.source,
139
+ });
140
+
141
+ if (payload.stage === "canonical") {
142
+ this.canonicalSegments.add(payload.segmentId);
143
+ }
144
+
145
+ if (payload.absorbedSegmentIds?.length) {
146
+ binding.removeSegments(payload.absorbedSegmentIds);
147
+ }
148
+
149
+ return true;
150
+ }
151
+
152
+ private shouldApplySegmentRevision(payload: SegmentOperationPayload): boolean {
153
+ const previous = this.latestRevisionBySegment.get(payload.segmentId);
154
+ const current = payload.segmentRevision;
155
+
156
+ if (previous !== undefined && current <= previous) {
157
+ this.debug("stale segment revision ignored", {
158
+ segmentId: payload.segmentId,
159
+ previous,
160
+ current,
161
+ });
162
+ return false;
163
+ }
164
+
165
+ this.latestRevisionBySegment.set(payload.segmentId, current);
166
+ return true;
167
+ }
168
+
169
+ private resolveBinding(targetId: string | undefined, segmentId: string): EphiaBinding | null {
170
+ if (targetId) {
171
+ const target = this.targetManager.get(targetId);
172
+ const binding = this.asEphiaBinding(target?.binding);
173
+ if (!binding) {
174
+ this.warn("[ephia] target not found; transcript event ignored", {
175
+ targetId,
176
+ segmentId,
177
+ });
178
+ return null;
179
+ }
180
+ this.segmentTargetMap.set(segmentId, targetId);
181
+ return binding;
182
+ }
183
+
184
+ const mappedTargetId = this.segmentTargetMap.get(segmentId);
185
+ if (mappedTargetId) {
186
+ return this.asEphiaBinding(this.targetManager.get(mappedTargetId)?.binding);
187
+ }
188
+
189
+ this.warn("[ephia] segment.operation missing targetId; event ignored", {
190
+ segmentId,
191
+ });
192
+ return null;
193
+ }
194
+
195
+ private _markEventSeen(eventId: string): void {
196
+ this.seenEventIds.add(eventId);
197
+ this.seenEventQueue.push(eventId);
198
+ if (this.seenEventQueue.length > MAX_SEEN_EVENTS) {
199
+ const oldest = this.seenEventQueue.shift();
200
+ if (oldest) this.seenEventIds.delete(oldest);
201
+ }
202
+ }
203
+
204
+ private asEphiaBinding(binding: unknown): EphiaBinding | null {
205
+ if (!binding) return null;
206
+ if (typeof binding === "object" && "upsertSegment" in binding) return binding as EphiaBinding;
207
+ return null;
208
+ }
209
+
210
+ private _enqueuePendingEvent(event: EphiaServerEvent, targetId?: string): void {
211
+ if (!targetId) return;
212
+
213
+ const queue = this.pendingEvents.get(targetId) ?? [];
214
+ if (queue.length >= MAX_PENDING_EVENTS) {
215
+ this.debug("pending events queue full, dropping oldest", { targetId });
216
+ queue.shift();
217
+ }
218
+
219
+ queue.push(event);
220
+ this.pendingEvents.set(targetId, queue);
221
+
222
+ this.debug("event enqueued for target", {
223
+ targetId,
224
+ eventId: event.eventId,
225
+ type: event.type,
226
+ queueSize: queue.length,
227
+ });
228
+ }
229
+ }
@@ -0,0 +1,18 @@
1
+ export {
2
+ DictationRuntime,
3
+ } from "./DictationRuntime";
4
+ export {
5
+ TranscriptApplier,
6
+ } from "./TranscriptApplier";
7
+
8
+ export type {
9
+ DictationPartialState,
10
+ DictationRuntimeOptions,
11
+ DictationRuntimeStatus,
12
+ DictationTarget,
13
+ DictationTargetInsertionMode,
14
+ DictationTargetRegistry,
15
+ } from "./DictationRuntime";
16
+ export type {
17
+ TranscriptApplierOptions,
18
+ } from "./TranscriptApplier";
@@ -0,0 +1,2 @@
1
+ export { canTransition, transitionSessionStatus } from "./session-machine";
2
+ export type { EphiaSessionStatus } from "./session-machine";
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { canTransition, transitionSessionStatus } from "./session-machine";
3
+
4
+ describe("session-machine", () => {
5
+ it("allows connecting_transport -> ready -> recording", () => {
6
+ expect(transitionSessionStatus("connecting_transport", "ready")).toBe("ready");
7
+ expect(transitionSessionStatus("ready", "recording")).toBe("recording");
8
+ });
9
+
10
+ it("rejects connecting_transport -> recording", () => {
11
+ expect(canTransition("connecting_transport", "recording")).toBe(false);
12
+ expect(() => transitionSessionStatus("connecting_transport", "recording")).toThrow(
13
+ /Transition interdite/
14
+ );
15
+ });
16
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Machine à états stricte pour les sessions Ephia Audio.
3
+ *
4
+ * Interdit les transitions invalides (ex: recording -> creating_session).
5
+ */
6
+
7
+ import { EphiaSdkError } from "../../shared/errors/EphiaSdkError";
8
+
9
+ export type EphiaSessionStatus =
10
+ | "idle"
11
+ | "creating_session"
12
+ | "connecting_transport"
13
+ | "ready"
14
+ | "recording"
15
+ | "paused"
16
+ | "finalizing"
17
+ | "ended"
18
+ | "error"
19
+ | "disposed";
20
+
21
+ const ALLOWED_TRANSITIONS: Record<EphiaSessionStatus, EphiaSessionStatus[]> = {
22
+ idle: ["creating_session", "disposed"],
23
+ creating_session: ["connecting_transport", "error", "disposed"],
24
+ connecting_transport: ["ready", "error", "disposed"],
25
+ ready: ["recording", "finalizing", "error", "disposed"],
26
+ recording: ["paused", "finalizing", "error", "disposed"],
27
+ paused: ["recording", "finalizing", "error", "disposed"],
28
+ // "paused" : stop() passe par finalizing pendant le drain backend, puis retombe en paused
29
+ finalizing: ["paused", "ended", "error", "disposed"],
30
+ ended: ["idle", "disposed"],
31
+ error: ["idle", "disposed"],
32
+ disposed: [],
33
+ };
34
+
35
+ export function transitionSessionStatus(
36
+ current: EphiaSessionStatus,
37
+ next: EphiaSessionStatus
38
+ ): EphiaSessionStatus {
39
+ if (current === next) return current;
40
+
41
+ const allowed = ALLOWED_TRANSITIONS[current] ?? [];
42
+ if (!allowed.includes(next)) {
43
+ throw new EphiaSdkError(
44
+ "client.invalid_state",
45
+ `Transition interdite: ${current} -> ${next}`,
46
+ { current, next, allowed }
47
+ );
48
+ }
49
+ return next;
50
+ }
51
+
52
+ export function canTransition(
53
+ current: EphiaSessionStatus,
54
+ next: EphiaSessionStatus
55
+ ): boolean {
56
+ if (current === next) return true;
57
+ const allowed = ALLOWED_TRANSITIONS[current] ?? [];
58
+ return allowed.includes(next);
59
+ }