@voxdiscover/voiceserver 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,870 @@
1
+ 'use strict';
2
+
3
+ var DailyIframe = require('@daily-co/daily-js');
4
+ var EventEmitter = require('eventemitter3');
5
+ var exponentialBackoff = require('exponential-backoff');
6
+
7
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
8
+
9
+ var DailyIframe__default = /*#__PURE__*/_interopDefault(DailyIframe);
10
+ var EventEmitter__default = /*#__PURE__*/_interopDefault(EventEmitter);
11
+
12
+ // src/VoiceAgent.ts
13
+
14
+ // src/utils/validation.ts
15
+ function decodeSessionToken(token) {
16
+ const parts = token.split(".");
17
+ if (parts.length !== 3) {
18
+ throw new Error("Malformed session token");
19
+ }
20
+ try {
21
+ const payload = JSON.parse(atob(parts[1]));
22
+ if (payload.exp && payload.exp * 1e3 < Date.now()) {
23
+ throw new Error("Session token expired");
24
+ }
25
+ return payload;
26
+ } catch (err) {
27
+ if (err instanceof Error && err.message.includes("expired")) {
28
+ throw err;
29
+ }
30
+ throw new Error("Failed to decode session token");
31
+ }
32
+ }
33
+ async function validateToken(sessionId, baseUrl = "http://localhost:8000") {
34
+ const response = await fetch(`${baseUrl}/v1/sessions/${sessionId}`);
35
+ if (!response.ok) {
36
+ if (response.status === 404) {
37
+ throw new Error("Session not found or expired");
38
+ }
39
+ throw new Error(`Failed to validate session: ${response.statusText}`);
40
+ }
41
+ const session = await response.json();
42
+ if (session.status !== "active") {
43
+ throw new Error("Session is no longer active");
44
+ }
45
+ }
46
+ var ReconnectionManager = class {
47
+ maxRetries;
48
+ initialDelayMs;
49
+ maxDelayMs;
50
+ constructor(config) {
51
+ this.maxRetries = config?.maxRetries ?? 5;
52
+ this.initialDelayMs = config?.initialDelayMs ?? 1e3;
53
+ this.maxDelayMs = config?.maxDelayMs ?? 3e4;
54
+ }
55
+ /**
56
+ * Execute connection function with exponential backoff retry logic.
57
+ * Adds jitter to prevent thundering herd problem.
58
+ */
59
+ async reconnectWithBackoff(connectFn, onRetry) {
60
+ return exponentialBackoff.backOff(connectFn, {
61
+ numOfAttempts: this.maxRetries,
62
+ startingDelay: this.initialDelayMs,
63
+ maxDelay: this.maxDelayMs,
64
+ jitter: "full",
65
+ // Full jitter to prevent simultaneous retries
66
+ retry: (err, attempt) => {
67
+ if (err?.message?.includes("token") || err?.message?.includes("session")) {
68
+ return false;
69
+ }
70
+ const delay = Math.min(
71
+ this.initialDelayMs * Math.pow(2, attempt - 1),
72
+ this.maxDelayMs
73
+ );
74
+ onRetry?.(attempt, delay);
75
+ return true;
76
+ }
77
+ });
78
+ }
79
+ };
80
+
81
+ // src/errors.ts
82
+ var VoiceAgentError = class _VoiceAgentError extends Error {
83
+ code;
84
+ cause;
85
+ context;
86
+ retryable;
87
+ constructor(message, code, options) {
88
+ super(message);
89
+ this.name = "VoiceAgentError";
90
+ this.code = code;
91
+ this.cause = options?.cause;
92
+ this.context = options?.context;
93
+ this.retryable = options?.retryable ?? false;
94
+ if (Error.captureStackTrace) {
95
+ Error.captureStackTrace(this, _VoiceAgentError);
96
+ }
97
+ Object.setPrototypeOf(this, _VoiceAgentError.prototype);
98
+ }
99
+ };
100
+ var TokenExpiredError = class _TokenExpiredError extends VoiceAgentError {
101
+ constructor(message = "Session token has expired", cause) {
102
+ super(message, "TOKEN_EXPIRED", {
103
+ cause,
104
+ retryable: false,
105
+ context: {
106
+ suggestion: "Request a new session token from your backend"
107
+ }
108
+ });
109
+ this.name = "TokenExpiredError";
110
+ Object.setPrototypeOf(this, _TokenExpiredError.prototype);
111
+ }
112
+ };
113
+ var TokenInvalidError = class _TokenInvalidError extends VoiceAgentError {
114
+ constructor(message = "Session token is invalid", cause) {
115
+ super(message, "TOKEN_INVALID", {
116
+ cause,
117
+ retryable: false,
118
+ context: {
119
+ suggestion: "Verify session token format and request a new token if needed"
120
+ }
121
+ });
122
+ this.name = "TokenInvalidError";
123
+ Object.setPrototypeOf(this, _TokenInvalidError.prototype);
124
+ }
125
+ };
126
+ var ConnectionFailedError = class _ConnectionFailedError extends VoiceAgentError {
127
+ constructor(message, cause) {
128
+ super(message, "CONNECTION_FAILED", {
129
+ cause,
130
+ retryable: true,
131
+ context: {
132
+ suggestion: "Check network connection and retry"
133
+ }
134
+ });
135
+ this.name = "ConnectionFailedError";
136
+ Object.setPrototypeOf(this, _ConnectionFailedError.prototype);
137
+ }
138
+ };
139
+ var PermissionDeniedError = class _PermissionDeniedError extends VoiceAgentError {
140
+ constructor(permission) {
141
+ super(
142
+ `${permission} permission denied by user`,
143
+ "PERMISSION_DENIED",
144
+ {
145
+ retryable: false,
146
+ context: {
147
+ permission,
148
+ suggestion: `Grant ${permission} permission in browser settings and reload`
149
+ }
150
+ }
151
+ );
152
+ this.name = "PermissionDeniedError";
153
+ Object.setPrototypeOf(this, _PermissionDeniedError.prototype);
154
+ }
155
+ };
156
+ var NetworkError = class _NetworkError extends VoiceAgentError {
157
+ constructor(message, cause) {
158
+ super(message, "NETWORK_ERROR", {
159
+ cause,
160
+ retryable: true,
161
+ context: {
162
+ suggestion: "Check internet connection and retry"
163
+ }
164
+ });
165
+ this.name = "NetworkError";
166
+ Object.setPrototypeOf(this, _NetworkError.prototype);
167
+ }
168
+ };
169
+
170
+ // src/VoiceAgent.ts
171
+ var VoiceAgent = class extends EventEmitter__default.default {
172
+ config;
173
+ sessionData = null;
174
+ dailyCall = null;
175
+ _state = "disconnected";
176
+ reconnectionManager;
177
+ /**
178
+ * Audio elements for remote participants.
179
+ * Daily.js createCallObject() does not auto-play remote audio — we must
180
+ * create <audio> elements ourselves in the track-started handler.
181
+ */
182
+ remoteAudioElements = /* @__PURE__ */ new Map();
183
+ /**
184
+ * Cumulative cost breakdown for the current session.
185
+ * Per RESEARCH.md Pattern 4: appended on each cost-update app-message.
186
+ * Reset to [] on disconnect() for clean state on reconnection.
187
+ */
188
+ costBreakdown = [];
189
+ /**
190
+ * Current agent ID tracked for duplicate-swap guard.
191
+ * Initialized from session token on connect().
192
+ */
193
+ currentAgentId = null;
194
+ /**
195
+ * Registered analytics callbacks.
196
+ * Per RESEARCH.md Pattern 5: observer pattern for lifecycle and error events.
197
+ */
198
+ analyticsCallbacks = [];
199
+ /**
200
+ * Circuit breaker counter for analytics to prevent infinite loops.
201
+ * Per RESEARCH.md Pitfall #5: if same event emitted >10 times/second, disable analytics.
202
+ */
203
+ analyticsCallCount = 0;
204
+ analyticsCallResetTimer = null;
205
+ analyticsDisabled = false;
206
+ /**
207
+ * Optional mem0 memory client for client-side conversation memory.
208
+ * Initialized from config.mem0ApiKey via dynamic import (optional peer dependency).
209
+ * Per RESEARCH.md Pattern 3: client-side pattern syncs on session end.
210
+ */
211
+ memoryClient = null;
212
+ /**
213
+ * Transcript history for mem0 sync on session end.
214
+ * Accumulates final transcripts as {role, content} pairs.
215
+ * Reset on disconnect() for clean state on reconnection.
216
+ */
217
+ transcriptHistory = [];
218
+ /**
219
+ * Create VoiceAgent instance.
220
+ * Per user decision: constructor pattern, minimal config (only token required).
221
+ */
222
+ constructor(config) {
223
+ super();
224
+ this.config = {
225
+ baseUrl: config.baseUrl ?? "http://localhost:8000",
226
+ reconnection: {
227
+ enabled: config.reconnection?.enabled ?? true,
228
+ maxAttempts: config.reconnection?.maxAttempts ?? 5
229
+ },
230
+ ...config
231
+ };
232
+ this.reconnectionManager = new ReconnectionManager({
233
+ maxRetries: this.config.reconnection?.maxAttempts
234
+ });
235
+ if (this.config.mem0ApiKey) {
236
+ this.initMemoryClient().catch((err) => {
237
+ console.warn("[VoiceAgent] mem0 initialization failed:", err);
238
+ });
239
+ }
240
+ }
241
+ /**
242
+ * Get current connection state.
243
+ * Per user decision: read-only state property.
244
+ */
245
+ get state() {
246
+ return this._state;
247
+ }
248
+ /**
249
+ * Register an analytics callback for lifecycle and error events.
250
+ *
251
+ * Emits: session_started, session_ended, connection_failed, and error events.
252
+ *
253
+ * IMPORTANT: Analytics callbacks MUST be read-only. Do NOT call SDK methods
254
+ * (connect, disconnect, mute, etc.) inside a callback. Doing so will trigger
255
+ * the circuit breaker and disable analytics for the remainder of the session.
256
+ * See RESEARCH.md Pitfall #5.
257
+ *
258
+ * @param callback Function called with each analytics event
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * agent.onAnalyticsEvent((event) => {
263
+ * // Integrate with Segment, DataDog, PostHog, etc.
264
+ * analytics.track(event.eventType, {
265
+ * session_id: event.sessionId,
266
+ * user_id: event.userId,
267
+ * });
268
+ * });
269
+ * ```
270
+ */
271
+ onAnalyticsEvent(callback) {
272
+ this.analyticsCallbacks.push(callback);
273
+ }
274
+ /**
275
+ * Search user memories from mem0 for context-aware responses.
276
+ *
277
+ * Requires mem0ApiKey and userId in config. Returns empty array if mem0
278
+ * is not configured or an error occurs.
279
+ *
280
+ * @param query Search query (e.g., "user preferences", "previous orders")
281
+ * @param limit Maximum number of results (default: 5)
282
+ * @returns Array of memory objects with 'memory' and 'score' fields
283
+ *
284
+ * @example
285
+ * ```typescript
286
+ * const memories = await agent.searchMemories('user preferences', 3);
287
+ * memories.forEach(m => console.log(m.memory));
288
+ * ```
289
+ */
290
+ async searchMemories(query, limit = 5) {
291
+ if (!this.memoryClient || !this.config.userId) {
292
+ return [];
293
+ }
294
+ try {
295
+ const result = await this.memoryClient.search(query, {
296
+ user_id: this.config.userId,
297
+ limit
298
+ });
299
+ return Array.isArray(result) ? result : result?.results ?? [];
300
+ } catch (err) {
301
+ console.error("[VoiceAgent] mem0 search failed:", err);
302
+ return [];
303
+ }
304
+ }
305
+ /**
306
+ * Initialize optional mem0 memory client via dynamic import.
307
+ * Uses dynamic import so missing mem0ai package is a soft warning, not an error.
308
+ * Per RESEARCH.md Pattern 3 (Client-side - Web SDK).
309
+ */
310
+ async initMemoryClient() {
311
+ try {
312
+ const { MemoryClient } = await import('mem0ai');
313
+ this.memoryClient = new MemoryClient({ apiKey: this.config.mem0ApiKey });
314
+ } catch {
315
+ console.warn(
316
+ "[VoiceAgent] mem0ai package not found. Install with: npm install mem0ai\nSee: https://docs.mem0.ai/platform/quickstart"
317
+ );
318
+ }
319
+ }
320
+ /**
321
+ * Emit analytics event to all registered callbacks.
322
+ * Per RESEARCH.md Pattern 5: try/catch per callback prevents analytics errors from breaking SDK.
323
+ * Per RESEARCH.md Pitfall #5: circuit breaker disables analytics on excessive calls.
324
+ */
325
+ emitAnalyticsEvent(event) {
326
+ if (this.analyticsDisabled || this.analyticsCallbacks.length === 0) {
327
+ return;
328
+ }
329
+ this.analyticsCallCount++;
330
+ if (this.analyticsCallResetTimer === null) {
331
+ this.analyticsCallResetTimer = setTimeout(() => {
332
+ this.analyticsCallCount = 0;
333
+ this.analyticsCallResetTimer = null;
334
+ }, 1e3);
335
+ }
336
+ if (this.analyticsCallCount > 10) {
337
+ this.analyticsDisabled = true;
338
+ console.error(
339
+ "[VoiceAgent] Analytics disabled: >10 events emitted in 1 second. Ensure analytics callbacks are read-only and do not call SDK methods."
340
+ );
341
+ return;
342
+ }
343
+ for (const callback of this.analyticsCallbacks) {
344
+ try {
345
+ callback(event);
346
+ } catch (err) {
347
+ console.error("[VoiceAgent] Analytics callback error:", err);
348
+ }
349
+ }
350
+ }
351
+ /**
352
+ * Connect to voice session.
353
+ * Per user decision: explicit connect(), async validation before connect.
354
+ */
355
+ async connect() {
356
+ if (this._state !== "disconnected") {
357
+ throw new Error("Already connected or connecting");
358
+ }
359
+ this.setState("connecting");
360
+ try {
361
+ this.sessionData = decodeSessionToken(this.config.token);
362
+ await validateToken(this.sessionData.session_id, this.config.baseUrl);
363
+ this.dailyCall = DailyIframe__default.default.createCallObject({
364
+ audioSource: true,
365
+ videoSource: false,
366
+ dailyConfig: {
367
+ avoidEval: true
368
+ // CSP-friendly
369
+ }
370
+ });
371
+ this.setupDailyEventListeners();
372
+ await this.dailyCall.join({ url: this.sessionData.room_url, token: this.sessionData.daily_token });
373
+ } catch (err) {
374
+ this.setState("failed");
375
+ if (err instanceof Error) {
376
+ let typedError;
377
+ if (err.message.includes("expired")) {
378
+ typedError = new TokenExpiredError(err.message, err);
379
+ } else if (err.message.includes("Malformed") || err.message.includes("decode")) {
380
+ typedError = new TokenInvalidError(err.message, err);
381
+ } else if (err.message.includes("Session not found")) {
382
+ typedError = new TokenExpiredError("Session not found or expired", err);
383
+ } else if (err.message.includes("validate")) {
384
+ typedError = new NetworkError(`Failed to validate session: ${err.message}`, err);
385
+ } else {
386
+ typedError = new ConnectionFailedError(err.message, err);
387
+ }
388
+ this.emit("connection:error", typedError);
389
+ throw typedError;
390
+ }
391
+ const error = new ConnectionFailedError(String(err));
392
+ this.emit("connection:error", error);
393
+ throw error;
394
+ }
395
+ }
396
+ /**
397
+ * Disconnect from voice session.
398
+ * Per user decision: full cleanup (leave + destroy + remove listeners).
399
+ * Per RESEARCH.md Pitfall 2: Both leave() and destroy() required.
400
+ */
401
+ async disconnect() {
402
+ if (!this.dailyCall) {
403
+ return;
404
+ }
405
+ const sessionDataSnapshot = this.sessionData;
406
+ try {
407
+ await this.dailyCall.leave();
408
+ await this.dailyCall.destroy();
409
+ } catch (err) {
410
+ console.error("Disconnect error:", err);
411
+ } finally {
412
+ if (this.memoryClient && this.config.userId && this.transcriptHistory.length > 0) {
413
+ try {
414
+ await this.memoryClient.add(this.transcriptHistory, { user_id: this.config.userId });
415
+ } catch (err) {
416
+ console.error("[VoiceAgent] mem0 memory sync failed (non-fatal):", err);
417
+ }
418
+ }
419
+ for (const audio of this.remoteAudioElements.values()) {
420
+ audio.pause();
421
+ audio.srcObject = null;
422
+ }
423
+ this.remoteAudioElements.clear();
424
+ this.dailyCall = null;
425
+ this.sessionData = null;
426
+ this.costBreakdown = [];
427
+ this.transcriptHistory = [];
428
+ this.setState("disconnected");
429
+ this.emitAnalyticsEvent({
430
+ timestamp: Date.now(),
431
+ eventType: "session_ended",
432
+ sessionId: sessionDataSnapshot?.session_id ?? "unknown",
433
+ agentId: sessionDataSnapshot?.agent_id,
434
+ userId: sessionDataSnapshot?.user_id
435
+ });
436
+ }
437
+ }
438
+ /**
439
+ * Mute microphone.
440
+ */
441
+ mute() {
442
+ if (!this.dailyCall) {
443
+ throw new Error("Not connected");
444
+ }
445
+ this.dailyCall.setLocalAudio(false);
446
+ this.emit("audio:muted");
447
+ }
448
+ /**
449
+ * Unmute microphone.
450
+ */
451
+ unmute() {
452
+ if (!this.dailyCall) {
453
+ throw new Error("Not connected");
454
+ }
455
+ this.dailyCall.setLocalAudio(true);
456
+ this.emit("audio:unmuted");
457
+ }
458
+ /**
459
+ * Update session context mid-session.
460
+ *
461
+ * Per Phase 15 ADV-02: allows developer to update user_id, customer_id,
462
+ * session_metadata, and custom JSON during an active session.
463
+ *
464
+ * Validates context client-side (10KB size, 50 fields) with warnings.
465
+ * POSTs to /v1/sessions/{session_id}/context endpoint.
466
+ * Emits 'context:updated' event on success.
467
+ *
468
+ * @param contextUpdates Partial context to merge into existing session context
469
+ * @throws Error if no active session
470
+ * @throws Error if the context update API call fails
471
+ *
472
+ * @example
473
+ * ```typescript
474
+ * await agent.updateContext({
475
+ * user_id: 'user_123',
476
+ * custom: { preferred_language: 'Spanish', loyalty_tier: 'gold' }
477
+ * });
478
+ * ```
479
+ */
480
+ async updateContext(contextUpdates) {
481
+ if (!this.sessionData) {
482
+ throw new Error("Cannot update context: no active session");
483
+ }
484
+ this.validateContext(contextUpdates);
485
+ const response = await fetch(
486
+ `${this.config.baseUrl}/v1/sessions/${this.sessionData.session_id}/context`,
487
+ {
488
+ method: "POST",
489
+ headers: {
490
+ "Content-Type": "application/json",
491
+ "Authorization": `Bearer ${this.config.token}`
492
+ },
493
+ body: JSON.stringify({ context: contextUpdates })
494
+ }
495
+ );
496
+ if (!response.ok) {
497
+ throw new Error(
498
+ `Context update failed: ${response.status} ${response.statusText}`
499
+ );
500
+ }
501
+ const updateData = {
502
+ updates: contextUpdates,
503
+ timestamp: Date.now()
504
+ };
505
+ this.emit("context:updated", updateData);
506
+ }
507
+ /**
508
+ * Validate context client-side for size (10KB) and field count (50 max).
509
+ * Validation is permissive: warns but does not throw on limits exceeded.
510
+ * Per user decision: don't break session on validation warning.
511
+ *
512
+ * @param context Context object to validate
513
+ */
514
+ validateContext(context) {
515
+ const serialized = JSON.stringify(context);
516
+ const sizeBytes = new Blob([serialized]).size;
517
+ if (sizeBytes > 10240) {
518
+ console.warn(
519
+ `[VoiceAgent] Context size (${sizeBytes} bytes) exceeds 10KB limit. Backend will truncate custom fields.`
520
+ );
521
+ }
522
+ const customFieldCount = Object.keys(context.custom ?? {}).length;
523
+ if (customFieldCount > 50) {
524
+ console.warn(
525
+ `[VoiceAgent] Custom context has ${customFieldCount} fields (limit: 50). Backend will truncate to first 50 fields.`
526
+ );
527
+ }
528
+ }
529
+ /**
530
+ * Switch agent mid-session without disconnecting WebRTC.
531
+ *
532
+ * Per Phase 15 ADV-01: hot-swap the backend bot while keeping the Daily room
533
+ * connection alive. The new agent receives full conversation context and
534
+ * transcript history.
535
+ *
536
+ * Protocol:
537
+ * 1. Validate connected and not already using newAgentId
538
+ * 2. Emit agent:swapping event (for analytics / UI loading indicator)
539
+ * 3. Send Daily app-message to backend bot requesting swap
540
+ * 4. Wait for agent-swap-complete confirmation (with timeout)
541
+ * 5. Update currentAgentId and emit agent:swapped on success
542
+ * 6. Emit agent:swap-failed and throw on timeout or backend error
543
+ *
544
+ * @param newAgentId UUID of the agent to swap to
545
+ * @param options Swap options (preserveContext, preserveTranscripts, timeout)
546
+ * @throws Error if not connected, already using newAgentId, or swap fails/times out
547
+ *
548
+ * @example
549
+ * ```typescript
550
+ * try {
551
+ * await agent.switchAgent('specialist-agent-uuid');
552
+ * console.log('Agent switched successfully');
553
+ * } catch (err) {
554
+ * console.error('Swap failed:', err);
555
+ * // Old agent is still active (resilient fallback)
556
+ * }
557
+ * ```
558
+ */
559
+ async switchAgent(newAgentId, options = {}) {
560
+ const {
561
+ preserveContext = true,
562
+ preserveTranscripts = true,
563
+ timeout = 5e3
564
+ } = options;
565
+ if (!this.dailyCall || this._state !== "connected") {
566
+ throw new Error("Cannot switch agent: not connected");
567
+ }
568
+ if (this.currentAgentId === newAgentId) {
569
+ throw new Error("Already using this agent");
570
+ }
571
+ const timestamp = Date.now();
572
+ const sessionId = this.sessionData?.session_id ?? "unknown";
573
+ this.emit("agent:swapping", { newAgentId, timestamp });
574
+ this.emitAnalyticsEvent({
575
+ timestamp,
576
+ eventType: "agent_swap_completed",
577
+ // will be overridden on failure
578
+ sessionId,
579
+ agentId: newAgentId
580
+ });
581
+ try {
582
+ this.dailyCall.sendAppMessage({
583
+ type: "agent-swap-request",
584
+ new_agent_id: newAgentId,
585
+ preserve_context: preserveContext,
586
+ preserve_transcripts: preserveTranscripts
587
+ }, "*");
588
+ await this.waitForSwapConfirmation(newAgentId, timeout);
589
+ this.currentAgentId = newAgentId;
590
+ this.emit("agent:swapped", { newAgentId, timestamp: Date.now() });
591
+ this.emitAnalyticsEvent({
592
+ timestamp: Date.now(),
593
+ eventType: "agent_swap_completed",
594
+ sessionId,
595
+ agentId: newAgentId
596
+ });
597
+ } catch (err) {
598
+ const errorMessage = err instanceof Error ? err.message : String(err);
599
+ this.emit("agent:swap-failed", { newAgentId, timestamp: Date.now(), error: errorMessage });
600
+ this.emitAnalyticsEvent({
601
+ timestamp: Date.now(),
602
+ eventType: "agent_swap_failed",
603
+ sessionId,
604
+ agentId: newAgentId,
605
+ error: {
606
+ code: "AGENT_SWAP_ERROR",
607
+ message: errorMessage,
608
+ retryable: true
609
+ }
610
+ });
611
+ throw new Error(`Agent swap failed: ${errorMessage}`);
612
+ }
613
+ }
614
+ /**
615
+ * Wait for backend agent-swap-complete confirmation via Daily app-message.
616
+ *
617
+ * Resolves when agent-swap-complete is received with matching agent_id.
618
+ * Rejects on agent-swap-failed message or timeout.
619
+ *
620
+ * @param agentId Expected new agent ID in confirmation
621
+ * @param timeoutMs Milliseconds before timing out
622
+ */
623
+ waitForSwapConfirmation(agentId, timeoutMs) {
624
+ return new Promise((resolve, reject) => {
625
+ const timer = setTimeout(() => {
626
+ cleanup();
627
+ reject(new Error("Agent swap timeout \u2014 new agent failed to connect"));
628
+ }, timeoutMs);
629
+ const handler = (event) => {
630
+ const data = event?.data;
631
+ if (!data) return;
632
+ if (data.type === "agent-swap-complete" && data.agent_id === agentId) {
633
+ cleanup();
634
+ resolve();
635
+ } else if (data.type === "agent-swap-failed") {
636
+ cleanup();
637
+ reject(new Error(data.error || "Backend agent swap failed"));
638
+ }
639
+ };
640
+ const cleanup = () => {
641
+ clearTimeout(timer);
642
+ this.dailyCall?.off("app-message", handler);
643
+ };
644
+ this.dailyCall?.on("app-message", handler);
645
+ });
646
+ }
647
+ /**
648
+ * Subscribe to real-time cost updates for the session.
649
+ *
650
+ * Registers a callback that is called after each provider API call (STT, LLM, TTS)
651
+ * with the cumulative cost summary including a breakdown by provider and service type.
652
+ *
653
+ * Per RESEARCH.md Pattern 4: Backend streams cost events; SDK aggregates and exposes
654
+ * via this callback for developer integration (budget alerts, user-facing displays).
655
+ *
656
+ * @param callback Function called with CostSummary on each cost update
657
+ *
658
+ * @example
659
+ * ```typescript
660
+ * agent.onCostUpdate((summary) => {
661
+ * console.log(`Session cost: $${summary.totalUsd.toFixed(4)}`);
662
+ * console.log('By provider:', summary.byProvider);
663
+ * console.log('By service:', summary.byServiceType);
664
+ * });
665
+ * ```
666
+ */
667
+ onCostUpdate(callback) {
668
+ this.on("cost:update", callback);
669
+ }
670
+ /**
671
+ * Get the current cumulative cost summary for the session.
672
+ *
673
+ * Returns a snapshot of all costs incurred so far, aggregated by provider
674
+ * and service type. Returns empty summary if no costs have been tracked.
675
+ *
676
+ * @returns CostSummary with totalUsd, breakdown array, byProvider, and byServiceType
677
+ */
678
+ getCostSummary() {
679
+ const totalUsd = this.costBreakdown.reduce((sum, b) => sum + b.costUsd, 0);
680
+ const byProvider = {};
681
+ const byServiceType = {};
682
+ for (const b of this.costBreakdown) {
683
+ byProvider[b.provider] = (byProvider[b.provider] ?? 0) + b.costUsd;
684
+ byServiceType[b.serviceType] = (byServiceType[b.serviceType] ?? 0) + b.costUsd;
685
+ }
686
+ return {
687
+ totalUsd,
688
+ breakdown: [...this.costBreakdown],
689
+ byProvider,
690
+ byServiceType
691
+ };
692
+ }
693
+ /**
694
+ * Set up cost tracking subscription on the Daily call object.
695
+ * Per RESEARCH.md Pattern 4: subscribe to cost-update app-messages from backend.
696
+ * Called from setupDailyEventListeners() after Daily call object is created.
697
+ */
698
+ setupCostTracking() {
699
+ if (!this.dailyCall) return;
700
+ this.dailyCall.on("app-message", this.handleCostUpdate);
701
+ }
702
+ /**
703
+ * Handle cost-update app-message from backend bot.
704
+ * Parses cost event, appends to costBreakdown, and emits cost:update event.
705
+ * Per RESEARCH.md Pattern 4: cost events contain provider, service_type, model, cost_usd.
706
+ */
707
+ handleCostUpdate = (event) => {
708
+ const data = event?.data;
709
+ if (!data || data.type !== "cost-update") {
710
+ return;
711
+ }
712
+ const { provider, service_type, model, cost_usd, timestamp } = data;
713
+ if (!provider || !service_type || !model || typeof cost_usd !== "number") {
714
+ return;
715
+ }
716
+ const breakdown = {
717
+ provider,
718
+ serviceType: service_type,
719
+ model,
720
+ costUsd: cost_usd,
721
+ timestamp: timestamp ?? Date.now()
722
+ };
723
+ this.costBreakdown.push(breakdown);
724
+ this.emit("cost:update", this.getCostSummary());
725
+ };
726
+ setState(newState) {
727
+ this._state = newState;
728
+ this.emit("connection:state", newState);
729
+ }
730
+ setupDailyEventListeners() {
731
+ if (!this.dailyCall) return;
732
+ this.dailyCall.on("joined-meeting", this.handleJoined).on("left-meeting", this.handleLeft).on("participant-joined", this.handleParticipantJoined).on("participant-left", this.handleParticipantLeft).on("track-started", this.handleTrackStarted).on("track-stopped", this.handleTrackStopped).on("error", this.handleDailyError).on("app-message", this.handleTranscript);
733
+ this.setupCostTracking();
734
+ }
735
+ handleJoined = () => {
736
+ this.setState("connected");
737
+ this.currentAgentId = this.sessionData?.agent_id ?? null;
738
+ this.emitAnalyticsEvent({
739
+ timestamp: Date.now(),
740
+ eventType: "session_started",
741
+ sessionId: this.sessionData?.session_id ?? "unknown",
742
+ agentId: this.sessionData?.agent_id,
743
+ userId: this.sessionData?.user_id
744
+ });
745
+ };
746
+ handleLeft = () => {
747
+ this.setState("disconnected");
748
+ };
749
+ handleParticipantJoined = () => {
750
+ };
751
+ handleParticipantLeft = (event) => {
752
+ const participantId = event?.participant?.session_id;
753
+ if (!participantId) return;
754
+ const audio = this.remoteAudioElements.get(participantId);
755
+ if (audio) {
756
+ audio.pause();
757
+ audio.srcObject = null;
758
+ this.remoteAudioElements.delete(participantId);
759
+ }
760
+ };
761
+ /**
762
+ * Create and play an <audio> element when a remote participant's audio track starts.
763
+ * Daily.js createCallObject() does NOT auto-play remote audio in headless mode —
764
+ * the app must handle track-started and wire the track to an audio element.
765
+ */
766
+ handleTrackStarted = (event) => {
767
+ const { participant, track, type } = event;
768
+ if (participant?.local || type !== "audio" || !track) return;
769
+ const participantId = participant.session_id;
770
+ let audio = this.remoteAudioElements.get(participantId);
771
+ if (!audio) {
772
+ audio = new Audio();
773
+ this.remoteAudioElements.set(participantId, audio);
774
+ }
775
+ audio.srcObject = new MediaStream([track]);
776
+ audio.play().catch((err) => {
777
+ console.warn("[VoiceAgent] Remote audio playback failed:", err);
778
+ });
779
+ };
780
+ handleTrackStopped = (event) => {
781
+ const { participant, type } = event;
782
+ if (participant?.local || type !== "audio") return;
783
+ const participantId = participant.session_id;
784
+ const audio = this.remoteAudioElements.get(participantId);
785
+ if (audio) {
786
+ audio.pause();
787
+ audio.srcObject = null;
788
+ this.remoteAudioElements.delete(participantId);
789
+ }
790
+ };
791
+ handleDailyError = (error) => {
792
+ const errorMsg = error?.errorMsg || "Unknown Daily.js error";
793
+ let typedError;
794
+ if (errorMsg.includes("token") || errorMsg.includes("expired")) {
795
+ typedError = new TokenExpiredError("Daily token invalid or expired");
796
+ } else if (errorMsg.includes("permission") || errorMsg.includes("microphone")) {
797
+ typedError = new PermissionDeniedError("microphone");
798
+ } else if (errorMsg.includes("network") || errorMsg.includes("connection")) {
799
+ typedError = new ConnectionFailedError(errorMsg);
800
+ } else {
801
+ typedError = new ConnectionFailedError(errorMsg);
802
+ }
803
+ this.emit("connection:error", typedError);
804
+ this.emitAnalyticsEvent({
805
+ timestamp: Date.now(),
806
+ eventType: "connection_failed",
807
+ sessionId: this.sessionData?.session_id ?? "unknown",
808
+ agentId: this.sessionData?.agent_id,
809
+ userId: this.sessionData?.user_id,
810
+ error: {
811
+ code: typedError.code,
812
+ message: typedError.message,
813
+ retryable: typedError.retryable
814
+ }
815
+ });
816
+ if (typedError.retryable && this.config.reconnection?.enabled && this._state === "connected") {
817
+ this.attemptReconnect();
818
+ }
819
+ };
820
+ handleTranscript = (event) => {
821
+ const { text, speaker, is_final } = event.data || {};
822
+ if (!text || !speaker) return;
823
+ const transcriptData = {
824
+ text,
825
+ speaker: speaker === "local" ? "user" : "agent",
826
+ timestamp: /* @__PURE__ */ new Date()
827
+ };
828
+ if (is_final) {
829
+ this.transcriptHistory.push({
830
+ role: speaker === "local" ? "user" : "assistant",
831
+ content: text
832
+ });
833
+ this.emit("transcript:final", transcriptData);
834
+ } else {
835
+ this.emit("transcript:interim", transcriptData);
836
+ }
837
+ };
838
+ async attemptReconnect() {
839
+ if (this._state === "reconnecting") return;
840
+ this.setState("reconnecting");
841
+ try {
842
+ await this.reconnectionManager.reconnectWithBackoff(
843
+ async () => {
844
+ if (!this.dailyCall || !this.sessionData) {
845
+ throw new Error("Cannot reconnect: no active session");
846
+ }
847
+ await this.dailyCall.join({ url: this.sessionData.room_url, token: this.sessionData.daily_token });
848
+ },
849
+ (attempt, delay) => {
850
+ console.log(`Reconnection attempt ${attempt}, waiting ${delay}ms`);
851
+ }
852
+ );
853
+ } catch (err) {
854
+ this.setState("failed");
855
+ const error = err instanceof VoiceAgentError ? err : new ConnectionFailedError("Failed to reconnect after multiple attempts", err);
856
+ this.emit("connection:error", error);
857
+ }
858
+ }
859
+ };
860
+
861
+ exports.ConnectionFailedError = ConnectionFailedError;
862
+ exports.NetworkError = NetworkError;
863
+ exports.PermissionDeniedError = PermissionDeniedError;
864
+ exports.ReconnectionManager = ReconnectionManager;
865
+ exports.TokenExpiredError = TokenExpiredError;
866
+ exports.TokenInvalidError = TokenInvalidError;
867
+ exports.VoiceAgent = VoiceAgent;
868
+ exports.VoiceAgentError = VoiceAgentError;
869
+ //# sourceMappingURL=index.cjs.map
870
+ //# sourceMappingURL=index.cjs.map