@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,71 @@
1
+ import type { EditorContext } from "ephia-protocol";
2
+ import type { TargetBinding } from "../bindings/TargetBinding";
3
+
4
+ export type EditorContextCollectorOptions = {
5
+ maxContextChars?: number;
6
+ };
7
+
8
+ export type EditorContextTarget = {
9
+ id: string;
10
+ binding: Pick<TargetBinding, "getText" | "getSelection" | "getCursorOffset"> | null;
11
+ };
12
+
13
+ type SelectionSnapshot = {
14
+ text: string;
15
+ range: {
16
+ start: number;
17
+ end: number;
18
+ };
19
+ } | null;
20
+
21
+ export class EditorContextCollector {
22
+ private readonly maxContextChars: number;
23
+
24
+ constructor(options: EditorContextCollectorOptions = {}) {
25
+ this.maxContextChars = options.maxContextChars ?? 600;
26
+ }
27
+
28
+ collect(target: EditorContextTarget): EditorContext {
29
+ const text = target.binding?.getText?.() ?? "";
30
+ const selection = target.binding?.getSelection?.() ?? null;
31
+ const cursorOffset = this.resolveCursorOffset(target.binding, text, selection);
32
+ const leftRaw = text.slice(0, cursorOffset);
33
+ const rightRaw = text.slice(cursorOffset);
34
+
35
+ return {
36
+ targetId: target.id,
37
+ documentEmpty: text.trim().length === 0,
38
+ insertionMode: this.resolveInsertionMode(text, cursorOffset, selection),
39
+ leftContext: leftRaw.slice(-this.maxContextChars),
40
+ rightContext: rightRaw.slice(0, this.maxContextChars),
41
+ selectedText: selection?.text || null,
42
+ cursorOffset,
43
+ };
44
+ }
45
+
46
+ private resolveCursorOffset(
47
+ binding: EditorContextTarget["binding"],
48
+ text: string,
49
+ selection: SelectionSnapshot
50
+ ): number {
51
+ if (selection?.range) return clamp(selection.range.start, 0, text.length);
52
+ const cursor = binding?.getCursorOffset?.();
53
+ if (typeof cursor === "number") return clamp(cursor, 0, text.length);
54
+ return text.length;
55
+ }
56
+
57
+ private resolveInsertionMode(
58
+ text: string,
59
+ cursorOffset: number,
60
+ selection: SelectionSnapshot
61
+ ): EditorContext["insertionMode"] {
62
+ if (selection?.text) return "replace_selection";
63
+ if (text.trim().length === 0) return "document_start";
64
+ if (cursorOffset >= text.length) return "append";
65
+ return "middle_of_sentence";
66
+ }
67
+ }
68
+
69
+ function clamp(value: number, min: number, max: number): number {
70
+ return Math.min(Math.max(value, min), max);
71
+ }
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { TargetBinding } from "../bindings/TargetBinding";
3
+ import type { EphiaBinding } from "../bindings/EphiaBinding";
4
+ import { EditorContextCollector } from "./EditorContextCollector";
5
+ import { TargetManager } from "./TargetManager";
6
+
7
+ // ── EditorContextCollector tests (standalone utility, kept as-is) ──────────
8
+
9
+ function legacyBinding(overrides: Partial<TargetBinding> = {}): TargetBinding {
10
+ return {
11
+ kind: "test",
12
+ attach: vi.fn(),
13
+ detach: vi.fn(),
14
+ insertPartial: vi.fn(),
15
+ commitFinal: vi.fn(),
16
+ clearPartial: vi.fn(),
17
+ getText: () => "Le patient presente une douleur.",
18
+ getSelection: () => null,
19
+ getCursorOffset: () => 10,
20
+ applyOperation: vi.fn(),
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe("EditorContextCollector", () => {
26
+ it("collects bounded camelCase editor context", () => {
27
+ const collector = new EditorContextCollector({ maxContextChars: 8 });
28
+ const context = collector.collect({
29
+ id: "report",
30
+ binding: legacyBinding({
31
+ getText: () => "abcdef gauche|droite zzz",
32
+ getCursorOffset: () => 13,
33
+ }),
34
+ });
35
+
36
+ expect(context).toMatchObject({
37
+ targetId: "report",
38
+ documentEmpty: false,
39
+ insertionMode: "middle_of_sentence",
40
+ leftContext: "f gauche",
41
+ rightContext: "|droite ",
42
+ cursorOffset: 13,
43
+ });
44
+ });
45
+
46
+ it("uses replace_selection when text is selected", () => {
47
+ const collector = new EditorContextCollector();
48
+ const context = collector.collect({
49
+ id: "report",
50
+ binding: legacyBinding({
51
+ getSelection: () => ({ text: "douleur", range: { start: 3, end: 10 } }),
52
+ }),
53
+ });
54
+
55
+ expect(context.insertionMode).toBe("replace_selection");
56
+ expect(context.selectedText).toBe("douleur");
57
+ expect(context.cursorOffset).toBe(3);
58
+ });
59
+ });
60
+
61
+ // ── TargetManager tests (uses EphiaBinding) ────────────────────────────────
62
+
63
+ function binding(overrides: Partial<EphiaBinding> = {}): EphiaBinding {
64
+ return {
65
+ kind: "test",
66
+ attach: vi.fn(),
67
+ detach: vi.fn(),
68
+ getText: () => "Le patient presente une douleur.",
69
+ getEditorContext: (targetId = "") => ({
70
+ targetId,
71
+ documentEmpty: false,
72
+ insertionMode: "middle_of_sentence" as const,
73
+ leftContext: "Le patient",
74
+ rightContext: " presente",
75
+ selectedText: null,
76
+ cursorOffset: 10,
77
+ }),
78
+ previewSegment: vi.fn(),
79
+ upsertSegment: vi.fn(),
80
+ removeSegment: vi.fn(),
81
+ removeSegments: vi.fn(),
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ describe("TargetManager", () => {
87
+ it("sends target change and editor context updates", () => {
88
+ vi.useFakeTimers();
89
+ const sendMessage = vi.fn(async () => undefined);
90
+ const manager = new TargetManager({ sendMessage, flushDelayMs: 10 });
91
+
92
+ manager.register({ id: "report", binding: binding(), mode: "write" });
93
+ vi.runAllTimers();
94
+
95
+ expect(sendMessage).toHaveBeenCalledWith({
96
+ type: "session.target.changed",
97
+ payload: { targetId: "report" },
98
+ });
99
+ expect(sendMessage).toHaveBeenCalledWith({
100
+ type: "session.editor_context.update",
101
+ payload: expect.objectContaining({
102
+ targetId: "report",
103
+ insertionMode: "middle_of_sentence",
104
+ }),
105
+ });
106
+ vi.useRealTimers();
107
+ });
108
+
109
+ it("syncs the latest active target on demand", () => {
110
+ const sendMessage = vi.fn(async () => undefined);
111
+ const manager = new TargetManager({ sendMessage, flushDelayMs: 10 });
112
+ manager.register({ id: "report", binding: binding(), mode: "write" });
113
+ sendMessage.mockClear();
114
+
115
+ manager.syncActiveTarget();
116
+
117
+ expect(sendMessage).toHaveBeenCalledWith({
118
+ type: "session.target.changed",
119
+ payload: { targetId: "report" },
120
+ });
121
+ expect(sendMessage).toHaveBeenCalledWith({
122
+ type: "session.editor_context.update",
123
+ payload: expect.objectContaining({ targetId: "report" }),
124
+ });
125
+ });
126
+
127
+ it("register returns a token and unregister with wrong token is a no-op", () => {
128
+ const sendMessage = vi.fn(async () => undefined);
129
+ const manager = new TargetManager({ sendMessage, flushDelayMs: 10 });
130
+
131
+ manager.register({ id: "report", binding: binding(), mode: "write" });
132
+ const staleToken = Symbol("stale");
133
+
134
+ // Unregister with wrong token — should be ignored.
135
+ const removed = manager.unregister("report", staleToken);
136
+ expect(removed).toBe(false);
137
+ expect(manager.get("report")).toBeDefined();
138
+ });
139
+
140
+ it("unregister with correct token removes the target", () => {
141
+ const sendMessage = vi.fn(async () => undefined);
142
+ const manager = new TargetManager({ sendMessage, flushDelayMs: 10 });
143
+
144
+ const token = manager.register({ id: "report", binding: binding(), mode: "write" });
145
+ const removed = manager.unregister("report", token);
146
+
147
+ expect(removed).toBe(true);
148
+ expect(manager.get("report")).toBeUndefined();
149
+ });
150
+
151
+ it("newer registration is not removed by stale token from older registration", () => {
152
+ const sendMessage = vi.fn(async () => undefined);
153
+ const manager = new TargetManager({ sendMessage, flushDelayMs: 10 });
154
+
155
+ const oldToken = manager.register({ id: "report", binding: binding(), mode: "write" });
156
+ // Simulate remount: same id is re-registered with a new binding.
157
+ manager.register({ id: "report", binding: binding(), mode: "write" });
158
+
159
+ // Stale cleanup from old mount — should not remove the new registration.
160
+ const removed = manager.unregister("report", oldToken);
161
+ expect(removed).toBe(false);
162
+ expect(manager.get("report")).toBeDefined();
163
+ });
164
+
165
+ it("unregister without token removes unconditionally (legacy behavior)", () => {
166
+ const sendMessage = vi.fn(async () => undefined);
167
+ const manager = new TargetManager({ sendMessage, flushDelayMs: 10 });
168
+
169
+ manager.register({ id: "report", binding: binding(), mode: "write" });
170
+ const removed = manager.unregister("report");
171
+
172
+ expect(removed).toBe(true);
173
+ expect(manager.get("report")).toBeUndefined();
174
+ });
175
+
176
+ it("preserves reset reason and scope", () => {
177
+ const sendMessage = vi.fn(async () => undefined);
178
+ const manager = new TargetManager({ sendMessage, flushDelayMs: 10 });
179
+ manager.register({ id: "report", binding: binding(), mode: "write" });
180
+ sendMessage.mockClear();
181
+
182
+ manager.reset("target", "report", "manual_edit");
183
+ manager.reset("global", undefined, "editor_empty");
184
+
185
+ expect(sendMessage).toHaveBeenCalledWith({
186
+ type: "session.reset",
187
+ payload: { scope: "target", targetId: "report", reason: "manual_edit" },
188
+ });
189
+ expect(sendMessage).toHaveBeenCalledWith({
190
+ type: "session.reset",
191
+ payload: { scope: "global", reason: "editor_empty" },
192
+ });
193
+ });
194
+ });
@@ -0,0 +1,194 @@
1
+ import type { EphiaClientMessage, ResetReason } from "ephia-protocol";
2
+ import type { EphiaBinding } from "../bindings/EphiaBinding";
3
+
4
+ type WireClientMessage = EphiaClientMessage | {
5
+ type: string;
6
+ payload?: Record<string, unknown>;
7
+ };
8
+
9
+ export type ManagedTarget = {
10
+ id: string;
11
+ binding: EphiaBinding | null;
12
+ element?: HTMLElement;
13
+ mode?: string;
14
+ };
15
+
16
+ /** Internal stored target — extends ManagedTarget with a registration token. */
17
+ type StoredTarget = ManagedTarget & { token: symbol };
18
+
19
+ export type TargetManagerOptions = {
20
+ sendMessage: (message: WireClientMessage) => Promise<void>;
21
+ flushDelayMs?: number;
22
+ onActiveTargetChange?: (targetId: string | null) => void;
23
+ onTargetRegistered?: (targetId: string) => void;
24
+ };
25
+
26
+ export class TargetManager {
27
+ private readonly targets = new Map<string, StoredTarget>();
28
+ private readonly sendMessage: TargetManagerOptions["sendMessage"];
29
+ private readonly flushDelayMs: number;
30
+ private readonly onActiveTargetChange?: (targetId: string | null) => void;
31
+ private readonly onTargetRegistered?: (targetId: string) => void;
32
+ private activeTargetId: string | null = null;
33
+ private flushTimers = new Map<string, ReturnType<typeof setTimeout>>();
34
+
35
+ constructor(options: TargetManagerOptions) {
36
+ this.sendMessage = options.sendMessage;
37
+ this.flushDelayMs = options.flushDelayMs ?? 80;
38
+ this.onActiveTargetChange = options.onActiveTargetChange;
39
+ this.onTargetRegistered = options.onTargetRegistered;
40
+ }
41
+
42
+ /**
43
+ * Register a target and return its registration token.
44
+ * Pass the token to unregister() to prevent stale cleanups from accidentally
45
+ * removing a newer registration with the same id (React StrictMode / remounts).
46
+ */
47
+ register(target: ManagedTarget): symbol {
48
+ const token = Symbol(target.id);
49
+ this.targets.set(target.id, { ...target, token });
50
+ if (this.activeTargetId === null && target.mode !== "read") {
51
+ this.setActive(target.id);
52
+ }
53
+ this.onTargetRegistered?.(target.id);
54
+ return token;
55
+ }
56
+
57
+ /**
58
+ * Unregister a target.
59
+ * When `token` is provided, only unregisters if the stored token matches
60
+ * (prevents a stale cleanup from removing a newer registration).
61
+ * Returns true if the target was removed, false if skipped.
62
+ */
63
+ unregister(targetId: string, token?: symbol): boolean {
64
+ const current = this.targets.get(targetId);
65
+ if (!current) return false;
66
+
67
+ if (token !== undefined && current.token !== token) {
68
+ // Token mismatch — a newer registration owns this id, skip.
69
+ return false;
70
+ }
71
+
72
+ this.targets.delete(targetId);
73
+ this.clearFlush(targetId);
74
+ if (this.activeTargetId === targetId) {
75
+ const next = this.firstWritableTargetId();
76
+ this.activeTargetId = null;
77
+ if (next) {
78
+ this.setActive(next);
79
+ } else {
80
+ this.onActiveTargetChange?.(null);
81
+ }
82
+ }
83
+ return true;
84
+ }
85
+
86
+ get(targetId: string): ManagedTarget | undefined {
87
+ return this.targets.get(targetId);
88
+ }
89
+
90
+ getActiveTargetId(): string | null {
91
+ return this.activeTargetId;
92
+ }
93
+
94
+ getActiveTarget(): ManagedTarget | undefined {
95
+ return this.activeTargetId ? this.targets.get(this.activeTargetId) : undefined;
96
+ }
97
+
98
+ setActive(targetId: string): void {
99
+ if (!this.targets.has(targetId)) return;
100
+ if (this.activeTargetId === targetId) {
101
+ this.scheduleEditorContextFlush(targetId);
102
+ return;
103
+ }
104
+ this.activeTargetId = targetId;
105
+ this.onActiveTargetChange?.(targetId);
106
+ this.send({
107
+ type: "session.target.changed",
108
+ payload: { targetId },
109
+ });
110
+ this.scheduleEditorContextFlush(targetId);
111
+ }
112
+
113
+ syncActiveTarget(): void {
114
+ if (!this.activeTargetId) return;
115
+ this.send({
116
+ type: "session.target.changed",
117
+ payload: { targetId: this.activeTargetId },
118
+ });
119
+ this.flushEditorContext(this.activeTargetId);
120
+ }
121
+
122
+ /** Resync native bindings from current DOM before dictation (manual edits between sessions). */
123
+ prepareForDictation(targetId = this.activeTargetId): void {
124
+ if (!targetId) return;
125
+ const binding = this.targets.get(targetId)?.binding;
126
+ if (binding?.kind === "native" && "prepareForDictation" in binding) {
127
+ (binding as { prepareForDictation: () => void }).prepareForDictation();
128
+ }
129
+ }
130
+
131
+ flushEditorContext(targetId = this.activeTargetId): void {
132
+ if (!targetId) return;
133
+ this.clearFlush(targetId);
134
+ const target = this.targets.get(targetId);
135
+ if (!target?.binding) return;
136
+ const context = target.binding.getEditorContext(target.id);
137
+ this.send({
138
+ type: "session.editor_context.update",
139
+ payload: context,
140
+ });
141
+ }
142
+
143
+ scheduleEditorContextFlush(targetId = this.activeTargetId): void {
144
+ if (!targetId) return;
145
+ this.clearFlush(targetId);
146
+ const timer = setTimeout(() => {
147
+ this.flushTimers.delete(targetId);
148
+ this.flushEditorContext(targetId);
149
+ }, this.flushDelayMs);
150
+ this.flushTimers.set(targetId, timer);
151
+ }
152
+
153
+ reset(
154
+ scope: "global" | "target",
155
+ targetId = this.activeTargetId,
156
+ reason: ResetReason = "user_explicit"
157
+ ): void {
158
+ this.send({
159
+ type: "session.reset",
160
+ payload:
161
+ scope === "target" && targetId
162
+ ? { scope: "target", targetId, reason }
163
+ : { scope: "global", reason },
164
+ });
165
+ }
166
+
167
+ dispose(): void {
168
+ for (const targetId of this.flushTimers.keys()) {
169
+ this.clearFlush(targetId);
170
+ }
171
+ this.targets.clear();
172
+ this.activeTargetId = null;
173
+ }
174
+
175
+ private firstWritableTargetId(): string | null {
176
+ for (const target of this.targets.values()) {
177
+ if (target.mode !== "read") return target.id;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ private clearFlush(targetId: string): void {
183
+ const timer = this.flushTimers.get(targetId);
184
+ if (timer) clearTimeout(timer);
185
+ this.flushTimers.delete(targetId);
186
+ }
187
+
188
+ private send(message: WireClientMessage): void {
189
+ this.sendMessage(message).catch(() => {
190
+ // The manager can be mounted before the LiveKit room is connected.
191
+ // A session-active resync flushes the latest active target/context.
192
+ });
193
+ }
194
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ EditorContextCollector,
3
+ type EditorContextCollectorOptions,
4
+ type EditorContextTarget,
5
+ } from "./EditorContextCollector";
6
+ export {
7
+ TargetManager,
8
+ type ManagedTarget,
9
+ type TargetManagerOptions,
10
+ } from "./TargetManager";
@@ -0,0 +1,11 @@
1
+ export {
2
+ stripLeadingOverlapFromText,
3
+ stripLeadingOverlapFromTextWithInfo,
4
+ } from "./overlap";
5
+ export {
6
+ normalizePreviewText,
7
+ normalizeCommittedText,
8
+ resolveVoiceFormattingConfig,
9
+ applyVoiceFormattingToEvent,
10
+ } from "./voice-formatting.normalizer";
11
+ export type { VoiceFormattingConfig, VoiceFormattingMode } from "./voice-formatting.normalizer";
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ stripLeadingOverlapFromText,
4
+ stripLeadingOverlapFromTextWithInfo,
5
+ } from "./overlap";
6
+
7
+ describe("realtime overlap", () => {
8
+ it("strips a repeated prefix already present before the cursor", () => {
9
+ const before = "... On retrouve cependant une formation méningi";
10
+ const incoming =
11
+ "On retrouve cependant une formation méningiomateuse mesurant 25 mm";
12
+
13
+ expect(stripLeadingOverlapFromText(before, incoming)).toBe(
14
+ "omateuse mesurant 25 mm"
15
+ );
16
+ });
17
+
18
+ it("reports partial-word overlap", () => {
19
+ const overlap = stripLeadingOverlapFromTextWithInfo(
20
+ "formation méningi",
21
+ "formation méningiomateuse"
22
+ );
23
+ expect(overlap).toEqual({
24
+ text: "omateuse",
25
+ partialWord: true,
26
+ overlapTokens: 2,
27
+ });
28
+ });
29
+
30
+ it("normalizes case, punctuation, and accents", () => {
31
+ expect(stripLeadingOverlapFromText("Bonne aération sinusienne.", "AERATION sinusienne et mastoïdienne")).toBe(
32
+ "et mastoïdienne"
33
+ );
34
+ });
35
+ });
@@ -0,0 +1,101 @@
1
+ const TOKEN_RE = /\S+/g;
2
+ const PUNCT_EDGE_RE = /^[.,;:!?"'«»“”‘’()[\]{}]+|[.,;:!?"'«»“”‘’()[\]{}]+$/g;
3
+ const ELISION_RE = /^(?:qu['’]|[dlsnmtcj]['’])/i;
4
+
5
+ interface Token {
6
+ raw: string;
7
+ norm: string;
8
+ start: number;
9
+ end: number;
10
+ }
11
+
12
+ function normalizeToken(token: string): string {
13
+ return token
14
+ .normalize("NFD")
15
+ .replace(/[\u0300-\u036f]/g, "")
16
+ .toLowerCase()
17
+ .replace(PUNCT_EDGE_RE, "")
18
+ .replace(ELISION_RE, "");
19
+ }
20
+
21
+ function tokenize(text: string): Token[] {
22
+ const out: Token[] = [];
23
+ for (const match of text.matchAll(TOKEN_RE)) {
24
+ const raw = match[0];
25
+ const start = match.index ?? 0;
26
+ const norm = normalizeToken(raw);
27
+ if (!norm) continue;
28
+ out.push({ raw, norm, start, end: start + raw.length });
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function prefixCharsForNormalizedPrefix(raw: string, normalizedPrefix: string): number {
34
+ if (!normalizedPrefix) return 0;
35
+ for (let i = 1; i <= raw.length; i++) {
36
+ const norm = normalizeToken(raw.slice(0, i));
37
+ if (norm.length >= normalizedPrefix.length && norm.startsWith(normalizedPrefix)) {
38
+ return i;
39
+ }
40
+ }
41
+ return 0;
42
+ }
43
+
44
+ /**
45
+ * Removes the prefix of `incoming` already covered by the suffix of `before`.
46
+ * Handles case/punctuation/diacritics and the common streaming case where the
47
+ * previous text ends with a partial word.
48
+ */
49
+ export function stripLeadingOverlapFromText(
50
+ before: string,
51
+ incoming: string,
52
+ maxTokens = 24
53
+ ): string {
54
+ return stripLeadingOverlapFromTextWithInfo(before, incoming, maxTokens).text;
55
+ }
56
+
57
+ export function stripLeadingOverlapFromTextWithInfo(
58
+ before: string,
59
+ incoming: string,
60
+ maxTokens = 24
61
+ ): { text: string; partialWord: boolean; overlapTokens: number } {
62
+ const left = tokenize(before);
63
+ const right = tokenize(incoming);
64
+ if (!left.length || !right.length) {
65
+ return { text: incoming, partialWord: false, overlapTokens: 0 };
66
+ }
67
+
68
+ const limit = Math.min(left.length, right.length, maxTokens);
69
+ for (let size = limit; size > 0; size--) {
70
+ const leftSlice = left.slice(left.length - size);
71
+ const rightSlice = right.slice(0, size);
72
+ let partialLast = false;
73
+ let matches = true;
74
+
75
+ for (let i = 0; i < size; i++) {
76
+ const l = leftSlice[i].norm;
77
+ const r = rightSlice[i].norm;
78
+ if (l === r) continue;
79
+ if (i === size - 1 && l.length >= 3 && r.startsWith(l)) {
80
+ partialLast = true;
81
+ continue;
82
+ }
83
+ matches = false;
84
+ break;
85
+ }
86
+ if (!matches) continue;
87
+
88
+ const lastRight = rightSlice[rightSlice.length - 1];
89
+ let cut = lastRight.end;
90
+ if (partialLast) {
91
+ cut = lastRight.start + prefixCharsForNormalizedPrefix(lastRight.raw, leftSlice[size - 1].norm);
92
+ }
93
+ return {
94
+ text: incoming.slice(cut).replace(/^[ \t]+/, ""),
95
+ partialWord: partialLast,
96
+ overlapTokens: size,
97
+ };
98
+ }
99
+
100
+ return { text: incoming, partialWord: false, overlapTokens: 0 };
101
+ }