@runtypelabs/persona 4.25.0 → 4.26.1

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 (236) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
  4. package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +14 -0
  8. package/dist/animations-extra.d.ts +14 -0
  9. package/dist/approval-ui-QAKWNFNM.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +186 -8
  12. package/dist/approval-ui.d.ts +186 -8
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
  15. package/dist/artifacts-ui.cjs +4 -4
  16. package/dist/artifacts-ui.d.cts +186 -8
  17. package/dist/artifacts-ui.d.ts +186 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-GKPCIQPB.js +1 -0
  23. package/dist/chunk-HGUCGM3K.js +9 -0
  24. package/dist/chunk-IO5VVUKP.js +3 -0
  25. package/dist/chunk-J4J3Y2P2.js +1 -0
  26. package/dist/chunk-K6HKIP5K.js +1 -0
  27. package/dist/chunk-K7NA7G7J.js +1 -0
  28. package/dist/chunk-MXUTS3LB.js +1 -0
  29. package/dist/chunk-PDPVORUF.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-GOZWBDA5.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +186 -8
  46. package/dist/context-mentions.d.ts +186 -8
  47. package/dist/context-mentions.js +8 -8
  48. package/dist/event-stream-view-FOIJHFQO.js +1 -0
  49. package/dist/event-stream-view.cjs +1 -1
  50. package/dist/event-stream-view.d.cts +189 -31
  51. package/dist/event-stream-view.d.ts +189 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +227 -667
  54. package/dist/forms-ui.d.ts +227 -667
  55. package/dist/history-shell-QDQ44PSE.js +3 -0
  56. package/dist/history-shell.js +3 -0
  57. package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
  58. package/dist/history-view.cjs +2 -2
  59. package/dist/history-view.d.cts +35 -1
  60. package/dist/history-view.d.ts +35 -1
  61. package/dist/history-view.js +2 -2
  62. package/dist/index.cjs +80 -74
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +233 -738
  65. package/dist/index.d.ts +233 -738
  66. package/dist/index.global.js +3 -215
  67. package/dist/index.global.js.map +1 -1
  68. package/dist/index.js +81 -75
  69. package/dist/index.js.map +1 -1
  70. package/dist/install.global.js +1 -1
  71. package/dist/install.global.js.map +1 -1
  72. package/dist/launcher.global.js +3 -67
  73. package/dist/launcher.global.js.map +1 -1
  74. package/dist/markdown-parsers.js +22 -22
  75. package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
  76. package/dist/runtype-tts.js +1 -1
  77. package/dist/session-actions-ISSKSI66.js +1 -0
  78. package/dist/session-actions.js +1 -0
  79. package/dist/session-reconnect.d.cts +186 -8
  80. package/dist/session-reconnect.d.ts +186 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +186 -8
  83. package/dist/smart-dom-reader.d.ts +186 -8
  84. package/dist/smart-dom-reader.js +11 -11
  85. package/dist/theme-editor-preview.cjs +116 -98
  86. package/dist/theme-editor-preview.d.cts +188 -8
  87. package/dist/theme-editor-preview.d.ts +188 -8
  88. package/dist/theme-editor-preview.js +49 -54
  89. package/dist/theme-editor.cjs +2 -2
  90. package/dist/theme-editor.d.cts +187 -23
  91. package/dist/theme-editor.d.ts +187 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-XLU6YK2N.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11656 -232
  100. package/dist/voice-runtime.d.ts +11656 -232
  101. package/dist/voice-runtime.js +17 -1
  102. package/dist/voice-worklet-player.cjs +19 -4
  103. package/dist/voice-worklet-player.d.cts +9 -10
  104. package/dist/voice-worklet-player.d.ts +9 -10
  105. package/dist/voice-worklet-player.js +19 -4
  106. package/dist/webmcp-polyfill.js +1 -1
  107. package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
  108. package/dist/webmcp-runtime.cjs +2 -2
  109. package/dist/webmcp-runtime.d.cts +1 -33
  110. package/dist/webmcp-runtime.d.ts +1 -33
  111. package/dist/webmcp-runtime.js +2 -2
  112. package/dist/widget.css +1 -1
  113. package/package.json +11 -5
  114. package/src/approval-paused-tool-settle.test.ts +325 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +6 -0
  139. package/src/index-global.ts +90 -248
  140. package/src/index.ts +13 -0
  141. package/src/install.ts +6 -4
  142. package/src/internal/runtype-history-provider.test.ts +1 -0
  143. package/src/internal/runtype-history-provider.ts +28 -13
  144. package/src/plugins/registry.ts +8 -8
  145. package/src/session-actions-chunk.test.ts +55 -0
  146. package/src/session-actions-eager.ts +11 -0
  147. package/src/session-actions-loader.ts +37 -0
  148. package/src/session-actions.ts +909 -0
  149. package/src/session-history.test.ts +3 -1
  150. package/src/session.ts +895 -1538
  151. package/src/session.voice.test.ts +1171 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +204 -5
  155. package/src/ui-ask-user-sheet.ts +382 -0
  156. package/src/ui-extras-eager.ts +10 -0
  157. package/src/ui-extras-entry.ts +9 -0
  158. package/src/ui-extras-loader.ts +26 -0
  159. package/src/ui.artifact-rail-resize.test.ts +2 -1
  160. package/src/ui.ask-user-chunk.test.ts +93 -0
  161. package/src/ui.history-chunk.test.ts +2 -1
  162. package/src/ui.history-config-provider.test.ts +2 -1
  163. package/src/ui.history-confirm-fallback.test.ts +142 -0
  164. package/src/ui.history-controller.test.ts +2 -1
  165. package/src/ui.history-lifecycle.test.ts +2 -1
  166. package/src/ui.history-render-hooks.test.ts +2 -1
  167. package/src/ui.history-shell.test.ts +2 -1
  168. package/src/ui.rail-overlay.test.ts +2 -1
  169. package/src/ui.rail-resize.test.ts +2 -1
  170. package/src/ui.rail-trigger-pending.test.ts +2 -1
  171. package/src/ui.ts +340 -2770
  172. package/src/utils/attachment-manager.ts +67 -67
  173. package/src/utils/constants.ts +3 -0
  174. package/src/utils/context-mention-controller.ts +323 -323
  175. package/src/utils/context-mention-manager.ts +40 -40
  176. package/src/utils/context-mention-orchestrator.ts +28 -0
  177. package/src/utils/event-stream-buffer.ts +50 -50
  178. package/src/utils/event-stream-store.ts +43 -43
  179. package/src/utils/throughput-tracker.ts +34 -34
  180. package/src/utils/virtual-scroller.ts +67 -67
  181. package/src/utils/visitor-store.test.ts +50 -0
  182. package/src/utils/visitor-store.ts +23 -5
  183. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  184. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  185. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  186. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  189. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  190. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  191. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  192. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  193. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  195. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  198. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  199. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  200. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  201. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  202. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  206. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  207. package/src/voice/audio-playback-manager.test.ts +57 -0
  208. package/src/voice/audio-playback-manager.ts +120 -98
  209. package/src/voice/browser-voice-provider.ts +37 -37
  210. package/src/voice/fallback-speech-engine.ts +22 -16
  211. package/src/voice/keyed-voice-transcript.ts +314 -0
  212. package/src/voice/mic-state-styles.ts +182 -0
  213. package/src/voice/read-aloud-controller.ts +46 -44
  214. package/src/voice/runtype-speech-engine.ts +35 -33
  215. package/src/voice/runtype-voice-provider.ts +680 -189
  216. package/src/voice/session-voice-wiring.ts +266 -0
  217. package/src/voice/voice-delegation.test.ts +71 -0
  218. package/src/voice/voice-delegation.ts +364 -0
  219. package/src/voice/voice-turn-tracker.ts +6 -6
  220. package/src/voice/voice.test.ts +1060 -8
  221. package/src/voice/worklet-playback-engine.test.ts +81 -0
  222. package/src/voice/worklet-playback-engine.ts +21 -3
  223. package/src/voice-runtime-loader.ts +4 -1
  224. package/src/voice-runtime.ts +7 -0
  225. package/src/webmcp-runtime-entry.ts +41 -39
  226. package/dist/approval-ui-3NMYNXBD.js +0 -1
  227. package/dist/chunk-5EIIHQLQ.js +0 -1
  228. package/dist/chunk-7CO4T5KB.js +0 -1
  229. package/dist/chunk-EFJBUH5F.js +0 -1
  230. package/dist/chunk-R2TMIMDI.js +0 -3
  231. package/dist/chunk-UTVLOTWB.js +0 -9
  232. package/dist/context-mentions-45MODWOY.js +0 -161
  233. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  234. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  235. package/dist/voice-runtime-7DA7MJH4.js +0 -1
  236. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
