@pouchy_ai/companion-sdk 0.30.0 → 0.32.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 (58) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +51 -2
  3. package/dist/adapter-core.d.ts +58 -0
  4. package/dist/adapter-core.d.ts.map +1 -0
  5. package/dist/adapter-core.js +158 -0
  6. package/dist/adapter-core.js.map +1 -0
  7. package/dist/call.d.ts +1 -0
  8. package/dist/call.d.ts.map +1 -0
  9. package/dist/call.js +1 -0
  10. package/dist/call.js.map +1 -0
  11. package/dist/client.d.ts +48 -1
  12. package/dist/client.d.ts.map +1 -0
  13. package/dist/client.js +56 -3
  14. package/dist/client.js.map +1 -0
  15. package/dist/errors.d.ts +1 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +1 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +6 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/protocol.d.ts +1 -0
  24. package/dist/protocol.d.ts.map +1 -0
  25. package/dist/protocol.js +1 -0
  26. package/dist/protocol.js.map +1 -0
  27. package/dist/scopes.d.ts +1 -0
  28. package/dist/scopes.d.ts.map +1 -0
  29. package/dist/scopes.js +1 -0
  30. package/dist/scopes.js.map +1 -0
  31. package/dist/sse.d.ts +1 -0
  32. package/dist/sse.d.ts.map +1 -0
  33. package/dist/sse.js +1 -0
  34. package/dist/sse.js.map +1 -0
  35. package/dist/svelte.d.ts +16 -0
  36. package/dist/svelte.d.ts.map +1 -0
  37. package/dist/svelte.js +25 -0
  38. package/dist/svelte.js.map +1 -0
  39. package/dist/vue.d.ts +15 -0
  40. package/dist/vue.d.ts.map +1 -0
  41. package/dist/vue.js +27 -0
  42. package/dist/vue.js.map +1 -0
  43. package/dist/ws-transport.d.ts +1 -0
  44. package/dist/ws-transport.d.ts.map +1 -0
  45. package/dist/ws-transport.js +1 -0
  46. package/dist/ws-transport.js.map +1 -0
  47. package/package.json +24 -6
  48. package/src/adapter-core.ts +227 -0
  49. package/src/call.ts +699 -0
  50. package/src/client.ts +1960 -0
  51. package/src/errors.ts +77 -0
  52. package/src/index.ts +105 -0
  53. package/src/protocol.ts +316 -0
  54. package/src/scopes.ts +94 -0
  55. package/src/sse.ts +41 -0
  56. package/src/svelte.ts +47 -0
  57. package/src/vue.ts +46 -0
  58. package/src/ws-transport.ts +70 -0
