@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/LICENSE +21 -0
- package/README.md +465 -0
- package/dist/index.cjs +870 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +580 -0
- package/dist/index.d.ts +580 -0
- package/dist/index.js +856 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -0
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
|