@@ -8,6 +8,17 @@
8
8
  // JSON control frames (transcript_interim / transcript_final /
9
9
  // audio_end / metrics).
10
10
  //
11
+ // Full duplex (speech-to-speech, e.g. GPT-Live): the client always declares
12
+ // `voiceCapabilities=full-duplex-v1`; engines that don't speak it ignore the
13
+ // param. A full-duplex session announces itself with
14
+ // `session_config{speechMode:'speech_to_speech'}` and then sends turn-keyed
15
+ // `transcript_update{role,text,turnId,final}` frames (later frames replace the
16
+ // text for the same (turnId, role); user and assistant turns can overlap),
17
+ // `delegation_started/completed` around agent tool turns, and `audio_clear` on
18
+ // barge-in. Reply audio has no per-turn end marker in that mode, so playback is
19
+ // treated as continuous: the "speaking" status is released by a timer derived
20
+ // from the queued audio duration rather than by the engine's end-of-stream.
21
+ //
11
22
  // The server's STT owns turn-taking, so the client streams continuously and
12
23
  // has no client-side VAD, barge-in monitoring, or batch upload. Auth rides the
13
24
  // `Sec-WebSocket-Protocol` subprotocol (`['runtype.bearer', clientToken]`),
@@ -24,20 +35,61 @@
24
35
  // socket idles (no call) until `startListening()` sends `{"type":"start"}`; if
25
36
  // it negotiates plain `runtype.bearer`, it ignored attach and the call is
26
37
  // already live, so `startListening()` adopts it as is.
38
+ //
39
+ // Wire vocabulary v1 (contract Amendment 5). Every socket sends
40
+ // `voiceProtocol=runtype-browser-v1`, `clientVersion=persona/<version>` and the
41
+ // snake_case `clientCapabilities` it supports. The server answers with
42
+ // `session_config.capabilities` (the negotiated intersection), the only gate
43
+ // for the client frames `context`, `delegation_update` and client delegation:
44
+ // a server without `capabilities` gets none of them. Receivers ignore unknown
45
+ // frames, fields and enum values. Non-fatal problems arrive as `warning`
46
+ // frames; an `error` always ends the call.
47
+ //
48
+ // Client delegation (full duplex, when the session supplied a bridge and
49
+ // `clientDelegation` isn't false, and `client_delegation` was negotiated): a
50
+ // `delegation_started{delegationId, input}` asks the widget to run the turn
51
+ // through its chat pipeline (one at a time). The provider answers with exactly
52
+ // one terminal `delegation_result{delegationId, status, text}`. A turn that
53
+ // parks on a tool approval first sends the non-terminal
54
+ // `delegation_update{delegationId, status:'pending_approval', text}`, then the
55
+ // terminal result once the visitor decides (or the approval lapses or is
56
+ // replaced). A second gated tool in the same turn sends another update.
57
+ // `delegation_cancelled{delegationId}` (or a `warning` refusing a frame for
58
+ // it) stops all frames for that id; the chat turn still renders, and since
59
+ // nobody speaks it now, browser TTS may read it. The voice model's spoken read-back of
60
+ // an answered delegation is folded: the chat already shows it. With
61
+ // `delegation_read_back` negotiated it is the utterance the server tags with that
62
+ // `delegationId`; otherwise it is the first new assistant utterance after
63
+ // `delegation_completed`, unless the visitor speaks first. Transcripts in such a call are display-only captions, never sent to the
64
+ // agent as conversation; a user bubble becomes conversation only when it is
65
+ // submitted. With `context` negotiated, one `context{text}` frame (chat history
66
+ // as of call start plus the host's `callContext`) is held until the visitor's
67
+ // first final transcript or first delegation.
27
68
 
28
69
  import type {
29
70
  VoiceProvider,
30
71
  VoiceResult,
31
72
  VoiceStatus,
32
73
  VoiceConfig,
74
+ VoiceDelegationRequest,
75
+ VoiceDelegationResult,
33
76
  VoiceMetrics,
34
77
  VoicePlaybackEngine,
78
+ VoiceSessionBridge,
79
+ VoiceTranscriptMetadata,
35
80
  } from "../types";
36
81
  import { AudioPlaybackManager } from "./audio-playback-manager";
82
+ import { VERSION } from "../version";
37
83
 
38
84
  const CAPTURE_SAMPLE_RATE = 16000;
39
85
  const PLAYBACK_SAMPLE_RATE = 24000;
40
86
  const CAPTURE_BUFFER_SIZE = 4096;
87
+ /**
88
+ * Mic audio held while the call socket handshakes (a slow upgrade can take
89
+ * seconds, and the visitor is already talking): 8 s of 16-bit PCM, 256 KB,
90
+ * well under the server's startup queue. Past it the oldest frames go.
91
+ */
92
+ const PRE_OPEN_AUDIO_MAX_BYTES = CAPTURE_SAMPLE_RATE * 2 * 8;
41
93
  /**
42
94
  * RMS-to-0..1 gain for the published capture level. Conversational speech sits
43
95
  * around 0.05 to 0.3 RMS, so the raw value would never leave the bottom of the
@@ -68,6 +120,70 @@ type AttachedSocket = {
68
120
  openedAt: number;
69
121
  idleTimer: ReturnType<typeof setTimeout> | undefined;
70
122
  };
123
+ /** Declared on every call; non-full-duplex engines ignore it. */
124
+ const FULL_DUPLEX_CAPABILITY = "full-duplex-v1";
125
+ /**
126
+ * Slack added to the computed end of queued speech-to-speech audio before the
127
+ * provider reports the reply as drained (covers engine prebuffer + scheduling).
128
+ */
129
+ const CONTINUOUS_DRAIN_GRACE_MS = 300;
130
+ /**
131
+ * How long a client `cancel` waits for the server's `audio_clear` before audio
132
+ * is accepted again, so a lost acknowledgement can't mute the call for good.
133
+ */
134
+ const CANCEL_ACK_TIMEOUT_MS = 2000;
135
+ /**
136
+ * How long a `delegation_result` waits for the call context still being built
137
+ * (a slow host `callContext`), so context always precedes the result. Past it,
138
+ * the result goes out and that context is dropped.
139
+ */
140
+ const CONTEXT_BEFORE_RESULT_TIMEOUT_MS = 2000;
141
+ const VOICE_PROTOCOL = "runtype-browser-v1";
142
+ /** `approvalTimeoutMs` cap: below core's delegation deadline (600 s, restarted by each update). */
143
+ const APPROVAL_TIMEOUT_MAX_MS = 540_000;
144
+ const DISCLOSURE_TEXT = "You're talking to an AI assistant. Voice is processed by OpenAI.";
145
+ /** Call-start context frame: total cap, host share, history window, per-message cap. */
146
+ const CONTEXT_MAX_CHARS = 8000;
147
+ const CONTEXT_HOST_MAX_CHARS = 4000;
148
+ const CONTEXT_MESSAGES = 12;
149
+ const CONTEXT_MESSAGE_MAX_CHARS = 2000;
150
+
151
+ type TranscriptCallback = (
152
+ role: "user" | "assistant",
153
+ text: string,
154
+ isFinal: boolean,
155
+ metadata?: VoiceTranscriptMetadata,
156
+ ) => void;
157
+
158
+ /**
159
+ * The call-start `context` text: "Conversation so far:" plus the last
160
+ * {@link CONTEXT_MESSAGES} messages (newest kept when over budget), then the
161
+ * host's extra context (at most {@link CONTEXT_HOST_MAX_CHARS}, so history
162
+ * always fits). Capped at {@link CONTEXT_MAX_CHARS}; "" when empty.
163
+ */
164
+ export function buildCallContext(
165
+ history: Array<{ role: "user" | "assistant"; content: string }>,
166
+ extra: string,
167
+ ): string {
168
+ const header = "Conversation so far:";
169
+ const tail = extra.slice(0, CONTEXT_HOST_MAX_CHARS).trim();
170
+ let budget = CONTEXT_MAX_CHARS - (tail ? tail.length + 2 : 0) - header.length;
171
+ const lines: string[] = [];
172
+ for (const message of history.slice(-CONTEXT_MESSAGES).reverse()) {
173
+ let text = message.content.replace(/\s+/g, " ").trim();
174
+ if (!text) continue;
175
+ if (text.length > CONTEXT_MESSAGE_MAX_CHARS) {
176
+ text = `${text.slice(0, CONTEXT_MESSAGE_MAX_CHARS - 1)}…`;
177
+ }
178
+ const line = `${message.role === "user" ? "User" : "Assistant"}: ${text}`;
179
+ if (line.length + 1 > budget) break;
180
+ budget -= line.length + 1;
181
+ lines.unshift(line);
182
+ }
183
+ const parts = lines.length > 0 ? [`${header}\n${lines.join("\n")}`] : [];
184
+ if (tail) parts.push(tail);
185
+ return parts.join("\n\n");
186
+ }
71
187
 