package/src/call.ts ADDED
@@ -0,0 +1,699 @@
1
+ // SDK-native voice call — opens a live WebRTC voice session directly to the
2
+ // provider using the credentials from startCall(), with NO dependency on Pouchy's
3
+ // first-party connection wrappers (which are coupled to app stores / genui). This
4
+ // is what lets a pure third-party embed do voice.
5
+ //
6
+ // - OpenAI Realtime: fully self-contained WebRTC (no deps).
7
+ // - ElevenLabs Convai: dynamically imports '@elevenlabs/client' (an OPTIONAL peer
8
+ // dependency) only when that provider is selected; if it's not installed the
9
+ // call rejects with a clear message.
10
+ //
11
+ // Browser-only (needs WebRTC + getUserMedia). Returns a handle to hang up and to
12
+ // inject live context mid-call (the bridge the server uses for voiceRelevant).
13
+
14
+ import type { CallCredentials } from './client';
15
+ import { CompanionError } from './errors';
16
+
17
+ /** Lets the live voice call invoke the app's declared tools (pause_game,
18
+ * set_feature, …). The companion's voice LLM calls a tool → invoke() runs the
19
+ * app's onToolCall handler and resolves with a result string fed back to the
20
+ * model. Wired by connectCall from the tools declared at createCompanion(). */
21
+ export interface VoiceToolBridge {
22
+ tools: { name: string; description?: string; parameters?: Record<string, unknown> }[];
23
+ invoke: (name: string, argsJson: string) => Promise<string>;
24
+ }
25
+
26
+ /** Universal "host control" verbs the companion can call on ANY surface (web /
27
+ * game / app / hardware), regardless of provider. They're generic on purpose —
28
+ * the SPECIFIC intent rides in the string params, which YOUR surface interprets
29
+ * — so a single fixed schema set serves every embed AND can be pre-registered on
30
+ * the (otherwise static) ElevenLabs Convai agent. `invoke_action` is the
31
+ * catch-all trigger; the SDK unwraps it to the named action so it reaches your
32
+ * onToolCall just like a declared tool. The others pass through by name. */
33
+ export const HOST_CONTROL_TOOLS: VoiceToolBridge['tools'] = [
34
+ {
35
+ name: 'invoke_action',
36
+ description:
37
+ 'Trigger a named action the current surface supports — e.g. pause, resume, start, stop, restart, next, previous, submit, confirm, cancel, refresh. Put the action name in "action" and any extra args in "params".',
38
+ parameters: {
39
+ type: 'object',
40
+ properties: {
41
+ action: { type: 'string', description: 'the action, e.g. "pause" / "resume" / "next"' },
42
+ params: { type: 'object', description: 'optional extra arguments for the action' }
43
+ },
44
+ required: ['action']
45
+ }
46
+ },
47
+ {
48
+ name: 'set_feature',
49
+ description:
50
+ 'Turn a named feature/capability on or off (e.g. hints, captions, dark mode, a light, notifications, a mode).',
51
+ parameters: {
52
+ type: 'object',
53
+ properties: { feature: { type: 'string' }, on: { type: 'boolean' } },
54
+ required: ['feature', 'on']
55
+ }
56
+ },
57
+ {
58
+ name: 'set_value',
59
+ description:
60
+ 'Set a named control to a value — volume, brightness, difficulty, speed, zoom, temperature, etc.',
61
+ parameters: {
62
+ type: 'object',
63
+ properties: {
64
+ name: { type: 'string' },
65
+ value: { description: 'the value: a number, string, or boolean' }
66
+ },
67
+ required: ['name', 'value']
68
+ }
69
+ },
70
+ {
71
+ name: 'navigate',
72
+ description: 'Go to a named view / screen / page / section / mode / level in the surface.',
73
+ parameters: {
74
+ type: 'object',
75
+ properties: { to: { type: 'string' }, params: { type: 'object' } },
76
+ required: ['to']
77
+ }
78
+ },
79
+ {
80
+ name: 'highlight',
81
+ description: "Draw the user's attention to a named element (guidance / onboarding / a tutorial step).",
82
+ parameters: {
83
+ type: 'object',
84
+ properties: { target: { type: 'string' }, note: { type: 'string' } },
85
+ required: ['target']
86
+ }
87
+ }
88
+ ];
89
+
90
+ /** Names of the host-control verbs (for routing). */
91
+ export const HOST_CONTROL_TOOL_NAMES: readonly string[] = HOST_CONTROL_TOOLS.map((t) => t.name);
92
+
93
+ /** Avatar gesture/expression tools the embodied ("形象") companion calls to emote
94
+ * — drive a VRM/3D body's gestures and facial expression. These are offered on
95
+ * EVERY embed (like the host verbs) so the companion can express itself without
96
+ * each third party re-declaring them, and — crucially — so the SDK can register
97
+ * a no-op handler for them on the ElevenLabs Convai path, where the shared agent
98
+ * is pre-configured to call play_gesture / play_expression regardless of what the
99
+ * embed declares. An integrator that actually renders an avatar opts in by
100
+ * declaring (or handling) the same tool name and the SDK routes the call to them;
101
+ * otherwise it answers with a silent no-op success (see invokeVoiceTool / emit),
102
+ * so a pure audio or 2D embed can ignore avatars entirely and the companion's
103
+ * emote calls never error. Enum values mirror the canonical GESTURE_CATEGORIES /
104
+ * EXPRESSION_INTENTS the avatar runtime understands (kept inline so the SDK stays
105
+ * self-contained). */
106
+ export const AVATAR_VISUAL_TOOLS: VoiceToolBridge['tools'] = [
107
+ {
108
+ name: 'play_gesture',
109
+ description:
110
+ 'Play a full-body avatar gesture to express yourself physically (greeting, agreement, thinking, celebration, dance, …). No effect on surfaces without an avatar.',
111
+ parameters: {
112
+ type: 'object',
113
+ properties: {
114
+ category: {
115
+ type: 'string',
116
+ description: 'the kind of gesture',
117
+ enum: [
118
+ 'greeting',
119
+ 'agreement',
120
+ 'disagreement',
121
+ 'thinking',
122
+ 'celebration',
123
+ 'affection',
124
+ 'shy',
125
+ 'surprise',
126
+ 'dance',
127
+ 'point',
128
+ 'pose'
129
+ ]
130
+ }
131
+ },
132
+ required: ['category']
133
+ }
134
+ },
135
+ {
136
+ name: 'play_expression',
137
+ description:
138
+ "Set the avatar's facial expression to convey emotion (smile, laugh, wink, sad, surprised, …). No effect on surfaces without an avatar.",
139
+ parameters: {
140
+ type: 'object',
141
+ properties: {
142
+ intent: {
143
+ type: 'string',
144
+ description: 'the facial expression',
145
+ enum: [
146
+ 'smile',
147
+ 'laugh',
148
+ 'wink',
149
+ 'shy',
150
+ 'pout',
151
+ 'sad',
152
+ 'angry',
153
+ 'surprised',
154
+ 'relaxed',
155
+ 'smug',
156
+ 'neutral'
157
+ ]
158
+ }
159
+ },
160
+ required: ['intent']
161
+ }
162
+ }
163
+ ];
164
+
165
+ /** Names of the avatar visual tools (for routing + the no-op fallback). */
166
+ export const AVATAR_VISUAL_TOOL_NAMES: readonly string[] = AVATAR_VISUAL_TOOLS.map((t) => t.name);
167
+
168
+ export interface CompanionCallOptions {
169
+ /** Element to route remote audio to. One is created if omitted. */
170
+ audioElement?: HTMLAudioElement;
171
+ onTranscript?: (e: { role: 'user' | 'assistant'; text: string }) => void;
172
+ onSpeakingChange?: (speaking: boolean) => void;
173
+ onError?: (err: Error) => void;
174
+ /** OPT-IN full duplex: keep the mic OPEN while the companion speaks so the
175
+ * user can interrupt mid-utterance (both providers' native VAD interruption
176
+ * then works). Default false = half-duplex — the mic is muted during agent
177
+ * speech, because on phone SPEAKERS browser echo cancellation does not fully
178
+ * remove the companion's own voice and it barges in on itself (the SDK-voice
179
+ * "说一句就断了" field report). Enable only where AEC actually holds:
180
+ * headphones, desktop, or devices you have verified. */
181
+ bargeIn?: boolean;
182
+ }
183
+
184
+ export interface CompanionCall {
185
+ /** Hang up. Idempotent. */
186
+ close: () => void;
187
+ /** Inject an out-of-band context line. `speak: true` makes the companion react
188
+ * now; false stages it for the next turn. */
189
+ injectEvent: (text: string, speak?: boolean) => void;
190
+ provider: CallCredentials['provider'];
191
+ }
192
+
193
+ const OPENAI_REALTIME_CALLS = 'https://api.openai.com/v1/realtime/calls';
194
+
195
+ /** Open a live call from pre-minted credentials. */
196
+ export async function openCompanionCall(
197
+ creds: CallCredentials,
198
+ opts: CompanionCallOptions = {},
199
+ bridge?: VoiceToolBridge
200
+ ): Promise<CompanionCall> {
201
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
202
+ throw new CompanionError(
203
+ 'connectCall is browser-only (needs WebRTC + microphone access)',
204
+ 0,
205
+ 'call_unsupported'
206
+ );
207
+ }
208
+ return creds.provider === 'openai-realtime'
209
+ ? openOpenAICall(creds, opts, bridge)
210
+ : openConvaiCall(creds, opts, bridge);
211
+ }
212
+
213
+ // Connect-step timeouts (mirror the first-party realtime path). Without them a
214
+ // stuck mic (Android WebView holding the device) or a hung SDP exchange hangs
215
+ // the connect promise forever — and if gUM already resolved, with a hot mic.
216
+ const GUM_TIMEOUT_MS = 10_000;
217
+ const SDP_TIMEOUT_MS = 8_000;
218
+
219
+ /** getUserMedia with a timeout. If the timeout wins, a late-resolving mic is
220
+ * stopped so a slow permission grant can't leave the device recording after
221
+ * we've already given up on the call. */
222
+ async function getMicWithTimeout(): Promise<MediaStream> {
223
+ let timedOut = false;
224
+ const micPromise = navigator.mediaDevices.getUserMedia({
225
+ // Explicit processing constraints: echoCancellation is what makes the
226
+ // opt-in barge-in mode viable at all, and it is harmless in half-duplex.
227
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
228
+ });
229
+ micPromise
230
+ .then((m) => {
231
+ if (timedOut) m.getTracks().forEach((t) => t.stop());
232
+ })
233
+ .catch(() => {});
234
+ let timer: ReturnType<typeof setTimeout> | undefined;
235
+ const timeout = new Promise<never>((_, reject) => {
236
+ timer = setTimeout(() => {
237
+ timedOut = true;
238
+ reject(new CompanionError('microphone request timed out', 0, 'call_connect_failed'));
239
+ }, GUM_TIMEOUT_MS);
240
+ });
241
+ // Clear the timer when the mic wins the race — it held a live 10s closure.
242
+ return Promise.race([micPromise, timeout]).finally(() => {
243
+ if (timer) clearTimeout(timer);
244
+ });
245
+ }
246
+
247
+ // ── OpenAI Realtime — self-contained WebRTC ─────────────────────────────────
248
+ async function openOpenAICall(
249
+ creds: Extract<CallCredentials, { provider: 'openai-realtime' }>,
250
+ opts: CompanionCallOptions,
251
+ bridge?: VoiceToolBridge
252
+ ): Promise<CompanionCall> {
253
+ // Acquire the mic FIRST (with a timeout): if gUM fails/times out there is no
254
+ // RTCPeerConnection yet to leak. Creating pc before gUM (the old order) leaked
255
+ // a peer connection on every permission denial.
256
+ const mic = await getMicWithTimeout();
257
+ const pc = new RTCPeerConnection();
258
+ const audioEl = opts.audioElement ?? new Audio();
259
+ audioEl.autoplay = true;
260
+ pc.ontrack = (e) => {
261
+ audioEl.srcObject = e.streams[0];
262
+ void audioEl.play().catch(() => {});
263
+ };
264
+
265
+ for (const track of mic.getAudioTracks()) pc.addTrack(track, mic);
266
+
267
+ // Half-duplex mic (mirrors the first-party realtime path, #1524): on a phone
268
+ // SPEAKER browser AEC doesn't fully cancel the companion's own voice, so it
269
+ // leaks into the mic, is transcribed as a USER turn, and barges in on itself —
270
+ // the avatar cuts off mid-sentence after one line (field report: SDK voice on
271
+ // agentpoker "说一句就断了"). Mute the owned mic track WHILE the model speaks
272
+ // (sends silence, keeps the track + transport alive), re-open after a short tail
273
+ // when the turn ends. Tradeoff: no voice barge-in mid-utterance — far better than
274
+ // the companion interrupting itself.
275
+ const MIC_UNMUTE_TAIL_MS = 350;
276
+ let micUnmuteTimer: ReturnType<typeof setTimeout> | null = null;
277
+ const setMicEnabled = (enabled: boolean) => {
278
+ try {
279
+ for (const tr of mic.getAudioTracks()) tr.enabled = enabled;
280
+ } catch {
281
+ /* track gone — ignore */
282
+ }
283
+ };
284
+ const muteMicForSpeech = () => {
285
+ if (micUnmuteTimer) {
286
+ clearTimeout(micUnmuteTimer);
287
+ micUnmuteTimer = null;
288
+ }
289
+ setMicEnabled(false);
290
+ };
291
+ const scheduleMicUnmute = () => {
292
+ if (micUnmuteTimer) clearTimeout(micUnmuteTimer);
293
+ micUnmuteTimer = setTimeout(() => {
294
+ micUnmuteTimer = null;
295
+ if (!closed) setMicEnabled(true);
296
+ }, MIC_UNMUTE_TAIL_MS);
297
+ };
298
+
299
+ const dc = pc.createDataChannel('oai-events');
300
+ // `response.function_call_arguments.done` carries the call_id + args but often
301
+ // not the name — the name arrives earlier on `response.output_item.added`; track it.
302
+ // Cancelled tool calls never reach `arguments.done`, so their entries were
303
+ // never deleted — cap the map (drop-oldest) so a long call can't grow it.
304
+ const callNames = new Map<string, string>();
305
+ const rememberCallName = (id: string, name: string) => {
306
+ callNames.set(id, name);
307
+ if (callNames.size > 64) {
308
+ const first = callNames.keys().next().value;
309
+ if (first !== undefined) callNames.delete(first);
310
+ }
311
+ };
312
+ if (bridge?.tools.length) {
313
+ dc.addEventListener('open', () => {
314
+ try {
315
+ dc.send(
316
+ JSON.stringify({
317
+ type: 'session.update',
318
+ session: {
319
+ type: 'realtime',
320
+ tool_choice: 'auto',
321
+ tools: bridge.tools.map((t) => ({
322
+ type: 'function',
323
+ name: t.name,
324
+ description: t.description ?? '',
325
+ parameters: t.parameters ?? { type: 'object', properties: {} }
326
+ }))
327
+ }
328
+ })
329
+ );
330
+ } catch {
331
+ /* a rejected tools update must not kill the call */
332
+ }
333
+ });
334
+ }
335
+ dc.onmessage = (ev) => {
336
+ let msg: {
337
+ type?: string;
338
+ transcript?: string;
339
+ delta?: string;
340
+ call_id?: string;
341
+ name?: string;
342
+ arguments?: string;
343
+ item?: { type?: string; name?: string; call_id?: string };
344
+ };
345
+ try {
346
+ msg = JSON.parse(ev.data);
347
+ } catch {
348
+ return;
349
+ }
350
+ // App tool-calling over voice: register name at start, dispatch at done.
351
+ if (
352
+ bridge &&
353
+ msg.type === 'response.output_item.added' &&
354
+ msg.item?.type === 'function_call' &&
355
+ msg.item.call_id &&
356
+ msg.item.name
357
+ ) {
358
+ rememberCallName(msg.item.call_id, msg.item.name);
359
+ }
360
+ if (bridge && msg.type === 'response.function_call_arguments.done' && msg.call_id) {
361
+ const callId = msg.call_id;
362
+ const name = (msg.name && msg.name) || callNames.get(callId) || '';
363
+ callNames.delete(callId);
364
+ if (name && bridge.tools.some((t) => t.name === name)) {
365
+ const argsText = typeof msg.arguments === 'string' ? msg.arguments : '{}';
366
+ // invokeVoiceTool never rejects, but openCompanionCall is a public
367
+ // export (bring-your-own bridge) — a throwing consumer bridge must
368
+ // still answer the model, or the voice turn hangs until provider
369
+ // timeout (plus an unhandled rejection).
370
+ void bridge
371
+ .invoke(name, argsText)
372
+ .catch((e) =>
373
+ JSON.stringify({ ok: false, error: e instanceof Error ? e.message : 'tool failed' })
374
+ )
375
+ .then((output) => {
376
+ try {
377
+ dc.send(
378
+ JSON.stringify({
379
+ type: 'conversation.item.create',
380
+ item: { type: 'function_call_output', call_id: callId, output }
381
+ })
382
+ );
383
+ dc.send(JSON.stringify({ type: 'response.create' }));
384
+ } catch {
385
+ /* channel gone */
386
+ }
387
+ });
388
+ }
389
+ return;
390
+ }
391
+ if (msg.type === 'response.audio_transcript.done' && msg.transcript) {
392
+ opts.onTranscript?.({ role: 'assistant', text: msg.transcript });
393
+ } else if (
394
+ msg.type === 'conversation.item.input_audio_transcription.completed' &&
395
+ msg.transcript
396
+ ) {
397
+ opts.onTranscript?.({ role: 'user', text: msg.transcript });
398
+ } else if (msg.type === 'output_audio_buffer.started') {
399
+ opts.onSpeakingChange?.(true);
400
+ // Model started speaking → mute the mic so its own audio can't
401
+ // echo-transcribe into a self-barge-in. bargeIn opts out (B wave):
402
+ // the mic stays open and OpenAI's server VAD handles interruption.
403
+ if (opts.bargeIn !== true) muteMicForSpeech();
404
+ } else if (
405
+ msg.type === 'output_audio_buffer.stopped' ||
406
+ msg.type === 'output_audio_buffer.cleared' ||
407
+ msg.type === 'response.done' ||
408
+ msg.type === 'conversation.interrupted'
409
+ ) {
410
+ // Turn ended (or was cancelled / interrupted) — clear the speaking state
411
+ // and re-open the mic after a short tail so the last bit of the model's
412
+ // audio doesn't get captured the instant we unmute. Several event names
413
+ // can end a turn; any of them re-arms the mic.
414
+ opts.onSpeakingChange?.(false);
415
+ if (opts.bargeIn !== true) scheduleMicUnmute();
416
+ }
417
+ };
418
+
419
+ const offer = await pc.createOffer();
420
+ await pc.setLocalDescription(offer);
421
+ // Bound the SDP exchange so a hung network can't leave the pc + hot mic open
422
+ // forever. On any failure, close both before throwing.
423
+ const sdpAbort = new AbortController();
424
+ const sdpTimer = setTimeout(() => sdpAbort.abort(), SDP_TIMEOUT_MS);
425
+ const cleanupPartial = () => {
426
+ pc.close();
427
+ mic.getTracks().forEach((t) => t.stop());
428
+ };
429
+ let res: Response;
430
+ try {
431
+ res = await fetch(OPENAI_REALTIME_CALLS, {
432
+ method: 'POST',
433
+ headers: { Authorization: `Bearer ${creds.clientSecret}`, 'Content-Type': 'application/sdp' },
434
+ body: offer.sdp,
435
+ signal: sdpAbort.signal
436
+ });
437
+ } catch {
438
+ clearTimeout(sdpTimer);
439
+ cleanupPartial();
440
+ throw new CompanionError(
441
+ sdpAbort.signal.aborted
442
+ ? 'OpenAI Realtime SDP exchange timed out'
443
+ : 'OpenAI Realtime SDP exchange failed',
444
+ 0,
445
+ 'call_connect_failed'
446
+ );
447
+ }
448
+ clearTimeout(sdpTimer);
449
+ if (!res.ok) {
450
+ cleanupPartial();
451
+ throw new CompanionError(
452
+ `OpenAI Realtime SDP exchange failed (${res.status})`,
453
+ res.status,
454
+ 'call_connect_failed'
455
+ );
456
+ }
457
+ await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
458
+
459
+ let closed = false;
460
+ // `disconnected` is TRANSIENT per the WebRTC spec — a brief Wi-Fi hiccup or a
461
+ // cell handoff routinely drops to `disconnected` and recovers to `connected`
462
+ // on its own. Tearing down the instant it appears killed calls that would have
463
+ // healed (field report: SDK voice "动不动就掉线"). Give it a grace window to
464
+ // come back; only if it's STILL disconnected when the timer fires do we tear
465
+ // down — so the mic still can't record forever on a genuine silent drop (the
466
+ // original concern). `failed` / `closed` stay immediately fatal.
467
+ const DISCONNECT_GRACE_MS = 6000;
468
+ let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
469
+ const clearDisconnectTimer = () => {
470
+ if (disconnectTimer) {
471
+ clearTimeout(disconnectTimer);
472
+ disconnectTimer = null;
473
+ }
474
+ };
475
+ const teardown = () => {
476
+ if (closed) return;
477
+ closed = true;
478
+ clearDisconnectTimer();
479
+ if (micUnmuteTimer) {
480
+ clearTimeout(micUnmuteTimer);
481
+ micUnmuteTimer = null;
482
+ }
483
+ try {
484
+ dc.close();
485
+ } catch {
486
+ /* ignore */
487
+ }
488
+ pc.close();
489
+ mic.getTracks().forEach((t) => t.stop());
490
+ };
491
+
492
+ pc.onconnectionstatechange = () => {
493
+ const st = pc.connectionState;
494
+ if (st === 'connected') {
495
+ // (Re)connected — cancel any pending disconnect teardown.
496
+ clearDisconnectTimer();
497
+ return;
498
+ }
499
+ if (st === 'failed' || st === 'closed') {
500
+ // Genuinely dead — tear down immediately, BEFORE surfacing the error, so a
501
+ // silent transport drop can't leave the mic recording (mirrors the
502
+ // first-party realtime/connection.ts fail() ordering).
503
+ clearDisconnectTimer();
504
+ teardown();
505
+ opts.onError?.(new Error(`call ${st}`));
506
+ return;
507
+ }
508
+ if (st === 'disconnected') {
509
+ // Hold a grace window; a recovery to `connected` (handled above) cancels
510
+ // the timer. Still disconnected when it fires → tear down.
511
+ if (disconnectTimer || closed) return;
512
+ disconnectTimer = setTimeout(() => {
513
+ disconnectTimer = null;
514
+ if (closed) return;
515
+ if (pc.connectionState === 'disconnected') {
516
+ teardown();
517
+ opts.onError?.(new Error('call disconnected'));
518
+ }
519
+ }, DISCONNECT_GRACE_MS);
520
+ }
521
+ };
522
+
523
+ // Data-channel liveness (mirrors the first-party dataChannel.onclose/onerror).
524
+ // A dc-only death — the channel closes/errors while pc.connectionState still
525
+ // reads 'connected' — otherwise goes undetected and the mic stays hot.
526
+ // teardown flips `closed` before it calls dc.close(), so a normal teardown's
527
+ // own channel close is guarded out here (no double-fire / spurious error).
528
+ dc.onclose = () => {
529
+ if (closed) return;
530
+ teardown();
531
+ opts.onError?.(new Error('call data channel closed'));
532
+ };
533
+ dc.onerror = () => {
534
+ if (closed) return;
535
+ teardown();
536
+ opts.onError?.(new Error('call data channel error'));
537
+ };
538
+
539
+ return {
540
+ provider: 'openai-realtime',
541
+ close: teardown,
542
+ injectEvent: (text, speak = false) => {
543
+ if (closed || dc.readyState !== 'open' || !text.trim()) return;
544
+ dc.send(
545
+ JSON.stringify({
546
+ type: 'conversation.item.create',
547
+ item: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] }
548
+ })
549
+ );
550
+ if (speak) dc.send(JSON.stringify({ type: 'response.create' }));
551
+ }
552
+ };
553
+ }
554
+
555
+ // ── ElevenLabs Convai — via the optional @elevenlabs/client peer dep ─────────
556
+ async function openConvaiCall(
557
+ creds: Extract<CallCredentials, { provider: 'elevenlabs-convai' }>,
558
+ opts: CompanionCallOptions,
559
+ bridge?: VoiceToolBridge
560
+ ): Promise<CompanionCall> {
561
+ let mod: typeof import('@elevenlabs/client');
562
+ try {
563
+ mod = await import('@elevenlabs/client');
564
+ } catch {
565
+ throw new CompanionError(
566
+ "elevenlabs-convai call needs the optional peer dependency '@elevenlabs/client' — install it (npm i @elevenlabs/client)",
567
+ 0,
568
+ 'call_dependency_missing'
569
+ );
570
+ }
571
+
572
+ // App tools → EL client tools. NOTE: ElevenLabs only CALLS a client tool the
573
+ // agent already knows (declared on the agent config); a shared Convai agent
574
+ // can't take arbitrary per-app tools at runtime, so dynamic app tools are
575
+ // reliable on the OpenAI Realtime path. Registering the handlers is harmless
576
+ // and works for any tool the agent does know.
577
+ const clientTools: Record<string, (params: unknown) => Promise<string>> = {};
578
+ if (bridge?.tools.length) {
579
+ for (const t of bridge.tools) {
580
+ clientTools[t.name] = (params: unknown) =>
581
+ bridge.invoke(t.name, JSON.stringify(params ?? {}));
582
+ }
583
+ }
584
+
585
+ let closed = false;
586
+ // Half-duplex mic (mirrors the first-party path, #1524): mute while the agent
587
+ // speaks so its own voice can't echo-transcribe into a self-barge-in (the SDK
588
+ // voice "说一句就断了" report), re-open after a short tail. Convai exposes
589
+ // setMicMuted; guard it (older client builds may lack it).
590
+ const MIC_UNMUTE_TAIL_MS = 350;
591
+ let micUnmuteTimer: ReturnType<typeof setTimeout> | null = null;
592
+ const setConvMicMuted = (muted: boolean) => {
593
+ try {
594
+ (conv as unknown as { setMicMuted?: (m: boolean) => void }).setMicMuted?.(muted);
595
+ } catch {
596
+ /* not supported on this client build — ignore */
597
+ }
598
+ };
599
+ const conv = await mod.Conversation.startSession({
600
+ conversationToken: creds.token,
601
+ connectionType: 'webrtc',
602
+ ...(Object.keys(clientTools).length ? { clientTools } : {}),
603
+ overrides: {
604
+ agent: {
605
+ // The server-assembled persona + memory snapshot drives the EL agent.
606
+ prompt: { prompt: creds.instructions },
607
+ ...(creds.language ? { language: creds.language as never } : {}),
608
+ ...(creds.firstMessage !== undefined ? { firstMessage: creds.firstMessage } : {})
609
+ },
610
+ ...(creds.voice ? { tts: { voiceId: creds.voice } } : {})
611
+ },
612
+ onModeChange: ({ mode }: { mode: string }) => {
613
+ if (closed) return;
614
+ const speaking = mode === 'speaking';
615
+ opts.onSpeakingChange?.(speaking);
616
+ // bargeIn opts out of half-duplex (B wave): the mic stays open and
617
+ // ElevenLabs' native interruption handling takes over.
618
+ if (opts.bargeIn === true) return;
619
+ if (speaking) {
620
+ if (micUnmuteTimer) {
621
+ clearTimeout(micUnmuteTimer);
622
+ micUnmuteTimer = null;
623
+ }
624
+ setConvMicMuted(true);
625
+ } else {
626
+ if (micUnmuteTimer) clearTimeout(micUnmuteTimer);
627
+ micUnmuteTimer = setTimeout(() => {
628
+ micUnmuteTimer = null;
629
+ if (!closed) setConvMicMuted(false);
630
+ }, MIC_UNMUTE_TAIL_MS);
631
+ }
632
+ },
633
+ onMessage: ({ message, role }: { message: string; role: string }) => {
634
+ const text = (message ?? '').trim();
635
+ if (!closed && text) {
636
+ opts.onTranscript?.({ role: role === 'agent' ? 'assistant' : 'user', text });
637
+ }
638
+ },
639
+ onError: (message: string) => {
640
+ if (closed) return;
641
+ // End the session (releases the EL SDK's mic + hidden <audio>) before
642
+ // surfacing the error — a silent drop otherwise leaves the mic live
643
+ // because close() depends on the embedder calling it. closed=true first
644
+ // prevents re-entry via the endSession→onDisconnect path.
645
+ closed = true;
646
+ try {
647
+ void conv.endSession().catch(() => {});
648
+ } catch {
649
+ /* session not yet established */
650
+ }
651
+ opts.onError?.(new Error(message));
652
+ },
653
+ onDisconnect: () => {
654
+ if (closed) return;
655
+ closed = true;
656
+ try {
657
+ void conv.endSession().catch(() => {});
658
+ } catch {
659
+ /* session not yet established */
660
+ }
661
+ opts.onError?.(new Error('call disconnected'));
662
+ },
663
+ // Liveness backstop (first-party F3): a transport wedge can flip status to
664
+ // 'disconnected' WITHOUT firing onDisconnect, leaving "connected" with a hot
665
+ // mic forever. Treat a disconnected status the same as onDisconnect.
666
+ onStatusChange: ({ status }: { status: string }) => {
667
+ if (closed || status !== 'disconnected') return;
668
+ closed = true;
669
+ try {
670
+ void conv.endSession().catch(() => {});
671
+ } catch {
672
+ /* session not yet established */
673
+ }
674
+ opts.onError?.(new Error('call disconnected'));
675
+ }
676
+ });
677
+
678
+ return {
679
+ provider: 'elevenlabs-convai',
680
+ close: () => {
681
+ if (closed) return;
682
+ closed = true;
683
+ if (micUnmuteTimer) {
684
+ clearTimeout(micUnmuteTimer);
685
+ micUnmuteTimer = null;
686
+ }
687
+ void conv.endSession().catch(() => {});
688
+ },
689
+ injectEvent: (text, speak = false) => {
690
+ if (closed || !text.trim()) return;
691
+ try {
692
+ if (speak) conv.sendUserMessage(text);
693
+ else conv.sendContextualUpdate(text);
694
+ } catch {
695
+ /* channel not ready */
696
+ }
697
+ }
698
+ };
699
+ }