72
188
  /**
73
189
  * Strip the canonical 44-byte WAV header (if present) and return the raw PCM16
@@ -107,40 +223,72 @@ function clampAttachIdleMs(ms: number | undefined): number {
107
223
  export class RuntypeVoiceProvider implements VoiceProvider {
108
224
  type: "runtype" = "runtype";
109
225
 
110
- private ws: WebSocket | null = null;
111
- private captureContext: AudioContext | null = null;
112
- private levelCallbacks: ((level: number) => void)[] = [];
113
- private mediaStream: MediaStream | null = null;
114
- private sourceNode: MediaStreamAudioSourceNode | null = null;
115
- private processor: ScriptProcessorNode | null = null;
116
- private playback: VoicePlaybackEngine | null = null;
226
+ #ws: WebSocket | null = null;
227
+ #captureContext: AudioContext | null = null;
228
+ #levelCallbacks: ((level: number) => void)[] = [];
229
+ #mediaStream: MediaStream | null = null;
230
+ #sourceNode: MediaStreamAudioSourceNode | null = null;
231
+ #processor: ScriptProcessorNode | null = null;
232
+ // Captured before the call socket opened; flushed in order on open.
233
+ #preOpenAudio: ArrayBuffer[] = [];
234
+ #preOpenBytes = 0;
235
+ #playback: VoicePlaybackEngine | null = null;
117
236
 
118
237
  // True while a call (WS session) is live: drives the idempotent start guard
119
238
  // and `isBargeInActive()`.
120
- private callLive = false;
121
- private isSpeaking = false;
239
+ #callLive = false;
240
+ #isSpeaking = false;
122
241
 
123
242
  // Invalidates in-flight async work (playback-engine creation, late frames,
124
243
  // status transitions) after a teardown/restart so a stale callback can't act
125
244
  // on a newer call's resources. Bumped on every start and every cleanup.
126
- private callGeneration = 0;
245
+ #callGeneration = 0;
127
246
 
128
247
  // Distinguishes a user-initiated close (code 1000) from a dropped connection.
129
- private intentionalClose = false;
248
+ #intentionalClose = false;
130
249
 
131
250
  // Prewarm latch and the (at most one) socket an `'attach'` prewarm opened.
132
- private lastPrewarmAt = Number.NEGATIVE_INFINITY;
133
- private attached: AttachedSocket | null = null;
134
-
135
- private resultCallbacks: ((result: VoiceResult) => void)[] = [];
136
- private errorCallbacks: ((error: Error) => void)[] = [];
137
- private statusCallbacks: ((status: VoiceStatus) => void)[] = [];
138
- private transcriptCallbacks: ((
139
- role: "user" | "assistant",
140
- text: string,
141
- isFinal: boolean,
142
- ) => void)[] = [];
143
- private metricsCallbacks: ((metrics: VoiceMetrics) => void)[] = [];
251
+ #lastPrewarmAt = Number.NEGATIVE_INFINITY;
252
+ #attached: AttachedSocket | null = null;
253
+ // Full-duplex (speech-to-speech) session state; reset on every cleanup.
254
+ #speechToSpeech = false;
255
+ #delegating = false;
256
+ // Until this time, audio and assistant text belong to a reply the client cancelled.
257
+ #cancelledUntil = 0;
258
+ #playbackEndsAt = 0;
259
+ #drainTimer: ReturnType<typeof setTimeout> | undefined;
260
+ // Engine `onFinished` callbacks are one-shot (cleared on fire and on flush),
261
+ // so the provider re-registers before each reply that may need it.
262
+ #finishedArmed = false;
263
+ // Client delegation (see header); per-call state reset on every cleanup.
264
+ private bridge: VoiceSessionBridge | null = null;
265
+ #clientDelegation = false;
266
+ // session_config.capabilities: the negotiated client frame types.
267
+ #capabilities = new Set<string>();
268
+ // Per delegation: aborts its pending approval follow-up (cancelled, or call end).
269
+ #followUps = new Map<string, AbortController>();
270
+ // Delegations the server cancelled (or refused a frame for): no more frames for them.
271
+ #cancelledDelegations = new Set<string>();
272
+ // session_config.callId: the server's id for this call, for logs.
273
+ #callId: string | undefined;
274
+ // Call-start context, built at session_config and held until the visitor's
275
+ // first final transcript (sent earlier, the voice model tends to answer it).
276
+ #contextSent = false;
277
+ #pendingContext: Promise<string> | null = null;
278
+ // The released context's send; settles once it went out (or never will).
279
+ #contextSend: { done: Promise<void>; drop: () => void } | null = null;
280
+ #delegations: Promise<void> = Promise.resolve();
281
+ // Delegations answered ok: their spoken read-back is folded.
282
+ #answered = new Set<string>();
283
+ #foldReadback = false;
284
+ #assistantTurns = new Set<string>();
285
+ #foldedTurns = new Set<string>();
286
+
287
+ #resultCallbacks: ((result: VoiceResult) => void)[] = [];
288
+ #errorCallbacks: ((error: Error) => void)[] = [];
289
+ #statusCallbacks: ((status: VoiceStatus) => void)[] = [];
290
+ #transcriptCallbacks: TranscriptCallback[] = [];
291
+ #metricsCallbacks: ((metrics: VoiceMetrics) => void)[] = [];
144
292
 
145
293
  constructor(private config: VoiceConfig["runtype"]) {}
146
294
 
@@ -149,31 +297,35 @@ export class RuntypeVoiceProvider implements VoiceProvider {
149
297
  /** No-op: the WS session opens lazily in `startListening` (the "call"). */
150
298
  async connect(): Promise<void> {}
151
299
 
300
+ setSessionBridge(bridge: VoiceSessionBridge): void {
301
+ this.bridge = bridge;
302
+ }
303
+
152
304
  /**
153
305
  * Warm the voice path ahead of the click. Fire-and-forget and throttled to
154
306
  * once per {@link PREWARM_THROTTLE_MS}; never emits an error or a status.
155
307
  */
156
308
  prewarm(): void {
157
- if (this.callLive || this.attached) return;
309
+ if (this.#callLive || this.#attached) return;
158
310
  const agentId = this.config?.agentId;
159
311
  const token = this.config?.clientToken;
160
312
  const host = this.config?.host;
161
313
  if (!agentId || !token || !host) return;
162
314
  const now = Date.now();
163
- if (now - this.lastPrewarmAt < PREWARM_THROTTLE_MS) return;
164
- this.lastPrewarmAt = now;
315
+ if (now - this.#lastPrewarmAt < PREWARM_THROTTLE_MS) return;
316
+ this.#lastPrewarmAt = now;
165
317
  try {
166
318
  if (this.config?.prewarmMode === "attach") {
167
- this.attach(host, agentId, token);
319
+ this.#attach(host, agentId, token);
168
320
  } else {
169
- this.requestPrewarm(host, agentId, token);
321
+ this.#requestPrewarm(host, agentId, token);
170
322
  }
171
323
  } catch {
172
324
  // A prewarm must never surface an error; the click connects normally.
173
325
  }
174
326
  }
175
327
 
176
- private requestPrewarm(host: string, agentId: string, token: string): void {
328
+ #requestPrewarm(host: string, agentId: string, token: string): void {
177
329
  if (typeof fetch !== "function") return;
178
330
  const url = `${toHttpBase(host)}/v1/client/agents/${encodeURIComponent(agentId)}/voice/prewarm`;
179
331
  void fetch(url, {
@@ -183,12 +335,12 @@ export class RuntypeVoiceProvider implements VoiceProvider {
183
335
  }).catch(() => {});
184
336
  }
185
337
 
186
- private attach(host: string, agentId: string, token: string): void {
187
- const params = new URLSearchParams({ clientCapabilities: "attach" });
338
+ #attach(host: string, agentId: string, token: string): void {
339
+ const params = new URLSearchParams();
188
340
  const configuredIdleMs = this.config?.attachIdleMs;
189
341
  const idleMs = clampAttachIdleMs(configuredIdleMs);
190
342
  if (configuredIdleMs !== undefined) params.set("attachIdleMs", String(idleMs));
191
- const ws = new WebSocket(`${this.voiceSocketUrl(host, agentId)}?${params}`, [
343
+ const ws = new WebSocket(this.#voiceSocketUrl(host, agentId, params), [
192
344
  "runtype.bearer",
193
345
  "runtype.attach",
194
346
  token,
@@ -206,18 +358,18 @@ export class RuntypeVoiceProvider implements VoiceProvider {
206
358
  openedAt: 0,
207
359
  idleTimer: undefined,
208
360
  };
209
- this.attached = entry;
361
+ this.#attached = entry;
210
362
 
211
363
  ws.onopen = () => {
212
- if (this.attached !== entry) return;
364
+ if (this.#attached !== entry) return;
213
365
  entry.state = ws.protocol === "runtype.attach" ? "attached" : "live";
214
366
  entry.openedAt = Date.now();
215
- this.armAttachIdle(entry, idleMs);
367
+ this.#armAttachIdle(entry, idleMs);
216
368
  if (entry.state === "attached") ws.send('{"type":"ping"}');
217
369
  entry.settle();
218
370
  };
219
371
  ws.onmessage = (event) => {
220
- if (this.attached !== entry || typeof event.data !== "string") return;
372
+ if (this.#attached !== entry || typeof event.data !== "string") return;
221
373
  let msg: { type?: unknown; idleMs?: unknown };
222
374
  try {
223
375
  msg = JSON.parse(event.data);
@@ -226,27 +378,27 @@ export class RuntypeVoiceProvider implements VoiceProvider {
226
378
  }
227
379
  // The server's idle window is authoritative; close no later than it does.
228
380
  if (msg.type === "attached" && typeof msg.idleMs === "number") {
229
- this.armAttachIdle(entry, msg.idleMs - (Date.now() - entry.openedAt));
381
+ this.#armAttachIdle(entry, msg.idleMs - (Date.now() - entry.openedAt));
230
382
  }
231
383
  };
232
384
  ws.onerror = () => {};
233
385
  ws.onclose = () => {
234
- if (this.attached === entry) this.releaseAttached();
386
+ if (this.#attached === entry) this.#releaseAttached();
235
387
  };
236
388
  }
237
389
 
238
- private armAttachIdle(entry: AttachedSocket, ms: number): void {
390
+ #armAttachIdle(entry: AttachedSocket, ms: number): void {
239
391
  clearTimeout(entry.idleTimer);
240
392
  entry.idleTimer = setTimeout(() => {
241
- if (this.attached === entry) this.releaseAttached();
393
+ if (this.#attached === entry) this.#releaseAttached();
242
394
  }, Math.max(0, ms));
243
395
  }
244
396
 
245
397
  /** Close and forget the prewarmed socket, if any. Silent by design. */
246
- private releaseAttached(): void {
247
- const entry = this.attached;
398
+ #releaseAttached(): void {
399
+ const entry = this.#attached;
248
400
  if (!entry) return;
249
- this.attached = null;
401
+ this.#attached = null;
250
402
  clearTimeout(entry.idleTimer);
251
403
  const { ws } = entry;
252
404
  ws.onopen = null;
@@ -269,8 +421,8 @@ export class RuntypeVoiceProvider implements VoiceProvider {
269
421
  * Resolves `null` when there is none, it stalled, or it dropped, so the
270
422
  * caller opens a fresh socket.
271
423
  */
272
- private async takeAttachedSocket(): Promise<{ ws: WebSocket; live: boolean } | null> {
273
- const entry = this.attached;
424
+ async #takeAttachedSocket(): Promise<{ ws: WebSocket; live: boolean } | null> {
425
+ const entry = this.#attached;
274
426
  if (!entry) return null;
275
427
  if (entry.state === "connecting") {
276
428
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -282,23 +434,39 @@ export class RuntypeVoiceProvider implements VoiceProvider {
282
434
  ]);
283
435
  clearTimeout(timer);
284
436
  }
285
- if (this.attached !== entry) return null;
437
+ if (this.#attached !== entry) return null;
286
438
  if (entry.state === "connecting" || entry.ws.readyState !== WebSocket.OPEN) {
287
- this.releaseAttached();
439
+ this.#releaseAttached();
288
440
  return null;
289
441
  }
290
- this.attached = null;
442
+ this.#attached = null;
291
443
  clearTimeout(entry.idleTimer);
292
444
  return { ws: entry.ws, live: entry.state === "live" };
293
445
  }
294
446
 
295
- private voiceSocketUrl(host: string, agentId: string): string {
296
- return `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice`;
447
+ /**
448
+ * The call socket URL. Every socket declares the full-duplex capability;
449
+ * `extra` adds params (e.g. an attach prewarm's). The token never goes in
450
+ * the URL, and these params are not secrets.
451
+ */
452
+ #voiceSocketUrl(host: string, agentId: string, attach?: URLSearchParams): string {
453
+ const capabilities = ["partial_transcript", "context"];
454
+ if (this.bridge && this.config?.clientDelegation !== false)
455
+ capabilities.push("client_delegation", "delegation_update", "delegation_read_back");
456
+ if (attach) capabilities.unshift("attach");
457
+ const params = new URLSearchParams({
458
+ voiceProtocol: VOICE_PROTOCOL,
459
+ clientVersion: `persona/${VERSION}`,
460
+ voiceCapabilities: FULL_DUPLEX_CAPABILITY,
461
+ clientCapabilities: capabilities.join(","),
462
+ });
463
+ attach?.forEach((value, key) => params.set(key, value));
464
+ return `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice?${params}`;
297
465
  }
298
466
 
299
467
  /** Start the call: acquire mic, open the WS, stream PCM until hang-up. */
300
468
  async startListening(): Promise<void> {
301
- if (this.callLive) return; // idempotent: a call is already live
469
+ if (this.#callLive) return; // idempotent: a call is already live
302
470
 
303
471
  const agentId = this.config?.agentId;
304
472
  const token = this.config?.clientToken;
@@ -307,9 +475,9 @@ export class RuntypeVoiceProvider implements VoiceProvider {
307
475
  if (!token) throw new Error("Runtype voice requires a clientToken");
308
476
  if (!host) throw new Error("Runtype voice requires a host (or widget apiUrl)");
309
477
 
310
- const generation = ++this.callGeneration;
311
- this.intentionalClose = false;
312
- this.callLive = true;
478
+ const generation = ++this.#callGeneration;
479
+ this.#intentionalClose = false;
480
+ this.#callLive = true;
313
481
 
314
482
  try {
315
483
  const stream = await navigator.mediaDevices.getUserMedia({
@@ -319,11 +487,11 @@ export class RuntypeVoiceProvider implements VoiceProvider {
319
487
  echoCancellation: true,
320
488
  },
321
489
  });
322
- if (generation !== this.callGeneration) {
490
+ if (generation !== this.#callGeneration) {
323
491
  stream.getTracks().forEach((t) => t.stop());
324
492
  return;
325
493
  }
326
- this.mediaStream = stream;
494
+ this.#mediaStream = stream;
327
495
 
328
496
  // Create + resume both contexts inside the click gesture (iOS autoplay).
329
497
  const AudioCtx =
@@ -334,115 +502,124 @@ export class RuntypeVoiceProvider implements VoiceProvider {
334
502
  if (captureContext.state === "suspended") {
335
503
  await captureContext.resume().catch(() => {});
336
504
  }
337
- this.captureContext = captureContext;
505
+ this.#captureContext = captureContext;
338
506
 
339
507
  const engine = this.config?.createPlaybackEngine
340
508
  ? await this.config.createPlaybackEngine()
341
509
  : new AudioPlaybackManager(PLAYBACK_SAMPLE_RATE);
342
- if (generation !== this.callGeneration) {
510
+ if (generation !== this.#callGeneration) {
343
511
  // Torn down while async work was in flight: free what we acquired.
344
512
  void engine.destroy();
345
513
  stream.getTracks().forEach((t) => t.stop());
346
514
  captureContext.close().catch(() => {});
347
515
  return;
348
516
  }
349
- this.playback = engine;
350
- engine.onFinished(() => {
351
- if (generation !== this.callGeneration) return;
352
- this.isSpeaking = false;
353
- // Reply drained: the call stays open, so return to listening.
354
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
355
- this.emitStatus("listening");
356
- }
357
- });
517
+ this.#playback = engine;
518
+ this.#armPlaybackFinished();
519
+ // Capture from now: frames buffer until the call socket is open.
520
+ this.#startCapture(captureContext, stream, generation);
358
521
 
359
- const adopted = await this.takeAttachedSocket();
360
- if (generation !== this.callGeneration) {
522
+ const adopted = await this.#takeAttachedSocket();
523
+ if (generation !== this.#callGeneration) {
361
524
  adopted?.ws.close(1000, "client ended call");
362
525
  return;
363
526
  }
364
527
  if (adopted) {
365
528
  const { ws } = adopted;
366
- this.ws = ws;
367
- this.bindCallSocket(ws, generation);
529
+ this.#ws = ws;
530
+ this.#bindCallSocket(ws, generation);
368
531
  if (!adopted.live) ws.send('{"type":"start"}');
369
- this.emitStatus("listening");
370
- this.startCapture(captureContext, stream, ws, generation);
532
+ this.#flushPreOpenAudio(ws);
533
+ this.#emitStatus("listening");
371
534
  return;
372
535
  }
373
536
 
374
537
  // Token rides the subprotocol; `runtype.bearer` is the marker the server
375
538
  // echoes as the negotiated subprotocol (browsers fail the handshake if an
376
539
  // offered subprotocol goes unanswered).
377
- const ws = new WebSocket(this.voiceSocketUrl(host, agentId), ["runtype.bearer", token]);
540
+ const ws = new WebSocket(this.#voiceSocketUrl(host, agentId), ["runtype.bearer", token]);
378
541
  ws.binaryType = "arraybuffer";
379
- this.ws = ws;
542
+ this.#ws = ws;
380
543
 
381
544
  ws.onopen = () => {
382
- if (generation !== this.callGeneration) return;
383
- this.emitStatus("listening");
384
- this.startCapture(captureContext, stream, ws, generation);
545
+ if (generation !== this.#callGeneration) return;
546
+ this.#flushPreOpenAudio(ws);
547
+ this.#emitStatus("listening");
385
548
  };
386
- this.bindCallSocket(ws, generation);
549
+ this.#bindCallSocket(ws, generation);
387
550
  } catch (error) {
388
- this.cleanup();
389
- this.emitError(error as Error);
390
- this.emitStatus("error");
551
+ this.#cleanup();
552
+ this.#emitError(error as Error);
553
+ this.#emitStatus("error");
391
554
  throw error;
392
555
  }
393
556
  }
394
557
 
395
558
  /** Route a call socket's frames, errors, and close into this call. */
396
- private bindCallSocket(ws: WebSocket, generation: number): void {
397
- ws.onmessage = (event) => this.handleMessage(event, generation);
559
+ #bindCallSocket(ws: WebSocket, generation: number): void {
560
+ ws.onmessage = (event) => this.#handleMessage(event, generation);
398
561
 
399
562
  ws.onerror = () => {
400
- if (generation !== this.callGeneration) return;
401
- this.emitError(new Error("Voice connection failed"));
402
- this.emitStatus("error");
403
- this.cleanup();
563
+ if (generation !== this.#callGeneration) return;
564
+ this.#emitError(new Error("Voice connection failed"));
565
+ this.#emitStatus("error");
566
+ this.#cleanup();
404
567
  };
405
568
 
406
569
  ws.onclose = (evt) => {
407
- if (this.intentionalClose) {
408
- this.intentionalClose = false;
570
+ if (this.#intentionalClose) {
571
+ this.#intentionalClose = false;
409
572
  return;
410
573
  }
411
- if (generation !== this.callGeneration) return;
574
+ if (generation !== this.#callGeneration) return;
412
575
  if (evt.code !== 1000) {
413
576
  const codeMsg = evt.code ? ` (code ${evt.code})` : "";
414
- this.emitError(new Error(`Voice connection closed${codeMsg}`));
415
- this.emitStatus("error");
577
+ this.#emitError(new Error(`Voice connection closed${codeMsg}`));
578
+ this.#emitStatus("error");
416
579
  } else {
417
- this.emitStatus("idle");
580
+ this.#emitStatus("idle");
418
581
  }
419
- this.cleanup();
582
+ this.#cleanup();
420
583
  };
421
584
  }
422
585
 
586
+ /** The AI-disclosure notice for a live speech-to-speech call (`disclosureText`; `false` hides it). */
587
+ getDisclosure(): string | null {
588
+ const text = this.config?.disclosureText;
589
+ return this.#callLive && this.#speechToSpeech && text !== false ? text || DISCLOSURE_TEXT : null;
590
+ }
591
+
423
592
  /** End the call (hang up). */
424
593
  async stopListening(): Promise<void> {
425
- this.cleanup();
426
- this.emitStatus("idle");
594
+ this.#cleanup();
595
+ this.#emitStatus("idle");
427
596
  }
428
597
 
429
598
  /** Tear down the call and drop all callbacks (used by `cleanupVoice`). */
430
599
  async disconnect(): Promise<void> {
431
- this.cleanup();
432
- this.emitStatus("disconnected");
433
- this.resultCallbacks = [];
434
- this.errorCallbacks = [];
435
- this.statusCallbacks = [];
436
- this.transcriptCallbacks = [];
437
- this.metricsCallbacks = [];
600
+ this.#cleanup();
601
+ this.#emitStatus("disconnected");
602
+ this.#resultCallbacks = [];
603
+ this.#errorCallbacks = [];
604
+ this.#statusCallbacks = [];
605
+ this.#transcriptCallbacks = [];
606
+ this.#metricsCallbacks = [];
438
607
  }
439
608
 
440
- /** Stop the spoken reply without ending the call. */
609
+ /**
610
+ * Stop the spoken reply without ending the call. In a speech-to-speech
611
+ * session this also asks the server to cancel the response; audio is dropped
612
+ * until its `audio_clear` acknowledgement arrives.
613
+ */
441
614
  stopPlayback(): void {
442
- if (this.playback) this.playback.flush();
443
- this.isSpeaking = false;
444
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
445
- this.emitStatus("listening");
615
+ this.#clearLocalPlayback();
616
+ const ws = this.#ws;
617
+ if (ws && ws.readyState === WebSocket.OPEN) {
618
+ if (this.#speechToSpeech) {
619
+ this.#cancelledUntil = Date.now() + CANCEL_ACK_TIMEOUT_MS;
620
+ ws.send('{"type":"cancel"}');
621
+ }
622
+ this.#emitStatus("listening");
446
623
  }
447
624
  }
448
625
 
@@ -455,35 +632,33 @@ export class RuntypeVoiceProvider implements VoiceProvider {
455
632
 
456
633
  /** True while the call (hot mic) is live. */
457
634
  isBargeInActive(): boolean {
458
- return this.callLive;
635
+ return this.#callLive;
459
636
  }
460
637
 
461
638
  /** "Hang up" the always-on mic. */
462
639
  onLevel(callback: (level: number) => void): void {
463
- this.levelCallbacks.push(callback);
640
+ this.#levelCallbacks.push(callback);
464
641
  }
465
642
 
466
643
  async deactivateBargeIn(): Promise<void> {
467
- this.cleanup();
468
- this.emitStatus("idle");
644
+ this.#cleanup();
645
+ this.#emitStatus("idle");
469
646
  }
470
647
 
471
648
  // --- Capture ---------------------------------------------------------------
472
649
 
473
- private startCapture(
474
- context: AudioContext,
475
- stream: MediaStream,
476
- ws: WebSocket,
477
- generation: number,
478
- ): void {
650
+ #startCapture(context: AudioContext, stream: MediaStream, generation: number): void {
479
651
  const source = context.createMediaStreamSource(stream);
480
- this.sourceNode = source;
652
+ this.#sourceNode = source;
481
653
  const processor = context.createScriptProcessor(CAPTURE_BUFFER_SIZE, 1, 1);
482
- this.processor = processor;
654
+ this.#processor = processor;
483
655
 
484
656
  processor.onaudioprocess = (e) => {
485
- if (generation !== this.callGeneration) return;
486
- if (ws.readyState !== WebSocket.OPEN) return;
657
+ if (generation !== this.#callGeneration) return;
658
+ // No socket yet (adopting a prewarm) or still handshaking: buffer.
659
+ const ws = this.#ws;
660
+ const open = ws?.readyState === WebSocket.OPEN;
661
+ if (ws && !open && ws.readyState !== WebSocket.CONNECTING) return;
487
662
  const input = e.inputBuffer.getChannelData(0);
488
663
  const pcm16 = new Int16Array(input.length);
489
664
  let sumSquares = 0;
@@ -492,14 +667,23 @@ export class RuntypeVoiceProvider implements VoiceProvider {
492
667
  sumSquares += s * s;
493
668
  pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
494
669
  }
495
- ws.send(pcm16.buffer);
670
+ if (ws && open) {
671
+ this.#flushPreOpenAudio(ws);
672
+ ws.send(pcm16.buffer);
673
+ } else {
674
+ this.#preOpenAudio.push(pcm16.buffer);
675
+ this.#preOpenBytes += pcm16.byteLength;
676
+ while (this.#preOpenBytes > PRE_OPEN_AUDIO_MAX_BYTES) {
677
+ this.#preOpenBytes -= this.#preOpenAudio.shift()!.byteLength;
678
+ }
679
+ }
496
680
  // Amplitude comes free from the buffer we already walked: no analyser
497
681
  // node, no second capture. RMS is scaled because speech rarely exceeds
498
682
  // ~0.3 RMS, so raw values would sit near the bottom of the 0..1 range.
499
- if (this.levelCallbacks.length > 0) {
683
+ if (this.#levelCallbacks.length > 0) {
500
684
  const rms = Math.sqrt(sumSquares / input.length);
501
685
  const level = Math.max(0, Math.min(1, rms * LEVEL_RMS_SCALE));
502
- for (const cb of this.levelCallbacks) cb(level);
686
+ for (const cb of this.#levelCallbacks) cb(level);
503
687
  }
504
688
  };
505
689
 
@@ -509,13 +693,19 @@ export class RuntypeVoiceProvider implements VoiceProvider {
509
693
  processor.connect(context.destination);
510
694
  }
511
695
 
696
+ #flushPreOpenAudio(ws: WebSocket): void {
697
+ for (const frame of this.#preOpenAudio) ws.send(frame);
698
+ this.#preOpenAudio = [];
699
+ this.#preOpenBytes = 0;
700
+ }
701
+
512
702
  // --- Downstream ------------------------------------------------------------
513
703
 
514
- private handleMessage(event: MessageEvent, generation: number): void {
515
- if (generation !== this.callGeneration) return;
704
+ #handleMessage(event: MessageEvent, generation: number): void {
705
+ if (generation !== this.#callGeneration) return;
516
706
 
517
707
  if (event.data instanceof ArrayBuffer) {
518
- this.handleAudioFrame(event.data, generation);
708
+ this.#handleAudioFrame(event.data, generation);
519
709
  return;
520
710
  }
521
711
 
@@ -527,30 +717,150 @@ export class RuntypeVoiceProvider implements VoiceProvider {
527
717
  }
528
718
 
529
719
  switch (msg.type) {
720
+ case "session_config":
721
+ // The follow-up session_config carries only interruptionMode: keep the mode.
722
+ if (msg.speechMode) {
723
+ this.#speechToSpeech = msg.speechMode === "speech_to_speech";
724
+ this.#playback?.setContinuousMode?.(this.#speechToSpeech);
725
+ }
726
+ // The negotiated client frame types. A server without `capabilities`
727
+ // predates them: no client delegation, context or updates.
728
+ if (Array.isArray(msg.capabilities)) this.#capabilities = new Set(msg.capabilities);
729
+ if (typeof msg.callId === "string") this.#callId = msg.callId;
730
+ this.#clientDelegation = this.#speechToSpeech && this.#capabilities.has("client_delegation") && !!this.bridge;
731
+ // Speech-to-speech is known only now: let the UI show its disclosure.
732
+ if (this.#speechToSpeech && !this.#isSpeaking) this.#emitStatus("listening");
733
+ if (this.#capabilities.has("context") && !this.#contextSent) {
734
+ this.#contextSent = true;
735
+ this.#pendingContext = this.#buildContextText();
736
+ }
737
+ break;
738
+
739
+ case "transcript_update": {
740
+ const role = msg.role === "assistant" ? "assistant" : "user";
741
+ // A reply the client cancelled keeps streaming until the server clears it.
742
+ const utteranceId = msg.utteranceId ?? msg.turnId;
743
+ if (!utteranceId || (role === "assistant" && this.#isCancelling())) break;
744
+ const turnId = String(utteranceId);
745
+ if (role === "user") {
746
+ // Release on a final user transcript, not a partial: a mid-utterance
747
+ // context append makes the voice model answer early (or not delegate).
748
+ if (msg.final === true) this.#flushCallContext(generation);
749
+ this.#foldReadback = false;
750
+ } else {
751
+ // Read-back of a delegated result the chat already renders. A server
752
+ // that negotiated `delegation_read_back` tags it with its delegation;
753
+ // an older one rotates the assistant id at completion, so it is the
754
+ // first new id after it (a guess a filler finishing late defeats).
755
+ if (this.#capabilities.has("delegation_read_back")) {
756
+ // Fold only from the first frame: hiding an utterance already shown would strand a partial caption.
757
+ const tag = msg.delegationId;
758
+ if (typeof tag === "string" && this.#answered.has(tag) && !this.#assistantTurns.has(turnId)) {
759
+ this.#foldedTurns.add(turnId);
760
+ }
761
+ } else if (this.#foldReadback && !this.#assistantTurns.has(turnId)) {
762
+ this.#foldedTurns.add(turnId);
763
+ this.#foldReadback = false;
764
+ }
765
+ this.#assistantTurns.add(turnId);
766
+ if (this.#foldedTurns.has(turnId)) break;
767
+ }
768
+ this.#emitTranscript(role, msg.text ?? "", msg.final === true, {
769
+ turnId,
770
+ ...(typeof msg.startMs === "number" && { startMs: msg.startMs }),
771
+ ...(typeof msg.endMs === "number" && { endMs: msg.endMs }),
772
+ // The chat pipeline owns the conversation: speech is only captioned.
773
+ ...(this.#clientDelegation && { caption: true }),
774
+ });
775
+ break;
776
+ }
777
+
778
+ case "delegation_started":
779
+ case "delegation_completed": {
780
+ const delegationId = String(msg.delegationId ?? msg.turnId);
781
+ if (msg.type === "delegation_started") {
782
+ this.#flushCallContext(generation);
783
+ // `input`: run this turn through the chat pipeline (client delegation).
784
+ const input = msg.input;
785
+ if (this.#clientDelegation && input) {
786
+ this.#runDelegation(
787
+ delegationId,
788
+ {
789
+ delegationId,
790
+ userText: String(input.text ?? ""),
791
+ userUtteranceIds: Array.isArray(input.userUtteranceIds)
792
+ ? input.userUtteranceIds.filter((id: unknown): id is string => typeof id === "string")
793
+ : [],
794
+ messages: Array.isArray(input.messages) ? input.messages : [],
795
+ },
796
+ generation,
797
+ );
798
+ }
799
+ } else if (msg.speak !== false && this.#answered.has(delegationId)) {
800
+ // Every spoken phase (the approval ask, the late result) of an answer
801
+ // the chat shows is read back. A refusal of one that never started
802
+ // here has no chat answer: it renders. `final` may never come.
803
+ this.#foldReadback = true;
804
+ }
805
+ this.#delegating = msg.type === "delegation_started";
806
+ if (!this.#isSpeaking && !this.#isCancelling()) {
807
+ this.#emitStatus(this.#delegating ? "processing" : "listening");
808
+ }
809
+ break;
810
+ }
811
+
812
+ case "delegation_cancelled":
813
+ // The server gave up on it: no more frames for it, and its approval
814
+ // timers stop. The chat turn still renders. Past its deadline (or for
815
+ // an unknown reason), a parked approval card is also declined, as the
816
+ // approval TTL would; the call ending or the voice model cancelling
817
+ // leaves the card usable in the chat.
818
+ this.#dropDelegation(
819
+ String(msg.delegationId),
820
+ msg.reason !== "session_ending" && msg.reason !== "provider_cancelled",
821
+ );
822
+ break;
823
+
824
+ case "warning":
825
+ // Non-fatal (an unknown or refused frame): the call goes on. A refused
826
+ // delegation frame means the server is done with that delegation.
827
+ console.warn(`[Persona voice] ${msg.code}: ${msg.message ?? ""}`, this.#callId ?? "");
828
+ if (msg.delegationId && (msg.code === "UNKNOWN_DELEGATION" || msg.code === "LATE_RESULT_LIMIT")) {
829
+ this.#dropDelegation(String(msg.delegationId));
830
+ }
831
+ break;
832
+
833
+ case "audio_clear":
834
+ // Barge-in (or our own cancel acknowledged): stop playback now.
835
+ this.#clearLocalPlayback();
836
+ this.#cancelledUntil = 0;
837
+ this.#emitStatus("listening");
838
+ break;
839
+
530
840
  case "transcript_interim":
531
- this.emitStatus("listening");
532
- this.emitTranscript("user", msg.text ?? "", false);
841
+ this.#emitStatus("listening");
842
+ this.#emitTranscript("user", msg.text ?? "", false);
533
843
  break;
534
844
 
535
845
  case "transcript_final": {
536
846
  const role = msg.role === "assistant" ? "assistant" : "user";
537
847
  // user final → agent is now thinking; assistant final → reply incoming.
538
- this.emitStatus(role === "user" ? "processing" : "speaking");
539
- this.emitTranscript(role, msg.text ?? "", true);
848
+ this.#emitStatus(role === "user" ? "processing" : "speaking");
849
+ this.#emitTranscript(role, msg.text ?? "", true);
540
850
  break;
541
851
  }
542
852
 
543
853
  case "audio_end":
544
- if (this.playback) {
545
- this.playback.markStreamEnd();
854
+ if (this.#playback) {
855
+ this.#playback.markStreamEnd();
546
856
  } else {
547
- this.isSpeaking = false;
548
- this.emitStatus("listening");
857
+ this.#isSpeaking = false;
858
+ this.#emitStatus("listening");
549
859
  }
550
860
  break;
551
861
 
552
862
  case "metrics":
553
- this.emitMetrics({
863
+ this.#emitMetrics({
554
864
  llmMs: msg.llm_ms,
555
865
  ttsMs: msg.tts_ms,
556
866
  firstAudioMs: msg.first_audio_ms,
@@ -559,106 +869,287 @@ export class RuntypeVoiceProvider implements VoiceProvider {
559
869
  break;
560
870
 
561
871
  case "error":
562
- this.emitError(new Error(msg.error || "Voice error"));
563
- this.emitStatus("error");
872
+ this.#emitError(new Error(msg.error || "Voice error"));
873
+ this.#emitStatus("error");
564
874
  break;
565
875
  }
566
876
  }
567
877
 
568
- private handleAudioFrame(buf: ArrayBuffer, generation: number): void {
569
- if (generation !== this.callGeneration) return;
570
- if (!this.playback) return;
878
+ /** Send the call-start `context` frame (history + host context), if any. */
879
+ /**
880
+ * The call-start `context` text. History is read now, before any of this
881
+ * call's voice bubbles exist, so it never includes the utterance in progress.
882
+ */
883
+ async #buildContextText(): Promise<string> {
884
+ const history = this.bridge?.getHistory() ?? [];
885
+ let extra = "";
886
+ try {
887
+ const source = this.config?.callContext;
888
+ extra = String((typeof source === "function" ? await source() : source) ?? "");
889
+ } catch {
890
+ // A failing host callback only loses its own part of the context.
891
+ }
892
+ return buildCallContext(history, extra);
893
+ }
894
+
895
+ /** Send the held call-start context, once: at the first final user transcript or delegation. */
896
+ #flushCallContext(generation: number): void {
897
+ const pending = this.#pendingContext;
898
+ if (!pending) return;
899
+ this.#pendingContext = null;
900
+ let dropped = false;
901
+ const done = pending.then((text) => {
902
+ const ws = this.#ws;
903
+ if (dropped || !text || generation !== this.#callGeneration || ws?.readyState !== WebSocket.OPEN) return;
904
+ ws.send(JSON.stringify({ type: "context", text }));
905
+ });
906
+ this.#contextSend = { done, drop: () => (dropped = true) };
907
+ }
908
+
909
+ /** Let a released context go out first; a hung host callback is dropped. */
910
+ async #awaitContextSend(): Promise<void> {
911
+ const send = this.#contextSend;
912
+ if (!send) return;
913
+ let timer: ReturnType<typeof setTimeout> | undefined;
914
+ const timedOut = await Promise.race([
915
+ send.done.then(() => false),
916
+ new Promise<boolean>((resolve) => {
917
+ timer = setTimeout(() => resolve(true), CONTEXT_BEFORE_RESULT_TIMEOUT_MS);
918
+ }),
919
+ ]);
920
+ clearTimeout(timer);
921
+ if (timedOut) send.drop();
922
+ }
923
+
924
+ /** No more frames for this delegation; its chat answer goes back to browser TTS. */
925
+ #dropDelegation(delegationId: string, expired = false): void {
926
+ this.#cancelledDelegations.add(delegationId);
927
+ this.bridge?.dropDelegation?.(delegationId, expired);
928
+ this.#followUps.get(delegationId)?.abort();
929
+ }
930
+
931
+ /** Run a delegated turn through the session bridge (one at a time) and answer it. */
932
+ #runDelegation(delegationId: string, request: VoiceDelegationRequest, generation: number): void {
933
+ const bridge = this.bridge!;
934
+ /** Send a frame for this delegation, unless the call or the delegation ended. */
935
+ const send = (type: "delegation_update" | "delegation_result", status: string, text: string) => {
936
+ const ws = this.#ws;
937
+ if (generation !== this.#callGeneration || ws?.readyState !== WebSocket.OPEN) return false;
938
+ if (this.#cancelledDelegations.has(delegationId)) return false;
939
+ ws.send(JSON.stringify({ type, delegationId, status, text }));
940
+ return true;
941
+ };
942
+ this.#delegations = this.#delegations.then(async () => {
943
+ // Cancelled while queued behind another turn: never start it.
944
+ if (generation !== this.#callGeneration || this.#cancelledDelegations.has(delegationId)) return;
945
+ const result: VoiceDelegationResult = await bridge
946
+ .runDelegatedTurn(request)
947
+ .catch(() => ({ status: "failed", text: "" }));
948
+ await this.#awaitContextSend();
949
+ // Parked on an approval: ask now (non-terminal), answer once the visitor
950
+ // decides. A server that can't take an update gets the ask as the result.
951
+ const parked = !!result.followUp;
952
+ const update = parked && this.#capabilities.has("delegation_update");
953
+ const sent = send(
954
+ update ? "delegation_update" : "delegation_result",
955
+ parked && !update ? "completed" : result.status,
956
+ result.text,
957
+ );
958
+ if (!sent) return;
959
+ if (result.status !== "failed" && result.inChat !== false) this.#answered.add(delegationId);
960
+ if (!result.followUp) return;
961
+ // The approval bookkeeping (expiry, supersede) runs either way.
962
+ const abort = new AbortController();
963
+ this.#followUps.set(delegationId, abort);
964
+ void result
965
+ .followUp({
966
+ signal: abort.signal,
967
+ // Under the server's 600 s deadline, which restarts at each update.
968
+ approvalTimeoutMs: this.config?.approvalTimeoutMs && Math.min(this.config.approvalTimeoutMs, APPROVAL_TIMEOUT_MAX_MS),
969
+ readBack: update,
970
+ // Another gated tool in the same turn: another (non-terminal) update.
971
+ onUpdate: (text) => send("delegation_update", "pending_approval", text),
972
+ })
973
+ .then((followUp) => {
974
+ this.#followUps.delete(delegationId);
975
+ // Hang-up and cancellation send nothing.
976
+ if (update && followUp && !abort.signal.aborted) send("delegation_result", followUp.status, followUp.text);
977
+ });
978
+ });
979
+ }
980
+
981
+ #handleAudioFrame(buf: ArrayBuffer, generation: number): void {
982
+ if (generation !== this.#callGeneration) return;
983
+ if (!this.#playback) return;
984
+ if (this.#isCancelling()) return;
571
985
  const pcm = stripWavHeader(buf);
572
986
  if (pcm.length === 0) return;
573
- if (!this.isSpeaking) {
574
- this.isSpeaking = true;
575
- this.emitStatus("speaking");
987
+ if (!this.#isSpeaking) {
988
+ this.#isSpeaking = true;
989
+ this.#emitStatus("speaking");
990
+ }
991
+ this.#armPlaybackFinished();
992
+ this.#playback.enqueue(pcm);
993
+ if (this.#speechToSpeech) this.#scheduleContinuousDrain(pcm.length);
994
+ }
995
+
996
+ #isCancelling(): boolean {
997
+ return Date.now() < this.#cancelledUntil;
998
+ }
999
+
1000
+ /** Register the (one-shot) engine drain callback if none is pending. */
1001
+ #armPlaybackFinished(): void {
1002
+ if (this.#finishedArmed || !this.#playback) return;
1003
+ this.#finishedArmed = true;
1004
+ const generation = this.#callGeneration;
1005
+ this.#playback.onFinished(() => {
1006
+ if (generation !== this.#callGeneration) return;
1007
+ this.#finishedArmed = false;
1008
+ this.#handlePlaybackDrained();
1009
+ });
1010
+ }
1011
+
1012
+ /**
1013
+ * Speech-to-speech replies never get an end-of-stream, so estimate when the
1014
+ * queued audio finishes from its duration and release "speaking" then. The
1015
+ * engine is never marked ended, so the next reply's audio plays through the
1016
+ * same stream untouched and an underrun is never mistaken for a reply end.
1017
+ */
1018
+ #scheduleContinuousDrain(byteLength: number): void {
1019
+ const now = Date.now();
1020
+ // PCM16 @ 24 kHz: 48 bytes per millisecond.
1021
+ this.#playbackEndsAt = Math.max(now, this.#playbackEndsAt) + byteLength / 48;
1022
+ clearTimeout(this.#drainTimer);
1023
+ this.#drainTimer = setTimeout(
1024
+ () => this.#handlePlaybackDrained(),
1025
+ this.#playbackEndsAt - now + CONTINUOUS_DRAIN_GRACE_MS,
1026
+ );
1027
+ }
1028
+
1029
+ #handlePlaybackDrained(): void {
1030
+ this.#isSpeaking = false;
1031
+ this.#playbackEndsAt = 0;
1032
+ // Reply drained: the call stays open, so return to listening (or to
1033
+ // processing while an agent delegation is still running).
1034
+ if (this.#ws && this.#ws.readyState === WebSocket.OPEN) {
1035
+ this.#emitStatus(this.#delegating ? "processing" : "listening");
576
1036
  }
577
- this.playback.enqueue(pcm);
1037
+ }
1038
+
1039
+ /** Drop queued audio locally and reset the speaking/drain bookkeeping. */
1040
+ #clearLocalPlayback(): void {
1041
+ this.#playback?.flush();
1042
+ // flush() discards registered callbacks: re-arm for the next reply.
1043
+ this.#finishedArmed = false;
1044
+ this.#armPlaybackFinished();
1045
+ this.#isSpeaking = false;
1046
+ this.#playbackEndsAt = 0;
1047
+ clearTimeout(this.#drainTimer);
578
1048
  }
579
1049
 
580
1050
  // --- Teardown --------------------------------------------------------------
581
1051
 
582
- private cleanup(): void {
1052
+ #cleanup(): void {
583
1053
  // Invalidate any in-flight async continuation / late frames first.
584
- this.callGeneration += 1;
585
- this.callLive = false;
586
- this.isSpeaking = false;
587
- this.releaseAttached();
588
-
589
- if (this.processor) {
590
- this.processor.onaudioprocess = null;
591
- this.processor.disconnect();
592
- this.processor = null;
1054
+ this.#callGeneration += 1;
1055
+ this.#callLive = false;
1056
+ this.#isSpeaking = false;
1057
+ this.#releaseAttached();
1058
+ this.#speechToSpeech = false;
1059
+ this.#delegating = false;
1060
+ this.#clientDelegation = false;
1061
+ this.#capabilities = new Set();
1062
+ for (const abort of this.#followUps.values()) abort.abort();
1063
+ this.#followUps.clear();
1064
+ this.#cancelledDelegations.clear();
1065
+ this.#callId = undefined;
1066
+ this.#contextSent = false;
1067
+ this.#pendingContext = null;
1068
+ this.#contextSend = null;
1069
+ this.#delegations = Promise.resolve();
1070
+ this.#answered.clear();
1071
+ this.#foldReadback = false;
1072
+ this.#assistantTurns.clear();
1073
+ this.#foldedTurns.clear();
1074
+ this.#finishedArmed = false;
1075
+ this.#cancelledUntil = 0;
1076
+ this.#playbackEndsAt = 0;
1077
+ clearTimeout(this.#drainTimer);
1078
+ this.#preOpenAudio = [];
1079
+ this.#preOpenBytes = 0;
1080
+
1081
+ if (this.#processor) {
1082
+ this.#processor.onaudioprocess = null;
1083
+ this.#processor.disconnect();
1084
+ this.#processor = null;
593
1085
  }
594
- if (this.sourceNode) {
595
- this.sourceNode.disconnect();
596
- this.sourceNode = null;
1086
+ if (this.#sourceNode) {
1087
+ this.#sourceNode.disconnect();
1088
+ this.#sourceNode = null;
597
1089
  }
598
- if (this.mediaStream) {
599
- this.mediaStream.getTracks().forEach((t) => t.stop());
600
- this.mediaStream = null;
1090
+ if (this.#mediaStream) {
1091
+ this.#mediaStream.getTracks().forEach((t) => t.stop());
1092
+ this.#mediaStream = null;
601
1093
  }
602
- if (this.captureContext) {
603
- this.captureContext.close().catch(() => {});
604
- this.captureContext = null;
1094
+ if (this.#captureContext) {
1095
+ this.#captureContext.close().catch(() => {});
1096
+ this.#captureContext = null;
605
1097
  }
606
- if (this.playback) {
607
- void this.playback.destroy();
608
- this.playback = null;
1098
+ if (this.#playback) {
1099
+ void this.#playback.destroy();
1100
+ this.#playback = null;
609
1101
  }
610
- if (this.ws) {
611
- this.intentionalClose = true;
1102
+ if (this.#ws) {
1103
+ this.#intentionalClose = true;
612
1104
  try {
613
- this.ws.close(1000, "client ended call");
1105
+ this.#ws.close(1000, "client ended call");
614
1106
  } catch {
615
1107
  // ignore
616
1108
  }
617
- this.ws = null;
1109
+ this.#ws = null;
618
1110
  }
619
1111
  }
620
1112
 
621
1113
  // --- Callback registration + emit -----------------------------------------
622
1114
 
623
1115
  onResult(callback: (result: VoiceResult) => void): void {
624
- this.resultCallbacks.push(callback);
1116
+ this.#resultCallbacks.push(callback);
625
1117
  }
626
1118
 
627
1119
  onError(callback: (error: Error) => void): void {
628
- this.errorCallbacks.push(callback);
1120
+ this.#errorCallbacks.push(callback);
629
1121
  }
630
1122
 
631
1123
  onStatusChange(callback: (status: VoiceStatus) => void): void {
632
- this.statusCallbacks.push(callback);
1124
+ this.#statusCallbacks.push(callback);
633
1125
  }
634
1126
 
635
- onTranscript(
636
- callback: (role: "user" | "assistant", text: string, isFinal: boolean) => void,
637
- ): void {
638
- this.transcriptCallbacks.push(callback);
1127
+ onTranscript(callback: TranscriptCallback): void {
1128
+ this.#transcriptCallbacks.push(callback);
639
1129
  }
640
1130
 
641
1131
  onMetrics(callback: (metrics: VoiceMetrics) => void): void {
642
- this.metricsCallbacks.push(callback);
1132
+ this.#metricsCallbacks.push(callback);
643
1133
  }
644
1134
 
645
- private emitStatus(status: VoiceStatus): void {
646
- this.statusCallbacks.forEach((cb) => cb(status));
1135
+ #emitStatus(status: VoiceStatus): void {
1136
+ this.#statusCallbacks.forEach((cb) => cb(status));
647
1137
  }
648
1138
 
649
- private emitError(error: Error): void {
650
- this.errorCallbacks.forEach((cb) => cb(error));
1139
+ #emitError(error: Error): void {
1140
+ this.#errorCallbacks.forEach((cb) => cb(error));
651
1141
  }
652
1142
 
653
- private emitTranscript(
1143
+ #emitTranscript(
654
1144
  role: "user" | "assistant",
655
1145
  text: string,
656
1146
  isFinal: boolean,
1147
+ metadata?: VoiceTranscriptMetadata,
657
1148
  ): void {
658
- this.transcriptCallbacks.forEach((cb) => cb(role, text, isFinal));
1149
+ this.#transcriptCallbacks.forEach((cb) => cb(role, text, isFinal, metadata));
659
1150
  }
660
1151
 
661
- private emitMetrics(metrics: VoiceMetrics): void {
662
- this.metricsCallbacks.forEach((cb) => cb(metrics));
1152
+ #emitMetrics(metrics: VoiceMetrics): void {
1153
+ this.#metricsCallbacks.forEach((cb) => cb(metrics));
663
1154
  }
664
1155
  }