getpatter 0.3.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.js ADDED
@@ -0,0 +1,4287 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
+
33
+ // src/logger.ts
34
+ function getLogger() {
35
+ return currentLogger;
36
+ }
37
+ function setLogger(logger) {
38
+ currentLogger = logger;
39
+ }
40
+ var defaultLogger, currentLogger;
41
+ var init_logger = __esm({
42
+ "src/logger.ts"() {
43
+ "use strict";
44
+ defaultLogger = {
45
+ info: (msg, ...args) => console.log(`[PATTER] ${msg}`, ...args),
46
+ warn: (msg, ...args) => console.warn(`[PATTER] WARNING: ${msg}`, ...args),
47
+ error: (msg, ...args) => console.error(`[PATTER] ERROR: ${msg}`, ...args),
48
+ debug: () => {
49
+ }
50
+ };
51
+ currentLogger = defaultLogger;
52
+ }
53
+ });
54
+
55
+ // src/llm-loop.ts
56
+ var OpenAILLMProvider, LLMLoop;
57
+ var init_llm_loop = __esm({
58
+ "src/llm-loop.ts"() {
59
+ "use strict";
60
+ init_logger();
61
+ OpenAILLMProvider = class {
62
+ apiKey;
63
+ model;
64
+ constructor(apiKey, model) {
65
+ this.apiKey = apiKey;
66
+ this.model = model;
67
+ }
68
+ async *stream(messages, tools) {
69
+ const body = {
70
+ model: this.model,
71
+ messages,
72
+ stream: true
73
+ };
74
+ if (tools) {
75
+ body.tools = tools;
76
+ }
77
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
78
+ method: "POST",
79
+ headers: {
80
+ "Content-Type": "application/json",
81
+ "Authorization": `Bearer ${this.apiKey}`
82
+ },
83
+ body: JSON.stringify(body)
84
+ });
85
+ if (!response.ok) {
86
+ const errText = await response.text();
87
+ getLogger().error(`LLM API error: ${response.status} ${errText}`);
88
+ return;
89
+ }
90
+ const reader = response.body?.getReader();
91
+ if (!reader) return;
92
+ const decoder = new TextDecoder();
93
+ let buffer = "";
94
+ while (true) {
95
+ const { done, value } = await reader.read();
96
+ if (done) break;
97
+ buffer += decoder.decode(value, { stream: true });
98
+ const lines = buffer.split("\n");
99
+ buffer = lines.pop() || "";
100
+ for (const line of lines) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed || !trimmed.startsWith("data: ")) continue;
103
+ const data = trimmed.slice(6);
104
+ if (data === "[DONE]") continue;
105
+ let chunk;
106
+ try {
107
+ chunk = JSON.parse(data);
108
+ } catch {
109
+ continue;
110
+ }
111
+ const delta = chunk.choices?.[0]?.delta;
112
+ if (!delta) continue;
113
+ if (delta.content) {
114
+ yield { type: "text", content: delta.content };
115
+ }
116
+ if (delta.tool_calls) {
117
+ for (const tc of delta.tool_calls) {
118
+ yield {
119
+ type: "tool_call",
120
+ index: tc.index,
121
+ id: tc.id,
122
+ name: tc.function?.name,
123
+ arguments: tc.function?.arguments
124
+ };
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ };
131
+ LLMLoop = class {
132
+ provider;
133
+ systemPrompt;
134
+ tools;
135
+ openaiTools;
136
+ toolMap;
137
+ constructor(apiKey, model, systemPrompt, tools, llmProvider) {
138
+ this.provider = llmProvider ?? new OpenAILLMProvider(apiKey, model);
139
+ this.systemPrompt = systemPrompt;
140
+ this.tools = tools ?? null;
141
+ this.toolMap = /* @__PURE__ */ new Map();
142
+ this.openaiTools = null;
143
+ if (this.tools && this.tools.length > 0) {
144
+ this.openaiTools = [];
145
+ for (const t of this.tools) {
146
+ this.openaiTools.push({
147
+ type: "function",
148
+ function: {
149
+ name: t.name,
150
+ description: t.description || "",
151
+ parameters: t.parameters || { type: "object", properties: {} }
152
+ }
153
+ });
154
+ this.toolMap.set(t.name, t);
155
+ }
156
+ }
157
+ }
158
+ /**
159
+ * Stream LLM response tokens, handling tool calls automatically.
160
+ * Yields text tokens as they arrive from the LLM.
161
+ */
162
+ async *run(userText, history, callContext) {
163
+ const messages = this.buildMessages(history, userText);
164
+ const maxIterations = 10;
165
+ for (let iter = 0; iter < maxIterations; iter++) {
166
+ const toolCallsAccumulated = /* @__PURE__ */ new Map();
167
+ const textParts = [];
168
+ let hasToolCalls = false;
169
+ for await (const chunk of this.provider.stream(messages, this.openaiTools)) {
170
+ if (chunk.type === "text" && chunk.content) {
171
+ textParts.push(chunk.content);
172
+ yield chunk.content;
173
+ } else if (chunk.type === "tool_call") {
174
+ hasToolCalls = true;
175
+ const idx = chunk.index ?? 0;
176
+ if (!toolCallsAccumulated.has(idx)) {
177
+ toolCallsAccumulated.set(idx, { id: "", name: "", arguments: "" });
178
+ }
179
+ const acc = toolCallsAccumulated.get(idx);
180
+ if (chunk.id) acc.id = chunk.id;
181
+ if (chunk.name) acc.name = chunk.name;
182
+ if (chunk.arguments) acc.arguments += chunk.arguments;
183
+ }
184
+ }
185
+ if (!hasToolCalls) return;
186
+ const assistantMsg = {
187
+ role: "assistant",
188
+ content: textParts.join("") || null,
189
+ tool_calls: []
190
+ };
191
+ const sortedIndices = [...toolCallsAccumulated.keys()].sort((a, b) => a - b);
192
+ for (const idx of sortedIndices) {
193
+ const tc = toolCallsAccumulated.get(idx);
194
+ assistantMsg.tool_calls.push({
195
+ id: tc.id,
196
+ type: "function",
197
+ function: { name: tc.name, arguments: tc.arguments }
198
+ });
199
+ }
200
+ messages.push(assistantMsg);
201
+ for (const tcData of assistantMsg.tool_calls) {
202
+ const toolName = tcData.function.name;
203
+ let args;
204
+ try {
205
+ args = JSON.parse(tcData.function.arguments);
206
+ } catch {
207
+ args = {};
208
+ }
209
+ const result = await this.executeTool(toolName, args, callContext);
210
+ messages.push({
211
+ role: "tool",
212
+ tool_call_id: tcData.id,
213
+ content: result
214
+ });
215
+ }
216
+ }
217
+ getLogger().warn(`LLM loop hit max iterations (${maxIterations})`);
218
+ }
219
+ async executeTool(toolName, args, callContext) {
220
+ const toolDef = this.toolMap.get(toolName);
221
+ if (!toolDef) {
222
+ return JSON.stringify({ error: `Unknown tool: ${toolName}` });
223
+ }
224
+ if (toolDef.handler) {
225
+ try {
226
+ return await toolDef.handler(args, callContext);
227
+ } catch (e) {
228
+ return JSON.stringify({ error: `Tool handler error: ${String(e)}` });
229
+ }
230
+ }
231
+ if (toolDef.webhookUrl) {
232
+ for (let attempt = 0; attempt < 3; attempt++) {
233
+ try {
234
+ const resp = await fetch(toolDef.webhookUrl, {
235
+ method: "POST",
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({
238
+ tool: toolName,
239
+ arguments: args,
240
+ ...callContext,
241
+ attempt: attempt + 1
242
+ }),
243
+ signal: AbortSignal.timeout(1e4)
244
+ });
245
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
246
+ const result = JSON.stringify(await resp.json());
247
+ const MAX_RESPONSE_BYTES2 = 1 * 1024 * 1024;
248
+ if (result.length > MAX_RESPONSE_BYTES2) {
249
+ return JSON.stringify({ error: `Webhook response too large: ${result.length} bytes (max ${MAX_RESPONSE_BYTES2})`, fallback: true });
250
+ }
251
+ return result;
252
+ } catch (e) {
253
+ if (attempt < 2) {
254
+ await new Promise((r) => setTimeout(r, 500));
255
+ } else {
256
+ return JSON.stringify({ error: `Tool failed after 3 attempts: ${String(e)}` });
257
+ }
258
+ }
259
+ }
260
+ }
261
+ return JSON.stringify({ error: `No handler or webhookUrl for tool '${toolName}'` });
262
+ }
263
+ buildMessages(history, userText) {
264
+ const messages = [
265
+ { role: "system", content: this.systemPrompt }
266
+ ];
267
+ for (const entry of history) {
268
+ messages.push({
269
+ role: entry.role === "assistant" ? "assistant" : "user",
270
+ content: entry.text
271
+ });
272
+ }
273
+ messages.push({ role: "user", content: userText });
274
+ return messages;
275
+ }
276
+ };
277
+ }
278
+ });
279
+
280
+ // src/test-mode.ts
281
+ var test_mode_exports = {};
282
+ __export(test_mode_exports, {
283
+ TestSession: () => TestSession
284
+ });
285
+ var import_readline, TestSession;
286
+ var init_test_mode = __esm({
287
+ "src/test-mode.ts"() {
288
+ "use strict";
289
+ import_readline = require("readline");
290
+ init_llm_loop();
291
+ init_logger();
292
+ TestSession = class {
293
+ async run(opts) {
294
+ const { agent, openaiKey, onMessage, onCallStart, onCallEnd } = opts;
295
+ const callId = `test_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
296
+ const caller = "+15550000001";
297
+ const callee = "+15550000002";
298
+ const conversationHistory = [];
299
+ const log = getLogger();
300
+ log.info("");
301
+ log.info("=".repeat(60));
302
+ log.info(" PATTER TEST MODE");
303
+ log.info("=".repeat(60));
304
+ log.info(` Agent: ${agent.model || "default"} / ${agent.voice || "default"}`);
305
+ log.info(` Provider: ${agent.provider || "openai_realtime"}`);
306
+ log.info(` Call ID: ${callId}`);
307
+ log.info(` Caller: ${caller} -> Callee: ${callee}`);
308
+ log.info("-".repeat(60));
309
+ log.info(" Commands: /quit /transfer <number> /hangup /history");
310
+ log.info("=".repeat(60));
311
+ log.info("");
312
+ if (onCallStart) {
313
+ await onCallStart({
314
+ call_id: callId,
315
+ caller,
316
+ callee,
317
+ direction: "test"
318
+ });
319
+ }
320
+ if (agent.firstMessage) {
321
+ log.info(` Agent: ${agent.firstMessage}`);
322
+ log.info("");
323
+ conversationHistory.push({
324
+ role: "assistant",
325
+ text: agent.firstMessage,
326
+ timestamp: Date.now()
327
+ });
328
+ }
329
+ let llmLoop = null;
330
+ if (!onMessage && openaiKey) {
331
+ let llmModel = agent.model || "gpt-4o-mini";
332
+ if (llmModel.includes("realtime")) llmModel = "gpt-4o-mini";
333
+ let resolvedPrompt = agent.systemPrompt;
334
+ if (agent.variables) {
335
+ for (const [k, v] of Object.entries(agent.variables)) {
336
+ resolvedPrompt = resolvedPrompt.replaceAll(`{${k}}`, v);
337
+ }
338
+ }
339
+ llmLoop = new LLMLoop(
340
+ openaiKey,
341
+ llmModel,
342
+ resolvedPrompt,
343
+ agent.tools
344
+ );
345
+ }
346
+ let ended = false;
347
+ const _callControl = {
348
+ callId,
349
+ caller,
350
+ callee,
351
+ transfer: async (number) => {
352
+ ended = true;
353
+ log.info(` [Transfer -> ${number}]`);
354
+ },
355
+ hangup: async () => {
356
+ ended = true;
357
+ log.info(" [Call ended by agent]");
358
+ }
359
+ };
360
+ void _callControl;
361
+ const rl = (0, import_readline.createInterface)({
362
+ input: process.stdin,
363
+ output: process.stdout
364
+ });
365
+ const askQuestion = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
366
+ try {
367
+ while (!ended) {
368
+ let userInput;
369
+ try {
370
+ userInput = await askQuestion(" You: ");
371
+ } catch {
372
+ log.info("\n [Session ended]");
373
+ break;
374
+ }
375
+ userInput = userInput.trim();
376
+ if (!userInput) continue;
377
+ if (userInput === "/quit") {
378
+ log.info(" [Session ended]");
379
+ break;
380
+ } else if (userInput === "/hangup") {
381
+ log.info(" [You hung up]");
382
+ break;
383
+ } else if (userInput.startsWith("/transfer ")) {
384
+ const number = userInput.slice(10).trim();
385
+ log.info(` [Transfer -> ${number}]`);
386
+ break;
387
+ } else if (userInput === "/history") {
388
+ for (const entry of conversationHistory) {
389
+ const role = entry.role.charAt(0).toUpperCase() + entry.role.slice(1);
390
+ log.info(` ${role}: ${entry.text}`);
391
+ }
392
+ continue;
393
+ }
394
+ conversationHistory.push({
395
+ role: "user",
396
+ text: userInput,
397
+ timestamp: Date.now()
398
+ });
399
+ if (onMessage) {
400
+ try {
401
+ const responseText = await onMessage({
402
+ text: userInput,
403
+ call_id: callId,
404
+ caller,
405
+ history: [...conversationHistory]
406
+ });
407
+ if (responseText) {
408
+ log.info(` Agent: ${responseText}`);
409
+ conversationHistory.push({
410
+ role: "assistant",
411
+ text: responseText,
412
+ timestamp: Date.now()
413
+ });
414
+ log.info("");
415
+ }
416
+ } catch (e) {
417
+ log.error(` [Error: ${String(e)}]`);
418
+ }
419
+ } else if (llmLoop) {
420
+ const callCtx = { call_id: callId, caller, callee };
421
+ const parts = [];
422
+ process.stdout.write(" Agent: ");
423
+ for await (const token of llmLoop.run(userInput, conversationHistory, callCtx)) {
424
+ parts.push(token);
425
+ process.stdout.write(token);
426
+ }
427
+ log.info("");
428
+ const responseText = parts.join("");
429
+ if (responseText) {
430
+ conversationHistory.push({
431
+ role: "assistant",
432
+ text: responseText,
433
+ timestamp: Date.now()
434
+ });
435
+ }
436
+ log.info("");
437
+ } else {
438
+ log.info(" [No onMessage handler or LLM loop configured]");
439
+ }
440
+ if (ended) break;
441
+ }
442
+ } finally {
443
+ rl.close();
444
+ }
445
+ if (onCallEnd) {
446
+ await onCallEnd({
447
+ call_id: callId,
448
+ caller,
449
+ callee,
450
+ direction: "test",
451
+ transcript: conversationHistory
452
+ });
453
+ }
454
+ }
455
+ };
456
+ }
457
+ });
458
+
459
+ // src/index.ts
460
+ var index_exports = {};
461
+ __export(index_exports, {
462
+ AuthenticationError: () => AuthenticationError,
463
+ CallMetricsAccumulator: () => CallMetricsAccumulator,
464
+ DEFAULT_PRICING: () => DEFAULT_PRICING,
465
+ DeepgramSTT: () => DeepgramSTT,
466
+ ElevenLabsConvAIAdapter: () => ElevenLabsConvAIAdapter,
467
+ ElevenLabsTTS: () => ElevenLabsTTS,
468
+ LLMLoop: () => LLMLoop,
469
+ MetricsStore: () => MetricsStore,
470
+ OpenAILLMProvider: () => OpenAILLMProvider,
471
+ OpenAIRealtimeAdapter: () => OpenAIRealtimeAdapter,
472
+ OpenAITTS: () => OpenAITTS,
473
+ Patter: () => Patter,
474
+ PatterConnectionError: () => PatterConnectionError,
475
+ PatterError: () => PatterError,
476
+ ProvisionError: () => ProvisionError,
477
+ RemoteMessageHandler: () => RemoteMessageHandler,
478
+ TestSession: () => TestSession,
479
+ WhisperSTT: () => WhisperSTT,
480
+ calculateRealtimeCost: () => calculateRealtimeCost,
481
+ calculateSttCost: () => calculateSttCost,
482
+ calculateTelephonyCost: () => calculateTelephonyCost,
483
+ calculateTtsCost: () => calculateTtsCost,
484
+ callsToCsv: () => callsToCsv,
485
+ callsToJson: () => callsToJson,
486
+ deepgram: () => deepgram,
487
+ elevenlabs: () => elevenlabs,
488
+ getLogger: () => getLogger,
489
+ isRemoteUrl: () => isRemoteUrl,
490
+ isWebSocketUrl: () => isWebSocketUrl,
491
+ makeAuthMiddleware: () => makeAuthMiddleware,
492
+ mergePricing: () => mergePricing,
493
+ mountApi: () => mountApi,
494
+ mountDashboard: () => mountDashboard,
495
+ mulawToPcm16: () => mulawToPcm16,
496
+ openaiTts: () => openaiTts,
497
+ pcm16ToMulaw: () => pcm16ToMulaw,
498
+ resample16kTo8k: () => resample16kTo8k,
499
+ resample24kTo16k: () => resample24kTo16k,
500
+ resample8kTo16k: () => resample8kTo16k,
501
+ setLogger: () => setLogger,
502
+ whisper: () => whisper
503
+ });
504
+ module.exports = __toCommonJS(index_exports);
505
+
506
+ // src/connection.ts
507
+ var import_ws = __toESM(require("ws"));
508
+
509
+ // src/errors.ts
510
+ var PatterError = class extends Error {
511
+ constructor(message) {
512
+ super(message);
513
+ this.name = "PatterError";
514
+ }
515
+ };
516
+ var PatterConnectionError = class extends PatterError {
517
+ constructor(message) {
518
+ super(message);
519
+ this.name = "PatterConnectionError";
520
+ }
521
+ };
522
+ var AuthenticationError = class extends PatterError {
523
+ constructor(message) {
524
+ super(message);
525
+ this.name = "AuthenticationError";
526
+ }
527
+ };
528
+ var ProvisionError = class extends PatterError {
529
+ constructor(message) {
530
+ super(message);
531
+ this.name = "ProvisionError";
532
+ }
533
+ };
534
+
535
+ // src/connection.ts
536
+ init_logger();
537
+ var DEFAULT_BACKEND_URL = "wss://api.getpatter.com";
538
+ var PatterConnection = class {
539
+ apiKey;
540
+ backendUrl;
541
+ wsUrl;
542
+ ws = null;
543
+ onMessage = null;
544
+ onCallStart = null;
545
+ onCallEnd = null;
546
+ constructor(apiKey, backendUrl = DEFAULT_BACKEND_URL) {
547
+ this.apiKey = apiKey;
548
+ this.backendUrl = backendUrl.replace(/\/+$/, "");
549
+ this.wsUrl = `${this.backendUrl}/ws/sdk`;
550
+ }
551
+ get isConnected() {
552
+ return this.ws !== null && this.ws.readyState === import_ws.default.OPEN;
553
+ }
554
+ async connect(options) {
555
+ this.onMessage = options.onMessage;
556
+ this.onCallStart = options.onCallStart ?? null;
557
+ this.onCallEnd = options.onCallEnd ?? null;
558
+ return new Promise((resolve, reject) => {
559
+ this.ws = new import_ws.default(this.wsUrl, {
560
+ headers: { "X-API-Key": this.apiKey }
561
+ });
562
+ this.ws.on("open", () => {
563
+ this.setupListeners();
564
+ resolve();
565
+ });
566
+ this.ws.on("error", (err) => {
567
+ reject(new PatterConnectionError(`Failed to connect: ${err.message}`));
568
+ });
569
+ });
570
+ }
571
+ setupListeners() {
572
+ if (!this.ws) return;
573
+ this.ws.on("error", (err) => {
574
+ getLogger().error(`WebSocket error: ${err.message}`);
575
+ });
576
+ this.ws.on("message", async (data) => {
577
+ const raw = data.toString();
578
+ let parsed;
579
+ try {
580
+ parsed = JSON.parse(raw);
581
+ } catch {
582
+ return;
583
+ }
584
+ const msgType = parsed.type;
585
+ if (msgType === "message" && this.onMessage) {
586
+ const msg = {
587
+ text: parsed.text,
588
+ callId: parsed.call_id,
589
+ caller: parsed.caller ?? ""
590
+ };
591
+ try {
592
+ const response = await this.onMessage(msg);
593
+ if (response != null) {
594
+ await this.sendResponse(msg.callId, response);
595
+ }
596
+ } catch {
597
+ }
598
+ } else if (msgType === "call_start" && this.onCallStart) {
599
+ await this.onCallStart(parsed);
600
+ } else if (msgType === "call_end" && this.onCallEnd) {
601
+ await this.onCallEnd(parsed);
602
+ }
603
+ });
604
+ this.ws.on("close", () => {
605
+ this.ws = null;
606
+ });
607
+ }
608
+ async sendResponse(callId, text) {
609
+ if (!this.ws) throw new PatterConnectionError("Not connected");
610
+ this.ws.send(JSON.stringify({ type: "response", call_id: callId, text }));
611
+ }
612
+ async requestCall(fromNumber, toNumber, firstMessage = "") {
613
+ if (!this.ws) throw new PatterConnectionError("Not connected");
614
+ this.ws.send(
615
+ JSON.stringify({
616
+ type: "call",
617
+ from: fromNumber,
618
+ to: toNumber,
619
+ first_message: firstMessage
620
+ })
621
+ );
622
+ }
623
+ async disconnect() {
624
+ if (this.ws) {
625
+ this.ws.close();
626
+ this.ws = null;
627
+ }
628
+ }
629
+ parseMessage(raw) {
630
+ try {
631
+ const data = JSON.parse(raw);
632
+ if (data.type !== "message") return null;
633
+ return {
634
+ text: data.text,
635
+ callId: data.call_id,
636
+ caller: data.caller ?? ""
637
+ };
638
+ } catch {
639
+ return null;
640
+ }
641
+ }
642
+ };
643
+
644
+ // src/providers.ts
645
+ var STTConfigImpl = class {
646
+ provider;
647
+ apiKey;
648
+ language;
649
+ constructor(provider, apiKey, language = "en") {
650
+ this.provider = provider;
651
+ this.apiKey = apiKey;
652
+ this.language = language;
653
+ }
654
+ toDict() {
655
+ return { provider: this.provider, api_key: this.apiKey, language: this.language };
656
+ }
657
+ };
658
+ var TTSConfigImpl = class {
659
+ provider;
660
+ apiKey;
661
+ voice;
662
+ constructor(provider, apiKey, voice = "alloy") {
663
+ this.provider = provider;
664
+ this.apiKey = apiKey;
665
+ this.voice = voice;
666
+ }
667
+ toDict() {
668
+ return { provider: this.provider, api_key: this.apiKey, voice: this.voice };
669
+ }
670
+ };
671
+ function deepgram(opts) {
672
+ return new STTConfigImpl("deepgram", opts.apiKey, opts.language ?? "en");
673
+ }
674
+ function whisper(opts) {
675
+ return new STTConfigImpl("whisper", opts.apiKey, opts.language ?? "en");
676
+ }
677
+ function elevenlabs(opts) {
678
+ return new TTSConfigImpl("elevenlabs", opts.apiKey, opts.voice ?? "rachel");
679
+ }
680
+ function openaiTts(opts) {
681
+ return new TTSConfigImpl("openai", opts.apiKey, opts.voice ?? "alloy");
682
+ }
683
+
684
+ // src/server.ts
685
+ var import_node_crypto3 = __toESM(require("crypto"));
686
+ var import_express = __toESM(require("express"));
687
+ var import_http = require("http");
688
+ var import_ws5 = require("ws");
689
+
690
+ // src/providers/openai-realtime.ts
691
+ var import_ws2 = __toESM(require("ws"));
692
+ init_logger();
693
+ var OpenAIRealtimeAdapter = class {
694
+ constructor(apiKey, model = "gpt-4o-mini-realtime-preview", voice = "alloy", instructions = "", tools) {
695
+ this.apiKey = apiKey;
696
+ this.model = model;
697
+ this.voice = voice;
698
+ this.instructions = instructions;
699
+ this.tools = tools;
700
+ }
701
+ ws = null;
702
+ async connect() {
703
+ const url = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(this.model)}`;
704
+ this.ws = new import_ws2.default(url, {
705
+ headers: {
706
+ Authorization: `Bearer ${this.apiKey}`,
707
+ "OpenAI-Beta": "realtime=v1"
708
+ }
709
+ });
710
+ await new Promise((resolve, reject) => {
711
+ let sessionCreated = false;
712
+ const timer = setTimeout(() => reject(new Error("OpenAI Realtime connect timeout")), 15e3);
713
+ this.ws.on("message", (raw) => {
714
+ let msg;
715
+ try {
716
+ msg = JSON.parse(raw.toString());
717
+ } catch (e) {
718
+ getLogger().warn(`OpenAI Realtime: failed to parse message: ${String(e)}`);
719
+ return;
720
+ }
721
+ if (msg.type === "session.created" && !sessionCreated) {
722
+ sessionCreated = true;
723
+ const config = {
724
+ input_audio_format: "g711_ulaw",
725
+ output_audio_format: "g711_ulaw",
726
+ voice: this.voice,
727
+ instructions: this.instructions || "You are a helpful voice assistant. Be concise.",
728
+ turn_detection: { type: "server_vad", threshold: 0.5, prefix_padding_ms: 300, silence_duration_ms: 500 },
729
+ input_audio_transcription: { model: "whisper-1" }
730
+ };
731
+ if (this.tools?.length) {
732
+ config.tools = this.tools.map((t) => ({
733
+ type: "function",
734
+ name: t.name,
735
+ description: t.description,
736
+ parameters: t.parameters
737
+ }));
738
+ }
739
+ this.ws.send(JSON.stringify({ type: "session.update", session: config }));
740
+ } else if (msg.type === "session.updated") {
741
+ clearTimeout(timer);
742
+ resolve();
743
+ }
744
+ });
745
+ this.ws.on("error", (err) => {
746
+ clearTimeout(timer);
747
+ reject(err);
748
+ });
749
+ });
750
+ }
751
+ sendAudio(mulawAudio) {
752
+ if (!this.ws || this.ws.readyState !== import_ws2.default.OPEN) return;
753
+ this.ws.send(JSON.stringify({ type: "input_audio_buffer.append", audio: mulawAudio.toString("base64") }));
754
+ }
755
+ onEvent(callback) {
756
+ if (!this.ws) return;
757
+ this.ws.on("message", (raw) => {
758
+ let data;
759
+ try {
760
+ data = JSON.parse(raw.toString());
761
+ } catch (e) {
762
+ getLogger().warn(`OpenAI Realtime: failed to parse event message: ${String(e)}`);
763
+ return;
764
+ }
765
+ const t = data.type;
766
+ if (t === "response.audio.delta") {
767
+ callback("audio", Buffer.from(data.delta ?? "", "base64"));
768
+ } else if (t === "response.audio_transcript.delta") {
769
+ callback("transcript_output", data.delta);
770
+ } else if (t === "input_audio_buffer.speech_started") {
771
+ callback("speech_started", null);
772
+ } else if (t === "conversation.item.input_audio_transcription.completed") {
773
+ callback("transcript_input", data.transcript);
774
+ } else if (t === "response.function_call_arguments.done") {
775
+ callback("function_call", { call_id: data.call_id, name: data.name, arguments: data.arguments });
776
+ } else if (t === "response.done") {
777
+ callback("response_done", null);
778
+ } else if (t === "error") {
779
+ callback("error", data.error);
780
+ }
781
+ });
782
+ }
783
+ cancelResponse() {
784
+ this.ws?.send(JSON.stringify({ type: "response.cancel" }));
785
+ }
786
+ async sendText(text) {
787
+ this.ws?.send(JSON.stringify({
788
+ type: "conversation.item.create",
789
+ item: { type: "message", role: "user", content: [{ type: "input_text", text }] }
790
+ }));
791
+ this.ws?.send(JSON.stringify({ type: "response.create" }));
792
+ }
793
+ async sendFunctionResult(callId, result) {
794
+ this.ws?.send(JSON.stringify({
795
+ type: "conversation.item.create",
796
+ item: { type: "function_call_output", call_id: callId, output: result }
797
+ }));
798
+ this.ws?.send(JSON.stringify({ type: "response.create" }));
799
+ }
800
+ close() {
801
+ this.ws?.close();
802
+ this.ws = null;
803
+ }
804
+ };
805
+
806
+ // src/providers/elevenlabs-convai.ts
807
+ var import_ws3 = __toESM(require("ws"));
808
+ var ELEVENLABS_CONVAI_URL = "wss://api.elevenlabs.io/v1/convai/conversation";
809
+ var ElevenLabsConvAIAdapter = class {
810
+ constructor(apiKey, agentId = "", voiceId = "21m00Tcm4TlvDq8ikWAM", _modelId = "eleven_turbo_v2_5", _language = "en", firstMessage = "") {
811
+ this.apiKey = apiKey;
812
+ this.agentId = agentId;
813
+ this.voiceId = voiceId;
814
+ this.firstMessage = firstMessage;
815
+ }
816
+ ws = null;
817
+ eventCallback = null;
818
+ async connect() {
819
+ const url = this.agentId ? `${ELEVENLABS_CONVAI_URL}?agent_id=${encodeURIComponent(this.agentId)}` : ELEVENLABS_CONVAI_URL;
820
+ this.ws = new import_ws3.default(url, {
821
+ headers: { "xi-api-key": this.apiKey }
822
+ });
823
+ await new Promise((resolve, reject) => {
824
+ const timeout = setTimeout(
825
+ () => reject(new Error("ElevenLabs ConvAI connect timeout")),
826
+ 15e3
827
+ );
828
+ this.ws.once("open", () => {
829
+ clearTimeout(timeout);
830
+ const config = {
831
+ type: "conversation_initiation_client_data",
832
+ conversation_config_override: {
833
+ tts: { voice_id: this.voiceId }
834
+ }
835
+ };
836
+ if (this.firstMessage) {
837
+ config["conversation_config_override"]["agent"] = {
838
+ first_message: this.firstMessage
839
+ };
840
+ }
841
+ this.ws.send(JSON.stringify(config));
842
+ resolve();
843
+ });
844
+ this.ws.once("error", (err) => {
845
+ clearTimeout(timeout);
846
+ reject(err);
847
+ });
848
+ });
849
+ this.ws.on("message", (raw) => {
850
+ if (!this.eventCallback) return;
851
+ let parsed;
852
+ try {
853
+ parsed = JSON.parse(raw.toString());
854
+ } catch {
855
+ return;
856
+ }
857
+ const msgType = parsed["type"];
858
+ if (msgType === "audio") {
859
+ const audioB64 = parsed["audio"];
860
+ if (audioB64) {
861
+ this.eventCallback("audio", Buffer.from(audioB64, "base64"));
862
+ }
863
+ } else if (msgType === "user_transcript") {
864
+ this.eventCallback("transcript_input", parsed["text"] ?? "");
865
+ } else if (msgType === "agent_response") {
866
+ this.eventCallback("transcript_output", parsed["text"] ?? "");
867
+ } else if (msgType === "interruption") {
868
+ this.eventCallback("interruption", null);
869
+ } else if (msgType === "error") {
870
+ this.eventCallback("error", parsed);
871
+ }
872
+ });
873
+ }
874
+ sendAudio(audioBytes) {
875
+ if (!this.ws || this.ws.readyState !== import_ws3.default.OPEN) return;
876
+ this.ws.send(
877
+ JSON.stringify({
878
+ type: "audio",
879
+ audio: audioBytes.toString("base64")
880
+ })
881
+ );
882
+ }
883
+ onEvent(callback) {
884
+ this.eventCallback = callback;
885
+ }
886
+ close() {
887
+ this.ws?.close();
888
+ this.ws = null;
889
+ this.eventCallback = null;
890
+ }
891
+ };
892
+
893
+ // src/providers/deepgram-stt.ts
894
+ var import_ws4 = __toESM(require("ws"));
895
+ init_logger();
896
+ var DEEPGRAM_WS_URL = "wss://api.deepgram.com/v1/listen";
897
+ var DeepgramSTT = class _DeepgramSTT {
898
+ constructor(apiKey, language = "en", model = "nova-3", encoding = "linear16", sampleRate = 16e3) {
899
+ this.apiKey = apiKey;
900
+ this.language = language;
901
+ this.model = model;
902
+ this.encoding = encoding;
903
+ this.sampleRate = sampleRate;
904
+ }
905
+ ws = null;
906
+ callbacks = [];
907
+ /** Request ID from Deepgram — used to query actual cost post-call. */
908
+ requestId = "";
909
+ /** Factory for Twilio calls — mulaw 8 kHz. */
910
+ static forTwilio(apiKey, language = "en", model = "nova-3") {
911
+ return new _DeepgramSTT(apiKey, language, model, "mulaw", 8e3);
912
+ }
913
+ async connect() {
914
+ const params = new URLSearchParams({
915
+ model: this.model,
916
+ language: this.language,
917
+ encoding: this.encoding,
918
+ sample_rate: String(this.sampleRate),
919
+ channels: "1",
920
+ interim_results: "true",
921
+ endpointing: "300",
922
+ smart_format: "true",
923
+ vad_events: "true",
924
+ no_delay: "true"
925
+ });
926
+ const url = `${DEEPGRAM_WS_URL}?${params.toString()}`;
927
+ this.ws = new import_ws4.default(url, {
928
+ headers: { Authorization: `Token ${this.apiKey}` }
929
+ });
930
+ await new Promise((resolve, reject) => {
931
+ const timer = setTimeout(() => reject(new Error("Deepgram connect timeout")), 1e4);
932
+ this.ws.once("open", () => {
933
+ clearTimeout(timer);
934
+ resolve();
935
+ });
936
+ this.ws.once("error", (err) => {
937
+ clearTimeout(timer);
938
+ reject(err);
939
+ });
940
+ });
941
+ this.ws.on("message", (raw) => {
942
+ let data;
943
+ try {
944
+ data = JSON.parse(raw.toString());
945
+ } catch {
946
+ return;
947
+ }
948
+ if (data.type === "Metadata" && data.request_id) {
949
+ this.requestId = data.request_id;
950
+ return;
951
+ }
952
+ if (data.type !== "Results") return;
953
+ const alternatives = data.channel?.alternatives ?? [];
954
+ if (!alternatives.length) return;
955
+ const best = alternatives[0];
956
+ const text = (best.transcript ?? "").trim();
957
+ if (!text) return;
958
+ const transcript = {
959
+ text,
960
+ isFinal: Boolean(data.is_final) && Boolean(data.speech_final),
961
+ confidence: best.confidence ?? 0
962
+ };
963
+ for (const cb of this.callbacks) {
964
+ cb(transcript);
965
+ }
966
+ });
967
+ }
968
+ sendAudio(audio) {
969
+ if (!this.ws || this.ws.readyState !== import_ws4.default.OPEN) return;
970
+ this.ws.send(audio);
971
+ }
972
+ onTranscript(callback) {
973
+ if (this.callbacks.length >= 10) {
974
+ getLogger().warn("DeepgramSTT: maximum of 10 onTranscript callbacks reached; replacing the last callback.");
975
+ this.callbacks[this.callbacks.length - 1] = callback;
976
+ return;
977
+ }
978
+ this.callbacks.push(callback);
979
+ }
980
+ close() {
981
+ if (this.ws) {
982
+ try {
983
+ this.ws.send(JSON.stringify({ type: "CloseStream" }));
984
+ } catch {
985
+ }
986
+ this.ws.close();
987
+ this.ws = null;
988
+ }
989
+ }
990
+ };
991
+
992
+ // src/providers/whisper-stt.ts
993
+ init_logger();
994
+ var OPENAI_TRANSCRIPTION_URL = "https://api.openai.com/v1/audio/transcriptions";
995
+ var DEFAULT_BUFFER_SIZE = 16e3 * 2;
996
+ function wrapPcmInWav(pcm, sampleRate = 16e3, channels = 1, bitsPerSample = 16) {
997
+ const dataSize = pcm.length;
998
+ const header = Buffer.alloc(44);
999
+ header.write("RIFF", 0);
1000
+ header.writeUInt32LE(36 + dataSize, 4);
1001
+ header.write("WAVE", 8);
1002
+ header.write("fmt ", 12);
1003
+ header.writeUInt32LE(16, 16);
1004
+ header.writeUInt16LE(1, 20);
1005
+ header.writeUInt16LE(channels, 22);
1006
+ header.writeUInt32LE(sampleRate, 24);
1007
+ header.writeUInt32LE(sampleRate * channels * (bitsPerSample / 8), 28);
1008
+ header.writeUInt16LE(channels * (bitsPerSample / 8), 32);
1009
+ header.writeUInt16LE(bitsPerSample, 34);
1010
+ header.write("data", 36);
1011
+ header.writeUInt32LE(dataSize, 40);
1012
+ return Buffer.concat([header, pcm]);
1013
+ }
1014
+ var WhisperSTT = class _WhisperSTT {
1015
+ apiKey;
1016
+ model;
1017
+ language;
1018
+ bufferSize;
1019
+ buffer = Buffer.alloc(0);
1020
+ callbacks = [];
1021
+ running = false;
1022
+ constructor(apiKey, model = "whisper-1", language, bufferSize = DEFAULT_BUFFER_SIZE) {
1023
+ this.apiKey = apiKey;
1024
+ this.model = model;
1025
+ this.language = language;
1026
+ this.bufferSize = bufferSize;
1027
+ }
1028
+ /** Factory for Twilio calls — mulaw 8 kHz is transcoded upstream, so we still receive PCM 16-bit. */
1029
+ static forTwilio(apiKey, language = "en", model = "whisper-1") {
1030
+ return new _WhisperSTT(apiKey, model, language);
1031
+ }
1032
+ async connect() {
1033
+ this.running = true;
1034
+ this.buffer = Buffer.alloc(0);
1035
+ }
1036
+ sendAudio(audio) {
1037
+ if (!this.running) return;
1038
+ this.buffer = Buffer.concat([this.buffer, audio]);
1039
+ if (this.buffer.length >= this.bufferSize) {
1040
+ const pcm = this.buffer;
1041
+ this.buffer = Buffer.alloc(0);
1042
+ void this.transcribeBuffer(pcm);
1043
+ }
1044
+ }
1045
+ onTranscript(callback) {
1046
+ if (this.callbacks.length >= 10) {
1047
+ getLogger().warn("WhisperSTT: maximum of 10 onTranscript callbacks reached; replacing the last callback.");
1048
+ this.callbacks[this.callbacks.length - 1] = callback;
1049
+ return;
1050
+ }
1051
+ this.callbacks.push(callback);
1052
+ }
1053
+ close() {
1054
+ this.running = false;
1055
+ if (this.buffer.length >= this.bufferSize / 4) {
1056
+ const pcm = this.buffer;
1057
+ this.buffer = Buffer.alloc(0);
1058
+ void this.transcribeBuffer(pcm);
1059
+ } else {
1060
+ this.buffer = Buffer.alloc(0);
1061
+ }
1062
+ }
1063
+ // ------------------------------------------------------------------
1064
+ // Private
1065
+ // ------------------------------------------------------------------
1066
+ async transcribeBuffer(pcm) {
1067
+ const wav = wrapPcmInWav(pcm);
1068
+ const formData = new FormData();
1069
+ formData.append("file", new Blob([wav.buffer.slice(wav.byteOffset, wav.byteOffset + wav.byteLength)], { type: "audio/wav" }), "audio.wav");
1070
+ formData.append("model", this.model);
1071
+ if (this.language) {
1072
+ formData.append("language", this.language);
1073
+ }
1074
+ try {
1075
+ const resp = await fetch(OPENAI_TRANSCRIPTION_URL, {
1076
+ method: "POST",
1077
+ headers: { Authorization: `Bearer ${this.apiKey}` },
1078
+ body: formData
1079
+ });
1080
+ if (!resp.ok) {
1081
+ const body = await resp.text();
1082
+ getLogger().error(`WhisperSTT transcription error: ${resp.status} ${body}`);
1083
+ return;
1084
+ }
1085
+ const json = await resp.json();
1086
+ const text = (json.text ?? "").trim();
1087
+ if (!text) return;
1088
+ const transcript = {
1089
+ text,
1090
+ isFinal: true,
1091
+ confidence: 1
1092
+ };
1093
+ for (const cb of this.callbacks) {
1094
+ cb(transcript);
1095
+ }
1096
+ } catch (err) {
1097
+ getLogger().error(`WhisperSTT transcription error: ${String(err)}`);
1098
+ }
1099
+ }
1100
+ };
1101
+
1102
+ // src/pricing.ts
1103
+ var DEFAULT_PRICING = {
1104
+ // STT — per minute of audio processed
1105
+ deepgram: { unit: "minute", price: 43e-4 },
1106
+ whisper: { unit: "minute", price: 6e-3 },
1107
+ // TTS — per 1,000 characters synthesized
1108
+ elevenlabs: { unit: "1k_chars", price: 0.18 },
1109
+ openai_tts: { unit: "1k_chars", price: 0.015 },
1110
+ // OpenAI Realtime — per token
1111
+ openai_realtime: {
1112
+ unit: "token",
1113
+ audio_input_per_token: 1e-4,
1114
+ audio_output_per_token: 4e-4,
1115
+ text_input_per_token: 5e-6,
1116
+ text_output_per_token: 2e-5
1117
+ },
1118
+ // Telephony — per minute of call duration
1119
+ twilio: { unit: "minute", price: 0.013 },
1120
+ telnyx: { unit: "minute", price: 7e-3 }
1121
+ };
1122
+ function mergePricing(overrides) {
1123
+ const merged = {};
1124
+ for (const [k, v] of Object.entries(DEFAULT_PRICING)) {
1125
+ merged[k] = { ...v };
1126
+ }
1127
+ if (!overrides) return merged;
1128
+ for (const [provider, values] of Object.entries(overrides)) {
1129
+ if (merged[provider]) {
1130
+ merged[provider] = { ...merged[provider], ...values };
1131
+ } else {
1132
+ merged[provider] = { unit: "minute", ...values };
1133
+ }
1134
+ }
1135
+ return merged;
1136
+ }
1137
+ function calculateSttCost(provider, audioSeconds, pricing) {
1138
+ const config = pricing[provider];
1139
+ if (!config || config.unit !== "minute") return 0;
1140
+ return audioSeconds / 60 * (config.price ?? 0);
1141
+ }
1142
+ function calculateTtsCost(provider, characterCount, pricing) {
1143
+ const config = pricing[provider];
1144
+ if (!config || config.unit !== "1k_chars") return 0;
1145
+ return characterCount / 1e3 * (config.price ?? 0);
1146
+ }
1147
+ function calculateRealtimeCost(usage, pricing) {
1148
+ const config = pricing.openai_realtime;
1149
+ if (!config || config.unit !== "token") return 0;
1150
+ const input = usage.input_token_details ?? {};
1151
+ const output = usage.output_token_details ?? {};
1152
+ let cost = 0;
1153
+ cost += (input.audio_tokens ?? 0) * (config.audio_input_per_token ?? 0);
1154
+ cost += (input.text_tokens ?? 0) * (config.text_input_per_token ?? 0);
1155
+ cost += (output.audio_tokens ?? 0) * (config.audio_output_per_token ?? 0);
1156
+ cost += (output.text_tokens ?? 0) * (config.text_output_per_token ?? 0);
1157
+ return cost;
1158
+ }
1159
+ function calculateTelephonyCost(provider, durationSeconds, pricing) {
1160
+ const config = pricing[provider];
1161
+ if (!config || config.unit !== "minute") return 0;
1162
+ return durationSeconds / 60 * (config.price ?? 0);
1163
+ }
1164
+
1165
+ // src/dashboard/store.ts
1166
+ var import_events = require("events");
1167
+ var MetricsStore = class extends import_events.EventEmitter {
1168
+ maxCalls;
1169
+ calls = [];
1170
+ activeCalls = /* @__PURE__ */ new Map();
1171
+ constructor(maxCalls = 500) {
1172
+ super();
1173
+ this.maxCalls = maxCalls;
1174
+ }
1175
+ publish(eventType, data) {
1176
+ this.emit("sse", { type: eventType, data });
1177
+ }
1178
+ recordCallStart(data) {
1179
+ const callId = data.call_id || "";
1180
+ if (!callId) return;
1181
+ const record = {
1182
+ call_id: callId,
1183
+ caller: data.caller || "",
1184
+ callee: data.callee || "",
1185
+ direction: data.direction || "inbound",
1186
+ started_at: Date.now() / 1e3,
1187
+ turns: []
1188
+ };
1189
+ this.activeCalls.set(callId, record);
1190
+ this.publish("call_start", {
1191
+ call_id: callId,
1192
+ caller: record.caller,
1193
+ callee: record.callee,
1194
+ direction: record.direction
1195
+ });
1196
+ }
1197
+ recordTurn(data) {
1198
+ const callId = data.call_id || "";
1199
+ const turn = data.turn;
1200
+ if (!callId || turn == null) return;
1201
+ const active = this.activeCalls.get(callId);
1202
+ if (active) {
1203
+ if (!active.turns) active.turns = [];
1204
+ active.turns.push(turn);
1205
+ }
1206
+ this.publish("turn_complete", { call_id: callId, turn });
1207
+ }
1208
+ recordCallEnd(data, metrics) {
1209
+ const callId = data.call_id || "";
1210
+ if (!callId) return;
1211
+ const active = this.activeCalls.get(callId);
1212
+ this.activeCalls.delete(callId);
1213
+ const entry = {
1214
+ call_id: callId,
1215
+ caller: active?.caller || "",
1216
+ callee: active?.callee || "",
1217
+ direction: active?.direction || "inbound",
1218
+ started_at: active?.started_at || 0,
1219
+ ended_at: Date.now() / 1e3,
1220
+ transcript: data.transcript || [],
1221
+ metrics: metrics ?? null
1222
+ };
1223
+ this.calls.push(entry);
1224
+ if (this.calls.length > this.maxCalls) {
1225
+ this.calls = this.calls.slice(-this.maxCalls);
1226
+ }
1227
+ this.publish("call_end", {
1228
+ call_id: callId,
1229
+ metrics: entry.metrics ?? null
1230
+ });
1231
+ }
1232
+ getCalls(limit = 50, offset = 0) {
1233
+ const ordered = [...this.calls].reverse();
1234
+ return ordered.slice(offset, offset + limit);
1235
+ }
1236
+ getCall(callId) {
1237
+ for (let i = this.calls.length - 1; i >= 0; i--) {
1238
+ if (this.calls[i].call_id === callId) return this.calls[i];
1239
+ }
1240
+ return null;
1241
+ }
1242
+ getActiveCalls() {
1243
+ return Array.from(this.activeCalls.values());
1244
+ }
1245
+ getAggregates() {
1246
+ const totalCalls = this.calls.length;
1247
+ if (totalCalls === 0) {
1248
+ return {
1249
+ total_calls: 0,
1250
+ total_cost: 0,
1251
+ avg_duration: 0,
1252
+ avg_latency_ms: 0,
1253
+ cost_breakdown: { stt: 0, tts: 0, llm: 0, telephony: 0 },
1254
+ active_calls: this.activeCalls.size
1255
+ };
1256
+ }
1257
+ let totalCost = 0;
1258
+ let totalDuration = 0;
1259
+ let totalLatency = 0;
1260
+ let latencyCount = 0;
1261
+ let costStt = 0;
1262
+ let costTts = 0;
1263
+ let costLlm = 0;
1264
+ let costTel = 0;
1265
+ for (const call of this.calls) {
1266
+ const m = call.metrics;
1267
+ if (!m) continue;
1268
+ const cost = m.cost || {};
1269
+ totalCost += cost.total || 0;
1270
+ costStt += cost.stt || 0;
1271
+ costTts += cost.tts || 0;
1272
+ costLlm += cost.llm || 0;
1273
+ costTel += cost.telephony || 0;
1274
+ totalDuration += m.duration_seconds || 0;
1275
+ const avgLat = m.latency_avg || {};
1276
+ const tMs = avgLat.total_ms || 0;
1277
+ if (tMs > 0) {
1278
+ totalLatency += tMs;
1279
+ latencyCount++;
1280
+ }
1281
+ }
1282
+ return {
1283
+ total_calls: totalCalls,
1284
+ total_cost: Math.round(totalCost * 1e6) / 1e6,
1285
+ avg_duration: Math.round(totalDuration / totalCalls * 100) / 100,
1286
+ avg_latency_ms: latencyCount > 0 ? Math.round(totalLatency / latencyCount * 10) / 10 : 0,
1287
+ cost_breakdown: {
1288
+ stt: Math.round(costStt * 1e6) / 1e6,
1289
+ tts: Math.round(costTts * 1e6) / 1e6,
1290
+ llm: Math.round(costLlm * 1e6) / 1e6,
1291
+ telephony: Math.round(costTel * 1e6) / 1e6
1292
+ },
1293
+ active_calls: this.activeCalls.size
1294
+ };
1295
+ }
1296
+ getCallsInRange(fromTs = 0, toTs = 0) {
1297
+ return this.calls.filter((call) => {
1298
+ const started = call.started_at || 0;
1299
+ if (fromTs && started < fromTs) return false;
1300
+ if (toTs && started > toTs) return false;
1301
+ return true;
1302
+ });
1303
+ }
1304
+ get callCount() {
1305
+ return this.calls.length;
1306
+ }
1307
+ };
1308
+
1309
+ // src/dashboard/auth.ts
1310
+ var import_node_crypto = __toESM(require("crypto"));
1311
+ function timingSafeCompare(a, b) {
1312
+ if (a.length !== b.length) return false;
1313
+ return import_node_crypto.default.timingSafeEqual(Buffer.from(a), Buffer.from(b));
1314
+ }
1315
+ function makeAuthMiddleware(token = "") {
1316
+ return (req, res, next) => {
1317
+ if (!token) {
1318
+ next();
1319
+ return;
1320
+ }
1321
+ const auth = req.headers.authorization || "";
1322
+ const expected = `Bearer ${token}`;
1323
+ if (auth.length === expected.length && timingSafeCompare(auth, expected)) {
1324
+ next();
1325
+ return;
1326
+ }
1327
+ const queryToken = String(req.query.token ?? "");
1328
+ if (queryToken.length === token.length && timingSafeCompare(queryToken, token)) {
1329
+ next();
1330
+ return;
1331
+ }
1332
+ res.status(401).json({ error: "Unauthorized" });
1333
+ };
1334
+ }
1335
+
1336
+ // src/dashboard/export.ts
1337
+ function callsToCsv(calls) {
1338
+ const header = [
1339
+ "call_id",
1340
+ "caller",
1341
+ "callee",
1342
+ "direction",
1343
+ "started_at",
1344
+ "ended_at",
1345
+ "duration_s",
1346
+ "cost_total",
1347
+ "cost_stt",
1348
+ "cost_tts",
1349
+ "cost_llm",
1350
+ "cost_telephony",
1351
+ "avg_latency_ms",
1352
+ "turns_count",
1353
+ "provider_mode"
1354
+ ];
1355
+ const rows = [header.join(",")];
1356
+ for (const call of calls) {
1357
+ const m = call.metrics || {};
1358
+ const cost = m.cost || {};
1359
+ const latencyAvg = m.latency_avg || {};
1360
+ const turns = m.turns;
1361
+ const turnsCount = Array.isArray(turns) ? turns.length : "";
1362
+ const row = [
1363
+ csvEscape(call.call_id || ""),
1364
+ csvEscape(call.caller || ""),
1365
+ csvEscape(call.callee || ""),
1366
+ csvEscape(call.direction || ""),
1367
+ call.started_at ?? "",
1368
+ call.ended_at ?? "",
1369
+ m.duration_seconds ?? "",
1370
+ cost.total ?? "",
1371
+ cost.stt ?? "",
1372
+ cost.tts ?? "",
1373
+ cost.llm ?? "",
1374
+ cost.telephony ?? "",
1375
+ latencyAvg.total_ms ?? "",
1376
+ turnsCount,
1377
+ m.provider_mode ?? ""
1378
+ ];
1379
+ rows.push(row.map(String).join(","));
1380
+ }
1381
+ return rows.join("\n") + "\n";
1382
+ }
1383
+ function callsToJson(calls) {
1384
+ return JSON.stringify(calls);
1385
+ }
1386
+ function csvEscape(value) {
1387
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
1388
+ return `"${value.replace(/"/g, '""')}"`;
1389
+ }
1390
+ return value;
1391
+ }
1392
+
1393
+ // src/dashboard/ui.ts
1394
+ var DASHBOARD_HTML = `<!DOCTYPE html>
1395
+ <html lang="en">
1396
+ <head>
1397
+ <meta charset="utf-8">
1398
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1399
+ <title>Patter Dashboard</title>
1400
+ <style>
1401
+ :root {
1402
+ --bg: #0a0a0f;
1403
+ --surface: #12121a;
1404
+ --surface2: #1a1a26;
1405
+ --border: #2a2a3a;
1406
+ --text: #e4e4ef;
1407
+ --text2: #8888a0;
1408
+ --accent: #6366f1;
1409
+ --accent2: #818cf8;
1410
+ --green: #22c55e;
1411
+ --red: #ef4444;
1412
+ --yellow: #eab308;
1413
+ --blue: #3b82f6;
1414
+ --radius: 8px;
1415
+ }
1416
+ * { margin:0; padding:0; box-sizing:border-box; }
1417
+ body {
1418
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
1419
+ background: var(--bg); color: var(--text);
1420
+ min-height: 100vh;
1421
+ }
1422
+ header {
1423
+ border-bottom: 1px solid var(--border);
1424
+ padding: 16px 24px;
1425
+ display: flex; align-items: center; gap: 16px;
1426
+ }
1427
+ header h1 { font-size: 18px; font-weight: 600; }
1428
+ header h1 span { color: var(--accent); }
1429
+ header .status {
1430
+ margin-left: auto; font-size: 13px; color: var(--text2);
1431
+ display: flex; align-items: center; gap: 6px;
1432
+ }
1433
+ header .dot {
1434
+ width: 8px; height: 8px; border-radius: 50%;
1435
+ background: var(--green); display: inline-block;
1436
+ }
1437
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
1438
+ .cards {
1439
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1440
+ gap: 16px; margin-bottom: 24px;
1441
+ }
1442
+ .card {
1443
+ background: var(--surface); border: 1px solid var(--border);
1444
+ border-radius: var(--radius); padding: 16px;
1445
+ }
1446
+ .card .label { font-size: 12px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; }
1447
+ .card .value { font-size: 28px; font-weight: 700; margin-top: 4px; }
1448
+ .card .sub { font-size: 12px; color: var(--text2); margin-top: 2px; }
1449
+ .section { margin-bottom: 24px; }
1450
+ .section h2 { font-size: 15px; font-weight: 600; margin-bottom: 12px; color: var(--text2); }
1451
+ table {
1452
+ width: 100%; border-collapse: collapse;
1453
+ background: var(--surface); border: 1px solid var(--border);
1454
+ border-radius: var(--radius); overflow: hidden;
1455
+ }
1456
+ th { text-align: left; font-size: 11px; text-transform: uppercase;
1457
+ color: var(--text2); padding: 10px 14px; border-bottom: 1px solid var(--border);
1458
+ letter-spacing: 0.5px;
1459
+ }
1460
+ td { padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 13px; }
1461
+ tr:last-child td { border-bottom: none; }
1462
+ tr.clickable { cursor: pointer; }
1463
+ tr.clickable:hover { background: var(--surface2); }
1464
+ .badge {
1465
+ display: inline-block; padding: 2px 8px; border-radius: 10px;
1466
+ font-size: 11px; font-weight: 600;
1467
+ }
1468
+ .badge-active { background: rgba(34,197,94,0.15); color: var(--green); }
1469
+ .badge-ended { background: rgba(136,136,160,0.15); color: var(--text2); }
1470
+ .badge-pipeline { background: rgba(99,102,241,0.15); color: var(--accent2); }
1471
+ .badge-realtime { background: rgba(59,130,246,0.15); color: var(--blue); }
1472
+ .cost { color: var(--green); }
1473
+ .latency { color: var(--yellow); }
1474
+ .empty { text-align: center; padding: 40px; color: var(--text2); font-size: 14px; }
1475
+ .modal-overlay {
1476
+ display: none; position: fixed; inset: 0;
1477
+ background: rgba(0,0,0,0.7); z-index: 100;
1478
+ justify-content: center; align-items: flex-start;
1479
+ padding: 60px 20px; overflow-y: auto;
1480
+ }
1481
+ .modal-overlay.open { display: flex; }
1482
+ .modal {
1483
+ background: var(--surface); border: 1px solid var(--border);
1484
+ border-radius: 12px; max-width: 800px; width: 100%;
1485
+ padding: 24px;
1486
+ }
1487
+ .modal-header {
1488
+ display: flex; justify-content: space-between; align-items: center;
1489
+ margin-bottom: 20px;
1490
+ }
1491
+ .modal-header h2 { font-size: 16px; }
1492
+ .modal-close {
1493
+ background: none; border: none; color: var(--text2);
1494
+ font-size: 24px; cursor: pointer;
1495
+ }
1496
+ .modal-close:hover { color: var(--text); }
1497
+ .detail-grid {
1498
+ display: grid; grid-template-columns: 1fr 1fr;
1499
+ gap: 16px; margin-bottom: 20px;
1500
+ }
1501
+ .detail-card {
1502
+ background: var(--surface2); border-radius: var(--radius); padding: 14px;
1503
+ }
1504
+ .detail-card h3 { font-size: 12px; color: var(--text2); text-transform: uppercase; margin-bottom: 8px; }
1505
+ .detail-row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; }
1506
+ .detail-row .k { color: var(--text2); }
1507
+ .transcript-box {
1508
+ background: var(--surface2); border-radius: var(--radius);
1509
+ padding: 14px; max-height: 300px; overflow-y: auto;
1510
+ }
1511
+ .transcript-box .msg { padding: 4px 0; font-size: 13px; }
1512
+ .transcript-box .msg.user .role { color: var(--blue); }
1513
+ .transcript-box .msg.assistant .role { color: var(--accent2); }
1514
+ .transcript-box .role { font-weight: 600; margin-right: 8px; }
1515
+ .turns-table { margin-top: 16px; }
1516
+ .bar-container { display: flex; height: 14px; border-radius: 3px; overflow: hidden; min-width: 120px; }
1517
+ .bar-stt { background: var(--blue); }
1518
+ .bar-llm { background: var(--accent); }
1519
+ .bar-tts { background: var(--yellow); }
1520
+ .nav-tabs {
1521
+ display: flex; gap: 4px; margin-bottom: 16px;
1522
+ border-bottom: 1px solid var(--border); padding-bottom: 0;
1523
+ }
1524
+ .nav-tab {
1525
+ padding: 8px 16px; font-size: 13px; color: var(--text2);
1526
+ cursor: pointer; border: none; background: none;
1527
+ border-bottom: 2px solid transparent; margin-bottom: -1px;
1528
+ }
1529
+ .nav-tab:hover { color: var(--text); }
1530
+ .nav-tab.active { color: var(--accent2); border-bottom-color: var(--accent); }
1531
+ .tab-content { display: none; }
1532
+ .tab-content.active { display: block; }
1533
+ </style>
1534
+ </head>
1535
+ <body>
1536
+ <header>
1537
+ <h1><span>Patter</span> Dashboard</h1>
1538
+ <div class="status"><span class="dot"></span> <span id="status-text">Listening</span></div>
1539
+ </header>
1540
+
1541
+ <div class="container">
1542
+ <div class="cards">
1543
+ <div class="card">
1544
+ <div class="label">Total Calls</div>
1545
+ <div class="value" id="stat-total">0</div>
1546
+ <div class="sub"><span id="stat-active">0</span> active</div>
1547
+ </div>
1548
+ <div class="card">
1549
+ <div class="label">Total Cost</div>
1550
+ <div class="value cost" id="stat-cost">$0.00</div>
1551
+ <div class="sub" id="stat-cost-breakdown">-</div>
1552
+ </div>
1553
+ <div class="card">
1554
+ <div class="label">Avg Duration</div>
1555
+ <div class="value" id="stat-duration">0s</div>
1556
+ </div>
1557
+ <div class="card">
1558
+ <div class="label">Avg Latency</div>
1559
+ <div class="value latency" id="stat-latency">0ms</div>
1560
+ <div class="sub">end-to-end response</div>
1561
+ </div>
1562
+ </div>
1563
+
1564
+ <div class="nav-tabs">
1565
+ <button class="nav-tab active" data-tab="calls">Calls</button>
1566
+ <button class="nav-tab" data-tab="active">Active</button>
1567
+ </div>
1568
+
1569
+ <div class="tab-content active" id="tab-calls">
1570
+ <div class="section">
1571
+ <table id="calls-table">
1572
+ <thead>
1573
+ <tr>
1574
+ <th>Call ID</th><th>Direction</th><th>From / To</th>
1575
+ <th>Duration</th><th>Mode</th><th>Cost</th><th>Avg Latency</th><th>Turns</th>
1576
+ </tr>
1577
+ </thead>
1578
+ <tbody id="calls-body">
1579
+ <tr><td colspan="8" class="empty">No calls yet. Waiting for incoming calls...</td></tr>
1580
+ </tbody>
1581
+ </table>
1582
+ </div>
1583
+ </div>
1584
+
1585
+ <div class="tab-content" id="tab-active">
1586
+ <div class="section">
1587
+ <table>
1588
+ <thead>
1589
+ <tr><th>Call ID</th><th>Caller</th><th>Callee</th><th>Direction</th><th>Duration</th><th>Turns</th></tr>
1590
+ </thead>
1591
+ <tbody id="active-body">
1592
+ <tr><td colspan="6" class="empty">No active calls</td></tr>
1593
+ </tbody>
1594
+ </table>
1595
+ </div>
1596
+ </div>
1597
+ </div>
1598
+
1599
+ <div class="modal-overlay" id="modal">
1600
+ <div class="modal">
1601
+ <div class="modal-header">
1602
+ <h2 id="modal-title">Call Detail</h2>
1603
+ <button class="modal-close" onclick="closeModal()">&times;</button>
1604
+ </div>
1605
+ <div id="modal-body"></div>
1606
+ </div>
1607
+ </div>
1608
+
1609
+ <script>
1610
+ const $ = (s) => document.querySelector(s);
1611
+ const $$ = (s) => document.querySelectorAll(s);
1612
+
1613
+ $$('.nav-tab').forEach(tab => {
1614
+ tab.addEventListener('click', () => {
1615
+ $$('.nav-tab').forEach(t => t.classList.remove('active'));
1616
+ $$('.tab-content').forEach(t => t.classList.remove('active'));
1617
+ tab.classList.add('active');
1618
+ document.querySelector('#tab-'+tab.dataset.tab).classList.add('active');
1619
+ });
1620
+ });
1621
+
1622
+ function esc(s) {
1623
+ if (!s) return '';
1624
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
1625
+ }
1626
+ function fmt\\$(v) { return v >= 0.01 ? '$'+v.toFixed(4) : v > 0 ? '$'+v.toFixed(6) : '$0.00'; }
1627
+ function fmtMs(v) { return v > 0 ? Math.round(v)+'ms' : '-'; }
1628
+ function fmtDur(s) {
1629
+ if (!s) return '-';
1630
+ if (s < 60) return Math.round(s)+'s';
1631
+ return Math.floor(s/60)+'m '+Math.round(s%60)+'s';
1632
+ }
1633
+ function shortId(id) { return id ? esc(id.length > 16 ? id.slice(0,8)+'...'+id.slice(-4) : id) : '-'; }
1634
+
1635
+ async function fetchJSON(url) {
1636
+ const r = await fetch(url);
1637
+ return r.json();
1638
+ }
1639
+
1640
+ async function refreshAggregates() {
1641
+ const d = await fetchJSON('/api/dashboard/aggregates');
1642
+ $('#stat-total').textContent = d.total_calls;
1643
+ $('#stat-active').textContent = d.active_calls;
1644
+ $('#stat-cost').textContent = fmt\\$(d.total_cost);
1645
+ const cb = d.cost_breakdown;
1646
+ $('#stat-cost-breakdown').textContent =
1647
+ 'STT '+fmt\\$(cb.stt)+' | LLM '+fmt\\$(cb.llm)+' | TTS '+fmt\\$(cb.tts)+' | Tel '+fmt\\$(cb.telephony);
1648
+ $('#stat-duration').textContent = fmtDur(d.avg_duration);
1649
+ $('#stat-latency').textContent = fmtMs(d.avg_latency_ms);
1650
+ }
1651
+
1652
+ async function refreshCalls() {
1653
+ const calls = await fetchJSON('/api/dashboard/calls?limit=50');
1654
+ const body = $('#calls-body');
1655
+ if (!calls.length) {
1656
+ body.innerHTML = '<tr><td colspan="8" class="empty">No calls yet. Waiting for incoming calls...</td></tr>';
1657
+ return;
1658
+ }
1659
+ body.innerHTML = calls.map(c => {
1660
+ const m = c.metrics || {};
1661
+ const cost = m.cost || {};
1662
+ const lat = m.latency_avg || {};
1663
+ const mode = m.provider_mode || '-';
1664
+ const turns = m.turns ? m.turns.length : 0;
1665
+ const modeClass = mode === 'pipeline' ? 'badge-pipeline' : 'badge-realtime';
1666
+ return '<tr class="clickable" onclick="showCall(\\''+esc(c.call_id)+'\\')">'+
1667
+ '<td><code>'+shortId(c.call_id)+'</code></td>'+
1668
+ '<td>'+(esc(c.direction) || '-')+'</td>'+
1669
+ '<td>'+(esc(c.caller) || '-')+' &rarr; '+(esc(c.callee) || '-')+'</td>'+
1670
+ '<td>'+fmtDur(m.duration_seconds)+'</td>'+
1671
+ '<td><span class="badge '+modeClass+'">'+esc(mode)+'</span></td>'+
1672
+ '<td class="cost">'+fmt\\$(cost.total || 0)+'</td>'+
1673
+ '<td class="latency">'+fmtMs(lat.total_ms || 0)+'</td>'+
1674
+ '<td>'+turns+'</td></tr>';
1675
+ }).join('');
1676
+ }
1677
+
1678
+ async function refreshActive() {
1679
+ const active = await fetchJSON('/api/dashboard/active');
1680
+ const body = $('#active-body');
1681
+ if (!active.length) {
1682
+ body.innerHTML = '<tr><td colspan="6" class="empty">No active calls</td></tr>';
1683
+ return;
1684
+ }
1685
+ const now = Date.now() / 1000;
1686
+ body.innerHTML = active.map(c => {
1687
+ const dur = c.started_at ? Math.round(now - c.started_at) : 0;
1688
+ const turns = c.turns ? c.turns.length : 0;
1689
+ return '<tr>'+
1690
+ '<td><code>'+shortId(c.call_id)+'</code></td>'+
1691
+ '<td>'+(esc(c.caller) || '-')+'</td>'+
1692
+ '<td>'+(esc(c.callee) || '-')+'</td>'+
1693
+ '<td>'+(esc(c.direction) || '-')+'</td>'+
1694
+ '<td>'+fmtDur(dur)+'</td>'+
1695
+ '<td>'+turns+'</td></tr>';
1696
+ }).join('');
1697
+ }
1698
+
1699
+ async function showCall(callId) {
1700
+ const c = await fetchJSON('/api/dashboard/calls/'+encodeURIComponent(callId));
1701
+ if (c.error) return;
1702
+ const m = c.metrics || {};
1703
+ const cost = m.cost || {};
1704
+ const latAvg = m.latency_avg || {};
1705
+ const latP95 = m.latency_p95 || {};
1706
+ const turns = m.turns || [];
1707
+
1708
+ $('#modal-title').textContent = 'Call '+shortId(c.call_id);
1709
+
1710
+ var html = '<div class="detail-grid">'+
1711
+ '<div class="detail-card">'+
1712
+ '<h3>Overview</h3>'+
1713
+ '<div class="detail-row"><span class="k">Call ID</span><span>'+esc(c.call_id)+'</span></div>'+
1714
+ '<div class="detail-row"><span class="k">Direction</span><span>'+(esc(c.direction) || '-')+'</span></div>'+
1715
+ '<div class="detail-row"><span class="k">From</span><span>'+(esc(c.caller) || '-')+'</span></div>'+
1716
+ '<div class="detail-row"><span class="k">To</span><span>'+(esc(c.callee) || '-')+'</span></div>'+
1717
+ '<div class="detail-row"><span class="k">Duration</span><span>'+fmtDur(m.duration_seconds)+'</span></div>'+
1718
+ '<div class="detail-row"><span class="k">Mode</span><span>'+(esc(m.provider_mode) || '-')+'</span></div>'+
1719
+ '<div class="detail-row"><span class="k">STT</span><span>'+(esc(m.stt_provider) || '-')+'</span></div>'+
1720
+ '<div class="detail-row"><span class="k">TTS</span><span>'+(esc(m.tts_provider) || '-')+'</span></div>'+
1721
+ '<div class="detail-row"><span class="k">LLM</span><span>'+(esc(m.llm_provider) || '-')+'</span></div>'+
1722
+ '<div class="detail-row"><span class="k">Telephony</span><span>'+(esc(m.telephony_provider) || '-')+'</span></div>'+
1723
+ '</div>'+
1724
+ '<div class="detail-card">'+
1725
+ '<h3>Cost Breakdown</h3>'+
1726
+ '<div class="detail-row"><span class="k">STT</span><span class="cost">'+fmt\\$(cost.stt || 0)+'</span></div>'+
1727
+ '<div class="detail-row"><span class="k">LLM</span><span class="cost">'+fmt\\$(cost.llm || 0)+'</span></div>'+
1728
+ '<div class="detail-row"><span class="k">TTS</span><span class="cost">'+fmt\\$(cost.tts || 0)+'</span></div>'+
1729
+ '<div class="detail-row"><span class="k">Telephony</span><span class="cost">'+fmt\\$(cost.telephony || 0)+'</span></div>'+
1730
+ '<div class="detail-row" style="border-top:1px solid var(--border);padding-top:6px;margin-top:4px">'+
1731
+ '<span class="k" style="font-weight:600">Total</span><span class="cost" style="font-weight:700">'+fmt\\$(cost.total || 0)+'</span>'+
1732
+ '</div>'+
1733
+ '<h3 style="margin-top:14px">Latency (avg / p95)</h3>'+
1734
+ '<div class="detail-row"><span class="k">STT</span><span class="latency">'+fmtMs(latAvg.stt_ms)+' / '+fmtMs(latP95.stt_ms)+'</span></div>'+
1735
+ '<div class="detail-row"><span class="k">LLM</span><span class="latency">'+fmtMs(latAvg.llm_ms)+' / '+fmtMs(latP95.llm_ms)+'</span></div>'+
1736
+ '<div class="detail-row"><span class="k">TTS</span><span class="latency">'+fmtMs(latAvg.tts_ms)+' / '+fmtMs(latP95.tts_ms)+'</span></div>'+
1737
+ '<div class="detail-row"><span class="k">Total</span><span class="latency" style="font-weight:700">'+fmtMs(latAvg.total_ms)+' / '+fmtMs(latP95.total_ms)+'</span></div>'+
1738
+ '</div></div>';
1739
+
1740
+ if (turns.length) {
1741
+ var maxMs = Math.max.apply(null, turns.map(function(t) {
1742
+ var l = t.latency || {};
1743
+ return (l.stt_ms||0) + (l.llm_ms||0) + (l.tts_ms||0) + (l.total_ms||0);
1744
+ }).concat([1]));
1745
+ html += '<div class="detail-card turns-table"><h3>Turns ('+turns.length+')</h3>'+
1746
+ '<table><thead><tr><th>#</th><th>User</th><th>Agent</th><th>Latency</th><th>Breakdown</th></tr></thead><tbody>';
1747
+ turns.forEach(function(t, i) {
1748
+ var l = t.latency || {};
1749
+ var total = l.total_ms || ((l.stt_ms||0) + (l.llm_ms||0) + (l.tts_ms||0));
1750
+ var scale = total > 0 ? 120 / maxMs : 0;
1751
+ var sttW = (l.stt_ms||0) * scale;
1752
+ var llmW = (l.llm_ms||0) * scale;
1753
+ var ttsW = (l.tts_ms||0) * scale;
1754
+ var totalW = total > 0 && sttW === 0 && llmW === 0 && ttsW === 0 ? total * scale : 0;
1755
+ html += '<tr>'+
1756
+ '<td>'+(t.turn_index !== undefined ? t.turn_index : i)+'</td>'+
1757
+ '<td title="'+esc(t.user_text||'')+'">'+esc((t.user_text||'').slice(0,40))+((t.user_text||'').length>40?'...':'')+'</td>'+
1758
+ '<td title="'+esc(t.agent_text||'')+'">'+esc((t.agent_text||'').slice(0,40))+((t.agent_text||'').length>40?'...':'')+'</td>'+
1759
+ '<td class="latency">'+fmtMs(total)+'</td>'+
1760
+ '<td><div class="bar-container">'+
1761
+ (sttW > 0 ? '<div class="bar-stt" style="width:'+sttW+'px" title="STT '+fmtMs(l.stt_ms)+'"></div>' : '')+
1762
+ (llmW > 0 ? '<div class="bar-llm" style="width:'+llmW+'px" title="LLM '+fmtMs(l.llm_ms)+'"></div>' : '')+
1763
+ (ttsW > 0 ? '<div class="bar-tts" style="width:'+ttsW+'px" title="TTS '+fmtMs(l.tts_ms)+'"></div>' : '')+
1764
+ (totalW > 0 ? '<div class="bar-llm" style="width:'+totalW+'px" title="Total '+fmtMs(total)+'"></div>' : '')+
1765
+ '</div></td></tr>';
1766
+ });
1767
+ html += '</tbody></table>'+
1768
+ '<div style="margin-top:8px;font-size:11px;color:var(--text2)">'+
1769
+ '<span style="color:var(--blue)">&#9632;</span> STT &nbsp;'+
1770
+ '<span style="color:var(--accent)">&#9632;</span> LLM &nbsp;'+
1771
+ '<span style="color:var(--yellow)">&#9632;</span> TTS'+
1772
+ '</div></div>';
1773
+ }
1774
+
1775
+ var transcript = c.transcript || [];
1776
+ if (transcript.length) {
1777
+ html += '<div class="detail-card" style="margin-top:16px"><h3>Transcript</h3><div class="transcript-box">';
1778
+ transcript.forEach(function(msg) {
1779
+ var role = esc(msg.role || 'unknown');
1780
+ html += '<div class="msg '+role+'"><span class="role">'+role+'</span>'+esc(msg.text || '')+'</div>';
1781
+ });
1782
+ html += '</div></div>';
1783
+ }
1784
+
1785
+ $('#modal-body').innerHTML = html;
1786
+ $('#modal').classList.add('open');
1787
+ }
1788
+
1789
+ function closeModal() { $('#modal').classList.remove('open'); }
1790
+ $('#modal').addEventListener('click', function(e) { if (e.target === $('#modal')) closeModal(); });
1791
+ document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeModal(); });
1792
+
1793
+ async function refresh() {
1794
+ try {
1795
+ await Promise.all([refreshAggregates(), refreshCalls(), refreshActive()]);
1796
+ $('#status-text').textContent = 'Listening';
1797
+ } catch (e) {
1798
+ $('#status-text').textContent = 'Connection error';
1799
+ }
1800
+ }
1801
+
1802
+ refresh();
1803
+
1804
+ if (typeof EventSource !== 'undefined') {
1805
+ var tokenParam = new URLSearchParams(window.location.search).get('token');
1806
+ var sseUrl = '/api/dashboard/events' + (tokenParam ? '?' + new URLSearchParams({ token: tokenParam }).toString() : '');
1807
+ var sseBackoff = 1000;
1808
+ var sseFailures = 0;
1809
+ var SSE_MAX_BACKOFF = 30000;
1810
+ var SSE_MAX_FAILURES = 5;
1811
+ var sseTimer = null;
1812
+
1813
+ function connectSSE() {
1814
+ var es = new EventSource(sseUrl);
1815
+ function onEvent() {
1816
+ sseBackoff = 1000;
1817
+ sseFailures = 0;
1818
+ }
1819
+ es.addEventListener('call_start', function() { onEvent(); refresh(); });
1820
+ es.addEventListener('turn_complete', function() { onEvent(); refreshAggregates(); });
1821
+ es.addEventListener('call_end', function() { onEvent(); refresh(); });
1822
+ es.onerror = function() {
1823
+ es.close();
1824
+ sseFailures++;
1825
+ if (sseFailures >= SSE_MAX_FAILURES) {
1826
+ $('#status-text').textContent = 'Polling (SSE unavailable)';
1827
+ setInterval(refresh, 5000);
1828
+ return;
1829
+ }
1830
+ $('#status-text').textContent = 'Reconnecting...';
1831
+ sseTimer = setTimeout(function() {
1832
+ connectSSE();
1833
+ }, sseBackoff);
1834
+ sseBackoff = Math.min(sseBackoff * 2, SSE_MAX_BACKOFF);
1835
+ };
1836
+ }
1837
+ connectSSE();
1838
+ } else {
1839
+ setInterval(refresh, 3000);
1840
+ }
1841
+ </script>
1842
+ </body>
1843
+ </html>`;
1844
+
1845
+ // src/dashboard/routes.ts
1846
+ function mountDashboard(app, store, token = "") {
1847
+ const auth = makeAuthMiddleware(token);
1848
+ app.get("/", auth, (_req, res) => {
1849
+ res.type("text/html").send(DASHBOARD_HTML);
1850
+ });
1851
+ app.get("/api/dashboard/calls", auth, (req, res) => {
1852
+ const limit = Math.min(parseInt(req.query.limit || "50", 10) || 50, 1e3);
1853
+ const offset = parseInt(req.query.offset || "0", 10) || 0;
1854
+ res.json(store.getCalls(limit, offset));
1855
+ });
1856
+ app.get("/api/dashboard/calls/:callId", auth, (req, res) => {
1857
+ const call = store.getCall(String(req.params.callId));
1858
+ if (!call) {
1859
+ res.status(404).json({ error: "Not found" });
1860
+ return;
1861
+ }
1862
+ res.json(call);
1863
+ });
1864
+ app.get("/api/dashboard/active", auth, (_req, res) => {
1865
+ res.json(store.getActiveCalls());
1866
+ });
1867
+ app.get("/api/dashboard/aggregates", auth, (_req, res) => {
1868
+ res.json(store.getAggregates());
1869
+ });
1870
+ app.get("/api/dashboard/events", auth, (req, res) => {
1871
+ res.writeHead(200, {
1872
+ "Content-Type": "text/event-stream",
1873
+ "Cache-Control": "no-cache",
1874
+ "Connection": "keep-alive"
1875
+ });
1876
+ const listener = (event) => {
1877
+ const data = JSON.stringify(event.data);
1878
+ res.write(`event: ${event.type}
1879
+ data: ${data}
1880
+
1881
+ `);
1882
+ };
1883
+ store.on("sse", listener);
1884
+ const keepalive = setInterval(() => {
1885
+ res.write(": keepalive\n\n");
1886
+ }, 3e4);
1887
+ req.on("close", () => {
1888
+ clearInterval(keepalive);
1889
+ store.off("sse", listener);
1890
+ });
1891
+ });
1892
+ app.get("/api/dashboard/export/calls", auth, (req, res) => {
1893
+ const fmt = req.query.format || "json";
1894
+ const fromDate = req.query.from || "";
1895
+ const toDate = req.query.to || "";
1896
+ let fromTs = 0;
1897
+ let toTs = 0;
1898
+ if (fromDate) {
1899
+ const d = new Date(fromDate);
1900
+ if (!isNaN(d.getTime())) fromTs = d.getTime() / 1e3;
1901
+ }
1902
+ if (toDate) {
1903
+ const d = new Date(toDate);
1904
+ if (!isNaN(d.getTime())) toTs = d.getTime() / 1e3;
1905
+ }
1906
+ const calls = fromTs || toTs ? store.getCallsInRange(fromTs, toTs) : store.getCalls(1e4);
1907
+ if (fmt === "csv") {
1908
+ const csvData = callsToCsv(calls);
1909
+ res.setHeader("Content-Type", "text/csv");
1910
+ res.setHeader("Content-Disposition", "attachment; filename=patter_calls.csv");
1911
+ res.send(csvData);
1912
+ } else {
1913
+ const jsonData = callsToJson(calls);
1914
+ res.setHeader("Content-Type", "application/json");
1915
+ res.setHeader("Content-Disposition", "attachment; filename=patter_calls.json");
1916
+ res.send(jsonData);
1917
+ }
1918
+ });
1919
+ }
1920
+ function mountApi(app, store, token = "") {
1921
+ const auth = makeAuthMiddleware(token);
1922
+ app.get("/api/v1/calls", auth, (req, res) => {
1923
+ const limit = Math.min(parseInt(req.query.limit || "50", 10) || 50, 1e3);
1924
+ const offset = parseInt(req.query.offset || "0", 10) || 0;
1925
+ const calls = store.getCalls(limit, offset);
1926
+ res.json({
1927
+ data: calls,
1928
+ pagination: {
1929
+ limit,
1930
+ offset,
1931
+ count: calls.length,
1932
+ total: store.callCount
1933
+ }
1934
+ });
1935
+ });
1936
+ app.get("/api/v1/calls/active", auth, (_req, res) => {
1937
+ const active = store.getActiveCalls();
1938
+ res.json({ data: active, count: active.length });
1939
+ });
1940
+ app.get("/api/v1/calls/:callId", auth, (req, res) => {
1941
+ const call = store.getCall(String(req.params.callId));
1942
+ if (!call) {
1943
+ res.status(404).json({ error: "Call not found" });
1944
+ return;
1945
+ }
1946
+ res.json({ data: call });
1947
+ });
1948
+ app.get("/api/v1/analytics/overview", auth, (_req, res) => {
1949
+ res.json({ data: store.getAggregates() });
1950
+ });
1951
+ app.get("/api/v1/analytics/costs", auth, (req, res) => {
1952
+ const fromDate = req.query.from || "";
1953
+ const toDate = req.query.to || "";
1954
+ let fromTs = 0;
1955
+ let toTs = 0;
1956
+ if (fromDate) {
1957
+ const d = new Date(fromDate);
1958
+ if (!isNaN(d.getTime())) fromTs = d.getTime() / 1e3;
1959
+ }
1960
+ if (toDate) {
1961
+ const d = new Date(toDate);
1962
+ if (!isNaN(d.getTime())) toTs = d.getTime() / 1e3;
1963
+ }
1964
+ const calls = fromTs || toTs ? store.getCallsInRange(fromTs, toTs) : store.getCalls(1e4);
1965
+ let totalCost = 0;
1966
+ let costStt = 0;
1967
+ let costTts = 0;
1968
+ let costLlm = 0;
1969
+ let costTelephony = 0;
1970
+ let callsWithCost = 0;
1971
+ for (const call of calls) {
1972
+ const m = call.metrics;
1973
+ if (!m) continue;
1974
+ const cost = m.cost || {};
1975
+ totalCost += cost.total || 0;
1976
+ costStt += cost.stt || 0;
1977
+ costTts += cost.tts || 0;
1978
+ costLlm += cost.llm || 0;
1979
+ costTelephony += cost.telephony || 0;
1980
+ callsWithCost++;
1981
+ }
1982
+ res.json({
1983
+ data: {
1984
+ total_cost: Math.round(totalCost * 1e6) / 1e6,
1985
+ breakdown: {
1986
+ stt: Math.round(costStt * 1e6) / 1e6,
1987
+ tts: Math.round(costTts * 1e6) / 1e6,
1988
+ llm: Math.round(costLlm * 1e6) / 1e6,
1989
+ telephony: Math.round(costTelephony * 1e6) / 1e6
1990
+ },
1991
+ calls_analyzed: callsWithCost,
1992
+ period: {
1993
+ from: fromDate || null,
1994
+ to: toDate || null
1995
+ }
1996
+ }
1997
+ });
1998
+ });
1999
+ }
2000
+
2001
+ // src/remote-message.ts
2002
+ var import_node_crypto2 = __toESM(require("crypto"));
2003
+ init_logger();
2004
+ var MAX_RESPONSE_BYTES = 64 * 1024;
2005
+ var RemoteMessageHandler = class {
2006
+ webhookSecret;
2007
+ /**
2008
+ * @param webhookSecret Optional HMAC secret. When provided, outgoing webhook
2009
+ * requests include an `X-Patter-Signature` header so the receiver can
2010
+ * verify the payload originated from Patter.
2011
+ */
2012
+ constructor(webhookSecret) {
2013
+ this.webhookSecret = webhookSecret;
2014
+ }
2015
+ /**
2016
+ * Compute HMAC-SHA256 hex digest for the given body.
2017
+ */
2018
+ signPayload(body) {
2019
+ if (!this.webhookSecret) {
2020
+ throw new Error("Cannot sign without a webhookSecret");
2021
+ }
2022
+ return import_node_crypto2.default.createHmac("sha256", this.webhookSecret).update(body).digest("hex");
2023
+ }
2024
+ /**
2025
+ * Release resources held by this handler.
2026
+ */
2027
+ close() {
2028
+ }
2029
+ /**
2030
+ * POST transcript to HTTP webhook, return response text.
2031
+ *
2032
+ * The webhook receives a JSON payload:
2033
+ * { text, call_id, caller, callee, history }
2034
+ *
2035
+ * The response can be plain text or JSON { text: "..." }.
2036
+ *
2037
+ * When `webhookSecret` was provided at construction time, the request
2038
+ * includes an `X-Patter-Signature` header with the HMAC-SHA256 hex
2039
+ * digest of the JSON body.
2040
+ */
2041
+ async callWebhook(url, data) {
2042
+ if (url.startsWith("http://")) {
2043
+ getLogger().warn(
2044
+ "Webhook URL uses unencrypted http:// \u2014 call transcripts and phone numbers will be sent in plaintext. Use https:// in production."
2045
+ );
2046
+ }
2047
+ const body = JSON.stringify(data);
2048
+ const headers = { "Content-Type": "application/json" };
2049
+ if (this.webhookSecret) {
2050
+ headers["X-Patter-Signature"] = this.signPayload(body);
2051
+ }
2052
+ const response = await fetch(url, {
2053
+ method: "POST",
2054
+ headers,
2055
+ body,
2056
+ signal: AbortSignal.timeout(3e4)
2057
+ });
2058
+ if (!response.ok) {
2059
+ throw new Error(`Webhook returned HTTP ${response.status}`);
2060
+ }
2061
+ const text = await response.text();
2062
+ if (text.length > MAX_RESPONSE_BYTES) {
2063
+ throw new Error(`Webhook response too large: ${text.length} bytes (max ${MAX_RESPONSE_BYTES})`);
2064
+ }
2065
+ const contentType = response.headers.get("content-type") || "";
2066
+ if (contentType.includes("application/json")) {
2067
+ try {
2068
+ const body2 = JSON.parse(text);
2069
+ if (typeof body2 === "object" && body2 !== null && "text" in body2) {
2070
+ return String(body2.text);
2071
+ }
2072
+ return String(body2);
2073
+ } catch {
2074
+ return text;
2075
+ }
2076
+ }
2077
+ return text;
2078
+ }
2079
+ /**
2080
+ * Send transcript via WebSocket, yield response chunks.
2081
+ *
2082
+ * Sends the message data as JSON. Receives one or more JSON frames
2083
+ * with { text: "..." } - multiple frames enable streaming.
2084
+ * A frame with { done: true } signals end of response.
2085
+ */
2086
+ async *callWebSocket(url, data) {
2087
+ if (url.startsWith("ws://")) {
2088
+ getLogger().warn(
2089
+ "WebSocket URL uses unencrypted ws:// \u2014 call transcripts and phone numbers will be sent in plaintext. Use wss:// in production."
2090
+ );
2091
+ }
2092
+ const { WebSocket: WebSocket5 } = await import("ws");
2093
+ const ws = new WebSocket5(url);
2094
+ const chunks = [];
2095
+ let done = false;
2096
+ let error = null;
2097
+ await new Promise((resolve, reject) => {
2098
+ ws.on("open", () => {
2099
+ ws.send(JSON.stringify(data));
2100
+ resolve();
2101
+ });
2102
+ ws.on("error", (err) => {
2103
+ error = err;
2104
+ reject(err);
2105
+ });
2106
+ });
2107
+ let resolveNext = null;
2108
+ ws.on("message", (raw) => {
2109
+ const rawStr = raw.toString();
2110
+ let text = null;
2111
+ try {
2112
+ const frame = JSON.parse(rawStr);
2113
+ if (typeof frame === "object" && frame !== null) {
2114
+ if (frame.done) {
2115
+ done = true;
2116
+ ws.close();
2117
+ if (resolveNext) resolveNext(null);
2118
+ return;
2119
+ }
2120
+ text = frame.text || null;
2121
+ } else {
2122
+ text = String(frame);
2123
+ }
2124
+ } catch {
2125
+ text = rawStr;
2126
+ }
2127
+ if (text && resolveNext) {
2128
+ resolveNext(text);
2129
+ resolveNext = null;
2130
+ } else if (text) {
2131
+ chunks.push(text);
2132
+ }
2133
+ });
2134
+ ws.on("close", () => {
2135
+ done = true;
2136
+ if (resolveNext) resolveNext(null);
2137
+ });
2138
+ ws.on("error", (err) => {
2139
+ error = err;
2140
+ done = true;
2141
+ if (resolveNext) resolveNext(null);
2142
+ });
2143
+ while (chunks.length > 0) {
2144
+ yield chunks.shift();
2145
+ }
2146
+ while (!done && !error) {
2147
+ const text = await new Promise((resolve) => {
2148
+ if (chunks.length > 0) {
2149
+ resolve(chunks.shift());
2150
+ } else {
2151
+ resolveNext = resolve;
2152
+ }
2153
+ });
2154
+ if (text === null) break;
2155
+ yield text;
2156
+ }
2157
+ if (error) throw error;
2158
+ }
2159
+ };
2160
+ function isRemoteUrl(onMessage) {
2161
+ if (typeof onMessage !== "string") return false;
2162
+ return onMessage.startsWith("http://") || onMessage.startsWith("https://") || onMessage.startsWith("ws://") || onMessage.startsWith("wss://");
2163
+ }
2164
+ function isWebSocketUrl(url) {
2165
+ return url.startsWith("ws://") || url.startsWith("wss://");
2166
+ }
2167
+
2168
+ // src/providers/elevenlabs-tts.ts
2169
+ var ELEVENLABS_BASE_URL = "https://api.elevenlabs.io/v1";
2170
+ var ElevenLabsTTS = class {
2171
+ constructor(apiKey, voiceId = "21m00Tcm4TlvDq8ikWAM", modelId = "eleven_turbo_v2_5", outputFormat = "pcm_16000") {
2172
+ this.apiKey = apiKey;
2173
+ this.voiceId = voiceId;
2174
+ this.modelId = modelId;
2175
+ this.outputFormat = outputFormat;
2176
+ }
2177
+ /**
2178
+ * Synthesise text to speech and return the full audio as a single Buffer.
2179
+ *
2180
+ * For large chunks (or when latency matters) call `synthesizeStream` instead.
2181
+ */
2182
+ async synthesize(text) {
2183
+ const chunks = [];
2184
+ for await (const chunk of this.synthesizeStream(text)) {
2185
+ chunks.push(chunk);
2186
+ }
2187
+ return Buffer.concat(chunks);
2188
+ }
2189
+ /**
2190
+ * Synthesise text and yield audio chunks as they arrive (streaming).
2191
+ *
2192
+ * The yielded buffers are raw PCM at 16 kHz (or whatever `outputFormat` is
2193
+ * configured to).
2194
+ */
2195
+ async *synthesizeStream(text) {
2196
+ const url = `${ELEVENLABS_BASE_URL}/text-to-speech/${encodeURIComponent(this.voiceId)}/stream?output_format=${encodeURIComponent(this.outputFormat)}`;
2197
+ const response = await fetch(url, {
2198
+ method: "POST",
2199
+ headers: {
2200
+ "xi-api-key": this.apiKey,
2201
+ "Content-Type": "application/json"
2202
+ },
2203
+ body: JSON.stringify({ text, model_id: this.modelId })
2204
+ });
2205
+ if (!response.ok) {
2206
+ const body = await response.text();
2207
+ throw new Error(`ElevenLabs TTS error ${response.status}: ${body}`);
2208
+ }
2209
+ if (!response.body) {
2210
+ throw new Error("ElevenLabs TTS: no response body");
2211
+ }
2212
+ const reader = response.body.getReader();
2213
+ try {
2214
+ while (true) {
2215
+ const { done, value } = await reader.read();
2216
+ if (done) break;
2217
+ if (value && value.length > 0) {
2218
+ yield Buffer.from(value);
2219
+ }
2220
+ }
2221
+ } finally {
2222
+ reader.releaseLock();
2223
+ }
2224
+ }
2225
+ };
2226
+
2227
+ // src/providers/openai-tts.ts
2228
+ var OPENAI_TTS_URL = "https://api.openai.com/v1/audio/speech";
2229
+ var OpenAITTS = class _OpenAITTS {
2230
+ constructor(apiKey, voice = "alloy", model = "tts-1") {
2231
+ this.apiKey = apiKey;
2232
+ this.voice = voice;
2233
+ this.model = model;
2234
+ }
2235
+ /**
2236
+ * Synthesise text to speech and return the full audio as a single Buffer.
2237
+ *
2238
+ * For large chunks (or when latency matters) call `synthesizeStream` instead.
2239
+ */
2240
+ async synthesize(text) {
2241
+ const chunks = [];
2242
+ for await (const chunk of this.synthesizeStream(text)) {
2243
+ chunks.push(chunk);
2244
+ }
2245
+ return Buffer.concat(chunks);
2246
+ }
2247
+ /**
2248
+ * Synthesise text and yield audio chunks as they arrive (streaming).
2249
+ *
2250
+ * OpenAI returns 24 kHz PCM16; each chunk is resampled to 16 kHz before
2251
+ * yielding so the output is ready for telephony pipelines.
2252
+ */
2253
+ async *synthesizeStream(text) {
2254
+ const response = await fetch(OPENAI_TTS_URL, {
2255
+ method: "POST",
2256
+ headers: {
2257
+ "Authorization": `Bearer ${this.apiKey}`,
2258
+ "Content-Type": "application/json"
2259
+ },
2260
+ body: JSON.stringify({
2261
+ model: this.model,
2262
+ input: text,
2263
+ voice: this.voice,
2264
+ response_format: "pcm"
2265
+ })
2266
+ });
2267
+ if (!response.ok) {
2268
+ const body = await response.text();
2269
+ throw new Error(`OpenAI TTS error ${response.status}: ${body}`);
2270
+ }
2271
+ if (!response.body) {
2272
+ throw new Error("OpenAI TTS: no response body");
2273
+ }
2274
+ const reader = response.body.getReader();
2275
+ try {
2276
+ while (true) {
2277
+ const { done, value } = await reader.read();
2278
+ if (done) break;
2279
+ if (value && value.length > 0) {
2280
+ yield _OpenAITTS.resample24kTo16k(Buffer.from(value));
2281
+ }
2282
+ }
2283
+ } finally {
2284
+ reader.releaseLock();
2285
+ }
2286
+ }
2287
+ /**
2288
+ * Resample 24 kHz PCM16-LE to 16 kHz by taking 2 out of every 3 samples.
2289
+ *
2290
+ * For each group of 3 input samples the first is kept as-is and the second
2291
+ * output sample is the average of input samples 2 and 3. This matches the
2292
+ * Python SDK implementation.
2293
+ */
2294
+ static resample24kTo16k(audio) {
2295
+ if (audio.length < 2) return audio;
2296
+ const sampleCount = Math.floor(audio.length / 2);
2297
+ const samples = new Int16Array(sampleCount);
2298
+ for (let i = 0; i < sampleCount; i++) {
2299
+ samples[i] = audio.readInt16LE(i * 2);
2300
+ }
2301
+ const resampled = [];
2302
+ for (let i = 0; i < samples.length; i += 3) {
2303
+ resampled.push(samples[i]);
2304
+ if (i + 1 < samples.length) {
2305
+ if (i + 2 < samples.length) {
2306
+ resampled.push(Math.trunc((samples[i + 1] + samples[i + 2]) / 2));
2307
+ } else {
2308
+ resampled.push(samples[i + 1]);
2309
+ }
2310
+ }
2311
+ }
2312
+ const out = Buffer.alloc(resampled.length * 2);
2313
+ for (let i = 0; i < resampled.length; i++) {
2314
+ out.writeInt16LE(resampled[i], i * 2);
2315
+ }
2316
+ return out;
2317
+ }
2318
+ };
2319
+
2320
+ // src/metrics.ts
2321
+ function round(value, decimals) {
2322
+ const factor = 10 ** decimals;
2323
+ return Math.round(value * factor) / factor;
2324
+ }
2325
+ function hrTimeMs() {
2326
+ const [sec, ns] = process.hrtime();
2327
+ return sec * 1e3 + ns / 1e6;
2328
+ }
2329
+ function p95(values) {
2330
+ if (values.length === 0) return 0;
2331
+ const sorted = [...values].sort((a, b) => a - b);
2332
+ const idx = Math.min(Math.floor(sorted.length * 0.95), sorted.length - 1);
2333
+ return sorted[idx];
2334
+ }
2335
+ var CallMetricsAccumulator = class {
2336
+ callId;
2337
+ providerMode;
2338
+ telephonyProvider;
2339
+ sttProvider;
2340
+ ttsProvider;
2341
+ llmProvider;
2342
+ _pricing;
2343
+ _callStart;
2344
+ _turns = [];
2345
+ // Per-turn timing state
2346
+ _turnStart = null;
2347
+ _sttComplete = null;
2348
+ _llmComplete = null;
2349
+ _ttsFirstByte = null;
2350
+ _turnUserText = "";
2351
+ _turnSttAudioSeconds = 0;
2352
+ // Cumulative usage counters
2353
+ _totalSttAudioSeconds = 0;
2354
+ _totalTtsCharacters = 0;
2355
+ _totalRealtimeCost = 0;
2356
+ _sttByteCount = 0;
2357
+ _sttSampleRate = 16e3;
2358
+ _sttBytesPerSample = 2;
2359
+ _actualTelephonyCost = null;
2360
+ _actualSttCost = null;
2361
+ constructor(opts) {
2362
+ this.callId = opts.callId;
2363
+ this.providerMode = opts.providerMode;
2364
+ this.telephonyProvider = opts.telephonyProvider;
2365
+ this.sttProvider = opts.sttProvider ?? "";
2366
+ this.ttsProvider = opts.ttsProvider ?? "";
2367
+ this.llmProvider = opts.llmProvider ?? "";
2368
+ this._pricing = mergePricing(opts.pricing);
2369
+ this._callStart = hrTimeMs();
2370
+ }
2371
+ /** Configure audio format for STT byte-to-seconds conversion. */
2372
+ configureSttFormat(sampleRate = 16e3, bytesPerSample = 2) {
2373
+ this._sttSampleRate = sampleRate;
2374
+ this._sttBytesPerSample = bytesPerSample;
2375
+ }
2376
+ // ---- Turn lifecycle ----
2377
+ startTurn() {
2378
+ this._turnStart = hrTimeMs();
2379
+ this._sttComplete = null;
2380
+ this._llmComplete = null;
2381
+ this._ttsFirstByte = null;
2382
+ this._turnUserText = "";
2383
+ this._turnSttAudioSeconds = 0;
2384
+ }
2385
+ recordSttComplete(text, audioSeconds = 0) {
2386
+ this._sttComplete = hrTimeMs();
2387
+ this._turnUserText = text;
2388
+ this._turnSttAudioSeconds = audioSeconds;
2389
+ this._totalSttAudioSeconds += audioSeconds;
2390
+ }
2391
+ recordLlmComplete() {
2392
+ this._llmComplete = hrTimeMs();
2393
+ }
2394
+ recordTtsFirstByte() {
2395
+ if (this._ttsFirstByte === null) {
2396
+ this._ttsFirstByte = hrTimeMs();
2397
+ }
2398
+ }
2399
+ recordTtsComplete(text) {
2400
+ this._totalTtsCharacters += text.length;
2401
+ }
2402
+ recordTurnComplete(agentText) {
2403
+ const latency = this._computeTurnLatency();
2404
+ const turn = {
2405
+ turn_index: this._turns.length,
2406
+ user_text: this._turnUserText,
2407
+ agent_text: agentText,
2408
+ latency,
2409
+ stt_audio_seconds: this._turnSttAudioSeconds,
2410
+ tts_characters: agentText.length,
2411
+ timestamp: Date.now() / 1e3
2412
+ };
2413
+ this._turns.push(turn);
2414
+ this._resetTurnState();
2415
+ return turn;
2416
+ }
2417
+ recordTurnInterrupted() {
2418
+ if (this._turnStart === null) return null;
2419
+ const latency = this._computeTurnLatency();
2420
+ const turn = {
2421
+ turn_index: this._turns.length,
2422
+ user_text: this._turnUserText,
2423
+ agent_text: "[interrupted]",
2424
+ latency,
2425
+ stt_audio_seconds: this._turnSttAudioSeconds,
2426
+ tts_characters: 0,
2427
+ timestamp: Date.now() / 1e3
2428
+ };
2429
+ this._turns.push(turn);
2430
+ this._resetTurnState();
2431
+ return turn;
2432
+ }
2433
+ // ---- Usage tracking ----
2434
+ addSttAudioBytes(byteCount) {
2435
+ this._sttByteCount += byteCount;
2436
+ }
2437
+ recordRealtimeUsage(usage) {
2438
+ this._totalRealtimeCost += calculateRealtimeCost(usage, this._pricing);
2439
+ }
2440
+ setActualTelephonyCost(cost) {
2441
+ this._actualTelephonyCost = cost;
2442
+ }
2443
+ setActualSttCost(cost) {
2444
+ this._actualSttCost = cost;
2445
+ }
2446
+ // ---- Finalize ----
2447
+ endCall() {
2448
+ const duration = (hrTimeMs() - this._callStart) / 1e3;
2449
+ if (this._totalSttAudioSeconds === 0 && this._sttByteCount > 0) {
2450
+ this._totalSttAudioSeconds = this._sttByteCount / (this._sttSampleRate * this._sttBytesPerSample);
2451
+ }
2452
+ const cost = this._computeCost(duration);
2453
+ const latencyAvg = this._computeAverageLatency();
2454
+ const latencyP95 = this._computeP95Latency();
2455
+ return {
2456
+ call_id: this.callId,
2457
+ duration_seconds: round(duration, 2),
2458
+ turns: [...this._turns],
2459
+ cost,
2460
+ latency_avg: latencyAvg,
2461
+ latency_p95: latencyP95,
2462
+ provider_mode: this.providerMode,
2463
+ stt_provider: this.sttProvider,
2464
+ tts_provider: this.ttsProvider,
2465
+ llm_provider: this.llmProvider,
2466
+ telephony_provider: this.telephonyProvider
2467
+ };
2468
+ }
2469
+ getCostSoFar() {
2470
+ const duration = (hrTimeMs() - this._callStart) / 1e3;
2471
+ return this._computeCost(duration);
2472
+ }
2473
+ // ---- Internal ----
2474
+ _resetTurnState() {
2475
+ this._turnStart = null;
2476
+ this._sttComplete = null;
2477
+ this._llmComplete = null;
2478
+ this._ttsFirstByte = null;
2479
+ this._turnUserText = "";
2480
+ this._turnSttAudioSeconds = 0;
2481
+ }
2482
+ _computeTurnLatency() {
2483
+ let stt_ms = 0;
2484
+ let llm_ms = 0;
2485
+ let tts_ms = 0;
2486
+ let total_ms = 0;
2487
+ if (this._turnStart !== null && this._sttComplete !== null) {
2488
+ stt_ms = this._sttComplete - this._turnStart;
2489
+ }
2490
+ if (this._sttComplete !== null && this._llmComplete !== null) {
2491
+ llm_ms = this._llmComplete - this._sttComplete;
2492
+ }
2493
+ if (this._llmComplete !== null && this._ttsFirstByte !== null) {
2494
+ tts_ms = this._ttsFirstByte - this._llmComplete;
2495
+ }
2496
+ if (this._turnStart !== null && this._ttsFirstByte !== null) {
2497
+ total_ms = this._ttsFirstByte - this._turnStart;
2498
+ }
2499
+ return {
2500
+ stt_ms: round(stt_ms, 1),
2501
+ llm_ms: round(llm_ms, 1),
2502
+ tts_ms: round(tts_ms, 1),
2503
+ total_ms: round(total_ms, 1)
2504
+ };
2505
+ }
2506
+ _computeCost(durationSeconds) {
2507
+ let stt;
2508
+ let tts;
2509
+ let llm;
2510
+ if (this.providerMode === "openai_realtime") {
2511
+ stt = 0;
2512
+ tts = 0;
2513
+ llm = this._totalRealtimeCost;
2514
+ } else if (this.providerMode === "elevenlabs_convai") {
2515
+ stt = 0;
2516
+ tts = 0;
2517
+ llm = 0;
2518
+ } else {
2519
+ stt = this._actualSttCost !== null ? this._actualSttCost : calculateSttCost(this.sttProvider, this._totalSttAudioSeconds, this._pricing);
2520
+ tts = calculateTtsCost(this.ttsProvider, this._totalTtsCharacters, this._pricing);
2521
+ llm = 0;
2522
+ }
2523
+ const telephony = this._actualTelephonyCost !== null ? this._actualTelephonyCost : calculateTelephonyCost(this.telephonyProvider, durationSeconds, this._pricing);
2524
+ const total = stt + tts + llm + telephony;
2525
+ return {
2526
+ stt: round(stt, 6),
2527
+ tts: round(tts, 6),
2528
+ llm: round(llm, 6),
2529
+ telephony: round(telephony, 6),
2530
+ total: round(total, 6)
2531
+ };
2532
+ }
2533
+ _computeAverageLatency() {
2534
+ if (this._turns.length === 0) {
2535
+ return { stt_ms: 0, llm_ms: 0, tts_ms: 0, total_ms: 0 };
2536
+ }
2537
+ const n = this._turns.length;
2538
+ return {
2539
+ stt_ms: round(this._turns.reduce((s, t) => s + t.latency.stt_ms, 0) / n, 1),
2540
+ llm_ms: round(this._turns.reduce((s, t) => s + t.latency.llm_ms, 0) / n, 1),
2541
+ tts_ms: round(this._turns.reduce((s, t) => s + t.latency.tts_ms, 0) / n, 1),
2542
+ total_ms: round(this._turns.reduce((s, t) => s + t.latency.total_ms, 0) / n, 1)
2543
+ };
2544
+ }
2545
+ _computeP95Latency() {
2546
+ if (this._turns.length === 0) {
2547
+ return { stt_ms: 0, llm_ms: 0, tts_ms: 0, total_ms: 0 };
2548
+ }
2549
+ return {
2550
+ stt_ms: round(p95(this._turns.map((t) => t.latency.stt_ms)), 1),
2551
+ llm_ms: round(p95(this._turns.map((t) => t.latency.llm_ms)), 1),
2552
+ tts_ms: round(p95(this._turns.map((t) => t.latency.tts_ms)), 1),
2553
+ total_ms: round(p95(this._turns.map((t) => t.latency.total_ms)), 1)
2554
+ };
2555
+ }
2556
+ };
2557
+
2558
+ // src/stream-handler.ts
2559
+ init_llm_loop();
2560
+
2561
+ // src/handler-utils.ts
2562
+ init_logger();
2563
+ function createHistoryManager(maxSize) {
2564
+ const entries = [];
2565
+ const push = (entry) => {
2566
+ if (entries.length >= maxSize) entries.shift();
2567
+ entries.push(entry);
2568
+ };
2569
+ const getHistory = () => [...entries];
2570
+ return { push, getHistory, entries };
2571
+ }
2572
+ async function executeToolWebhook(webhookUrl, toolName, parsedArgs, context, label = "") {
2573
+ try {
2574
+ validateWebhookUrl(webhookUrl);
2575
+ } catch (e) {
2576
+ const tag = label ? ` (${label})` : "";
2577
+ getLogger().error(`Tool webhook URL rejected${tag}: ${String(e)}`);
2578
+ return JSON.stringify({ error: String(e), fallback: true });
2579
+ }
2580
+ let result = "";
2581
+ for (let attempt = 0; attempt < 3; attempt++) {
2582
+ try {
2583
+ const resp = await fetch(webhookUrl, {
2584
+ method: "POST",
2585
+ headers: { "Content-Type": "application/json" },
2586
+ body: JSON.stringify({
2587
+ tool: toolName,
2588
+ arguments: parsedArgs,
2589
+ call_id: context.callId,
2590
+ caller: context.caller,
2591
+ attempt: attempt + 1
2592
+ }),
2593
+ signal: AbortSignal.timeout(1e4)
2594
+ });
2595
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
2596
+ result = JSON.stringify(await resp.json());
2597
+ const MAX_RESPONSE_BYTES2 = 1 * 1024 * 1024;
2598
+ if (result.length > MAX_RESPONSE_BYTES2) {
2599
+ const tag = label ? ` (${label})` : "";
2600
+ getLogger().warn(`Tool webhook response too large: ${result.length} bytes (max ${MAX_RESPONSE_BYTES2})${tag}`);
2601
+ return JSON.stringify({ error: `Webhook response too large: ${result.length} bytes (max ${MAX_RESPONSE_BYTES2})`, fallback: true });
2602
+ }
2603
+ return result;
2604
+ } catch (e) {
2605
+ if (attempt < 2) {
2606
+ const tag = label ? ` (${label})` : "";
2607
+ getLogger().info(`Tool webhook retry ${attempt + 1}${tag}: ${String(e)}`);
2608
+ await new Promise((r) => setTimeout(r, 500));
2609
+ } else {
2610
+ result = JSON.stringify({ error: `Tool failed after 3 attempts: ${String(e)}`, fallback: true });
2611
+ }
2612
+ }
2613
+ }
2614
+ return result;
2615
+ }
2616
+
2617
+ // src/stream-handler.ts
2618
+ init_logger();
2619
+ function checkGuardrails(text, guardrails) {
2620
+ if (!guardrails) return null;
2621
+ for (const guard of guardrails) {
2622
+ let blocked = false;
2623
+ if (guard.blockedTerms) {
2624
+ blocked = guard.blockedTerms.some((term) => text.toLowerCase().includes(term.toLowerCase()));
2625
+ }
2626
+ if (!blocked && guard.check) {
2627
+ blocked = guard.check(text);
2628
+ }
2629
+ if (blocked) return guard;
2630
+ }
2631
+ return null;
2632
+ }
2633
+ function sanitizeLogValue(v, maxLen = 200) {
2634
+ const cleaned = v.replace(/[\x00-\x1f\x7f]/g, "");
2635
+ return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + "..." : cleaned;
2636
+ }
2637
+ function isValidE164(number) {
2638
+ return /^\+[1-9]\d{6,14}$/.test(number);
2639
+ }
2640
+ var StreamHandler = class {
2641
+ deps;
2642
+ ws;
2643
+ caller;
2644
+ callee;
2645
+ // Mutable call state
2646
+ streamSid = "";
2647
+ callId = "";
2648
+ adapter = null;
2649
+ stt = null;
2650
+ tts = null;
2651
+ isSpeaking = false;
2652
+ llmLoop = null;
2653
+ chunkCount = 0;
2654
+ callEndFired = false;
2655
+ history;
2656
+ metricsAcc;
2657
+ constructor(deps, ws, caller, callee) {
2658
+ this.deps = deps;
2659
+ this.ws = ws;
2660
+ this.caller = caller;
2661
+ this.callee = callee;
2662
+ this.history = createHistoryManager(200);
2663
+ const sttProviderName = deps.agent.stt?.provider || (deps.agent.deepgramKey ? "deepgram" : void 0);
2664
+ const ttsProviderName = deps.agent.tts?.provider === "elevenlabs" ? "elevenlabs" : deps.agent.tts?.provider === "openai" ? "openai_tts" : deps.agent.elevenlabsKey ? "elevenlabs" : void 0;
2665
+ const providerMode = deps.agent.provider ?? "openai_realtime";
2666
+ this.metricsAcc = new CallMetricsAccumulator({
2667
+ callId: "",
2668
+ providerMode,
2669
+ telephonyProvider: deps.bridge.telephonyProvider,
2670
+ sttProvider: sttProviderName,
2671
+ ttsProvider: ttsProviderName,
2672
+ pricing: deps.pricing
2673
+ });
2674
+ getLogger().info(`WebSocket connection opened (${deps.bridge.label})`);
2675
+ }
2676
+ // ---------------------------------------------------------------------------
2677
+ // Public: called by the provider-specific parsers in server.ts
2678
+ // ---------------------------------------------------------------------------
2679
+ /**
2680
+ * Handle the call-start event.
2681
+ *
2682
+ * @param callId Call SID (Twilio) or call_control_id (Telnyx)
2683
+ * @param customParams TwiML custom parameters (Twilio only, empty for Telnyx)
2684
+ */
2685
+ async handleCallStart(callId, customParams = {}) {
2686
+ this.callId = callId;
2687
+ this.metricsAcc.callId = callId;
2688
+ getLogger().info(`Call started: ${callId}`);
2689
+ if (Object.keys(customParams).length > 0) {
2690
+ getLogger().info(`Custom params: ${sanitizeLogValue(JSON.stringify(customParams))}`);
2691
+ }
2692
+ this.deps.metricsStore.recordCallStart({
2693
+ call_id: callId,
2694
+ caller: this.caller,
2695
+ callee: this.callee,
2696
+ direction: "inbound"
2697
+ });
2698
+ if (this.deps.onCallStart) {
2699
+ await this.deps.onCallStart({
2700
+ call_id: callId,
2701
+ caller: this.caller,
2702
+ callee: this.callee,
2703
+ direction: "inbound",
2704
+ ...Object.keys(customParams).length > 0 ? { custom_params: customParams } : {}
2705
+ });
2706
+ }
2707
+ if (this.deps.recording && this.deps.config.twilioSid && this.deps.config.twilioToken && callId) {
2708
+ try {
2709
+ const recUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.deps.config.twilioSid}/Calls/${callId}/Recordings.json`;
2710
+ const recResp = await fetch(recUrl, {
2711
+ method: "POST",
2712
+ headers: {
2713
+ "Authorization": `Basic ${Buffer.from(`${this.deps.config.twilioSid}:${this.deps.config.twilioToken}`).toString("base64")}`
2714
+ }
2715
+ });
2716
+ if (recResp.ok) {
2717
+ getLogger().info(`Recording started for ${callId}`);
2718
+ } else {
2719
+ getLogger().warn(`could not start recording: ${await recResp.text()}`);
2720
+ }
2721
+ } catch (e) {
2722
+ getLogger().warn(`could not start recording: ${String(e)}`);
2723
+ }
2724
+ }
2725
+ const agentVars = this.deps.sanitizeVariables(this.deps.agent.variables ?? {});
2726
+ const safeCustomParams = this.deps.sanitizeVariables(customParams);
2727
+ const allVars = { ...agentVars, ...safeCustomParams };
2728
+ const resolvedPrompt = Object.keys(allVars).length > 0 ? this.deps.resolveVariables(this.deps.agent.systemPrompt, allVars) : this.deps.agent.systemPrompt;
2729
+ const provider = this.deps.agent.provider ?? "openai_realtime";
2730
+ if (provider === "pipeline") {
2731
+ await this.initPipeline(resolvedPrompt);
2732
+ } else {
2733
+ await this.initRealtimeAdapter(resolvedPrompt);
2734
+ }
2735
+ }
2736
+ /** Set the stream SID (Twilio only, called after parsing 'start' event). */
2737
+ setStreamSid(sid) {
2738
+ this.streamSid = sid;
2739
+ }
2740
+ /** Handle an incoming audio chunk (already decoded from base64). */
2741
+ handleAudio(audioBuffer) {
2742
+ const provider = this.deps.agent.provider ?? "openai_realtime";
2743
+ if (provider === "pipeline" && this.stt && !this.isSpeaking) {
2744
+ this.stt.sendAudio(audioBuffer);
2745
+ } else if (this.adapter) {
2746
+ this.adapter.sendAudio(audioBuffer);
2747
+ }
2748
+ }
2749
+ /** Handle a DTMF keypress event (Twilio only). */
2750
+ async handleDtmf(digit) {
2751
+ getLogger().info(`DTMF: ${digit}`);
2752
+ if (this.adapter instanceof OpenAIRealtimeAdapter) {
2753
+ await this.adapter.sendText(`The user pressed key ${digit} on their phone keypad.`);
2754
+ }
2755
+ if (this.deps.onTranscript) {
2756
+ await this.deps.onTranscript({ role: "user", text: `[DTMF: ${digit}]`, call_id: this.callId });
2757
+ }
2758
+ }
2759
+ /** Handle call stop / stream end. */
2760
+ async handleStop() {
2761
+ this.stt?.close();
2762
+ this.adapter?.close();
2763
+ await this.fireCallEnd();
2764
+ }
2765
+ /** Handle WebSocket close event. */
2766
+ async handleWsClose() {
2767
+ await this.fireCallEnd();
2768
+ this.stt?.close();
2769
+ this.adapter?.close();
2770
+ }
2771
+ // ---------------------------------------------------------------------------
2772
+ // Private: Pipeline mode
2773
+ // ---------------------------------------------------------------------------
2774
+ async initPipeline(resolvedPrompt) {
2775
+ const label = this.deps.bridge.label;
2776
+ this.stt = this.deps.bridge.createStt(this.deps.agent);
2777
+ if (this.deps.agent.tts) {
2778
+ if (this.deps.agent.tts.provider === "elevenlabs") {
2779
+ this.tts = new ElevenLabsTTS(this.deps.agent.tts.apiKey, this.deps.agent.tts.voice ?? "21m00Tcm4TlvDq8ikWAM");
2780
+ }
2781
+ if (this.deps.agent.tts.provider === "openai") {
2782
+ this.tts = new OpenAITTS(this.deps.agent.tts.apiKey, this.deps.agent.tts.voice ?? "alloy");
2783
+ }
2784
+ } else if (this.deps.agent.elevenlabsKey) {
2785
+ const voiceId = this.deps.agent.voice && this.deps.agent.voice !== "alloy" ? this.deps.agent.voice : "21m00Tcm4TlvDq8ikWAM";
2786
+ this.tts = new ElevenLabsTTS(this.deps.agent.elevenlabsKey, voiceId);
2787
+ }
2788
+ if (!this.stt) {
2789
+ getLogger().info(`Pipeline mode (${label}): no STT configured`);
2790
+ }
2791
+ if (!this.tts) {
2792
+ getLogger().info(`Pipeline mode (${label}): no TTS configured`);
2793
+ }
2794
+ try {
2795
+ if (this.stt) await this.stt.connect();
2796
+ getLogger().info(`Pipeline mode (${label}): STT + TTS connected`);
2797
+ } catch (e) {
2798
+ getLogger().error(`Pipeline connect FAILED (${label}):`, e);
2799
+ return;
2800
+ }
2801
+ if (this.deps.agent.firstMessage && !this.deps.onMessage && this.tts) {
2802
+ try {
2803
+ for await (const chunk of this.tts.synthesizeStream(this.deps.agent.firstMessage)) {
2804
+ const encoded = chunk.toString("base64");
2805
+ this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid);
2806
+ }
2807
+ } catch (e) {
2808
+ getLogger().error(`First message TTS error (${label}):`, e);
2809
+ }
2810
+ }
2811
+ if (!this.deps.onMessage && this.deps.config.openaiKey) {
2812
+ let llmModel = this.deps.agent.model || "gpt-4o-mini";
2813
+ if (llmModel.includes("realtime")) llmModel = "gpt-4o-mini";
2814
+ this.llmLoop = new LLMLoop(
2815
+ this.deps.config.openaiKey,
2816
+ llmModel,
2817
+ resolvedPrompt,
2818
+ this.deps.agent.tools
2819
+ );
2820
+ getLogger().info(`Built-in LLM loop active (pipeline, ${label})`);
2821
+ }
2822
+ if (this.stt) {
2823
+ this.stt.onTranscript(async (transcript) => {
2824
+ await this.handleTranscript(transcript);
2825
+ });
2826
+ }
2827
+ }
2828
+ /** Handle a final transcript from STT in pipeline mode. */
2829
+ async handleTranscript(transcript) {
2830
+ if (!transcript.isFinal || !transcript.text) return;
2831
+ const label = this.deps.bridge.label;
2832
+ getLogger().info(`User (${label} pipeline): ${sanitizeLogValue(transcript.text)}`);
2833
+ this.metricsAcc.startTurn();
2834
+ this.metricsAcc.recordSttComplete(transcript.text);
2835
+ this.history.push({ role: "user", text: transcript.text, timestamp: Date.now() });
2836
+ if (this.deps.onTranscript) {
2837
+ await this.deps.onTranscript({
2838
+ role: "user",
2839
+ text: transcript.text,
2840
+ call_id: this.callId,
2841
+ history: [...this.history.entries]
2842
+ });
2843
+ }
2844
+ let responseText = "";
2845
+ if (this.deps.onMessage && typeof this.deps.onMessage === "function") {
2846
+ try {
2847
+ responseText = await this.deps.onMessage({
2848
+ text: transcript.text,
2849
+ call_id: this.callId,
2850
+ caller: this.caller,
2851
+ history: [...this.history.entries]
2852
+ });
2853
+ } catch (e) {
2854
+ getLogger().error(`onMessage error (${label}):`, e);
2855
+ return;
2856
+ }
2857
+ } else if (this.deps.onMessage && isRemoteUrl(this.deps.onMessage)) {
2858
+ const msgData = {
2859
+ text: transcript.text,
2860
+ call_id: this.callId,
2861
+ caller: this.caller,
2862
+ callee: this.callee,
2863
+ history: [...this.history.entries]
2864
+ };
2865
+ if (isWebSocketUrl(this.deps.onMessage)) {
2866
+ await this.handleWebSocketResponse(msgData);
2867
+ return;
2868
+ } else {
2869
+ try {
2870
+ responseText = await this.deps.remoteHandler.callWebhook(this.deps.onMessage, msgData);
2871
+ } catch (e) {
2872
+ getLogger().error(`Webhook remote error (${label}):`, e);
2873
+ return;
2874
+ }
2875
+ }
2876
+ } else if (this.llmLoop) {
2877
+ const callCtx = { call_id: this.callId, caller: this.caller, callee: this.callee };
2878
+ const parts = [];
2879
+ this.metricsAcc.recordLlmComplete();
2880
+ this.isSpeaking = true;
2881
+ try {
2882
+ for await (const token of this.llmLoop.run(transcript.text, this.history.entries, callCtx)) {
2883
+ parts.push(token);
2884
+ }
2885
+ } catch (e) {
2886
+ getLogger().error(`LLM loop error (${label}):`, e);
2887
+ }
2888
+ responseText = parts.join("");
2889
+ } else {
2890
+ return;
2891
+ }
2892
+ if (!responseText) return;
2893
+ this.metricsAcc.recordLlmComplete();
2894
+ this.history.push({ role: "assistant", text: responseText, timestamp: Date.now() });
2895
+ this.isSpeaking = true;
2896
+ this.metricsAcc.recordTtsFirstByte();
2897
+ try {
2898
+ for await (const chunk of this.tts.synthesizeStream(responseText)) {
2899
+ if (!this.isSpeaking) break;
2900
+ const encoded = chunk.toString("base64");
2901
+ this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid);
2902
+ }
2903
+ } catch (e) {
2904
+ getLogger().error(`TTS streaming error (${label}):`, e);
2905
+ } finally {
2906
+ this.isSpeaking = false;
2907
+ }
2908
+ this.metricsAcc.recordTtsComplete(responseText);
2909
+ const turn = this.metricsAcc.recordTurnComplete(responseText);
2910
+ if (turn) {
2911
+ this.deps.metricsStore.recordTurn({ call_id: this.callId, turn });
2912
+ if (this.deps.onMetrics) await this.deps.onMetrics({ call_id: this.callId, turn });
2913
+ }
2914
+ }
2915
+ /** Handle streaming WebSocket remote response with TTS. */
2916
+ async handleWebSocketResponse(msgData) {
2917
+ const onMessage = this.deps.onMessage;
2918
+ const parts = [];
2919
+ this.metricsAcc.recordLlmComplete();
2920
+ this.isSpeaking = true;
2921
+ try {
2922
+ for await (const chunk of this.deps.remoteHandler.callWebSocket(onMessage, msgData)) {
2923
+ parts.push(chunk);
2924
+ if (this.tts) {
2925
+ for await (const audioChunk of this.tts.synthesizeStream(chunk)) {
2926
+ if (!this.isSpeaking) break;
2927
+ const encoded = audioChunk.toString("base64");
2928
+ this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid);
2929
+ }
2930
+ }
2931
+ }
2932
+ } catch (e) {
2933
+ getLogger().error(`WebSocket remote error (${this.deps.bridge.label}):`, e);
2934
+ } finally {
2935
+ this.isSpeaking = false;
2936
+ }
2937
+ const responseText = parts.join("");
2938
+ this.metricsAcc.recordTtsFirstByte();
2939
+ this.metricsAcc.recordTtsComplete(responseText);
2940
+ const turn = this.metricsAcc.recordTurnComplete(responseText);
2941
+ if (turn) {
2942
+ this.deps.metricsStore.recordTurn({ call_id: this.callId, turn });
2943
+ if (this.deps.onMetrics) await this.deps.onMetrics({ call_id: this.callId, turn });
2944
+ }
2945
+ if (responseText) this.history.push({ role: "assistant", text: responseText, timestamp: Date.now() });
2946
+ }
2947
+ // ---------------------------------------------------------------------------
2948
+ // Private: OpenAI Realtime / ElevenLabs ConvAI mode
2949
+ // ---------------------------------------------------------------------------
2950
+ async initRealtimeAdapter(resolvedPrompt) {
2951
+ const label = this.deps.bridge.label;
2952
+ this.adapter = this.deps.buildAIAdapter(resolvedPrompt);
2953
+ try {
2954
+ await this.adapter.connect();
2955
+ getLogger().info(`AI adapter connected (${label})`);
2956
+ } catch (e) {
2957
+ getLogger().error(`AI adapter connect FAILED (${label}):`, e);
2958
+ return;
2959
+ }
2960
+ if (this.deps.agent.firstMessage && this.adapter instanceof OpenAIRealtimeAdapter) {
2961
+ await this.adapter.sendText(this.deps.agent.firstMessage);
2962
+ }
2963
+ this.adapter.onEvent(async (type, eventData) => {
2964
+ try {
2965
+ await this.handleAdapterEvent(type, eventData);
2966
+ } catch (err) {
2967
+ getLogger().error(`Adapter event handler error (${label}):`, err);
2968
+ }
2969
+ });
2970
+ }
2971
+ async handleAdapterEvent(type, eventData) {
2972
+ if (type === "audio") {
2973
+ const encoded = eventData.toString("base64");
2974
+ this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid);
2975
+ this.chunkCount++;
2976
+ this.deps.bridge.sendMark(this.ws, `audio_${this.chunkCount}`, this.streamSid);
2977
+ } else if (type === "transcript_input") {
2978
+ const inputText = eventData;
2979
+ getLogger().info(`User (${this.deps.bridge.label}): ${sanitizeLogValue(inputText)}`);
2980
+ this.history.push({ role: "user", text: inputText, timestamp: Date.now() });
2981
+ if (this.deps.onTranscript) {
2982
+ await this.deps.onTranscript({
2983
+ role: "user",
2984
+ text: inputText,
2985
+ call_id: this.callId,
2986
+ history: [...this.history.entries]
2987
+ });
2988
+ }
2989
+ } else if (type === "transcript_output") {
2990
+ const outputText = eventData;
2991
+ if (outputText) {
2992
+ const triggered = checkGuardrails(outputText, this.deps.agent.guardrails);
2993
+ if (triggered) {
2994
+ getLogger().info(`Guardrail '${triggered.name}' triggered`);
2995
+ if (this.adapter instanceof OpenAIRealtimeAdapter) {
2996
+ this.adapter.cancelResponse();
2997
+ await this.adapter.sendText(triggered.replacement ?? "I'm sorry, I can't respond to that.");
2998
+ }
2999
+ }
3000
+ this.history.push({ role: "assistant", text: outputText, timestamp: Date.now() });
3001
+ }
3002
+ } else if (type === "speech_started" || type === "interruption") {
3003
+ this.deps.bridge.sendClear(this.ws, this.streamSid);
3004
+ if (this.adapter instanceof OpenAIRealtimeAdapter) {
3005
+ this.adapter.cancelResponse();
3006
+ }
3007
+ } else if (type === "function_call" && this.adapter instanceof OpenAIRealtimeAdapter) {
3008
+ await this.handleFunctionCall(eventData);
3009
+ }
3010
+ }
3011
+ async handleFunctionCall(fc) {
3012
+ const adapter = this.adapter;
3013
+ if (fc.name === "transfer_call") {
3014
+ let transferArgs;
3015
+ try {
3016
+ transferArgs = JSON.parse(fc.arguments || "{}");
3017
+ } catch {
3018
+ transferArgs = {};
3019
+ }
3020
+ const transferTo = transferArgs.number ?? "";
3021
+ if (!isValidE164(transferTo)) {
3022
+ getLogger().warn(`transfer_call rejected (${this.deps.bridge.label}): invalid number ${JSON.stringify(transferTo)}`);
3023
+ await adapter.sendFunctionResult(fc.call_id, JSON.stringify({ error: "Invalid phone number format", status: "rejected" }));
3024
+ return;
3025
+ }
3026
+ getLogger().info(`Transferring call to ${transferTo}`);
3027
+ await adapter.sendFunctionResult(fc.call_id, JSON.stringify({ status: "transferring", to: transferTo }));
3028
+ await this.deps.bridge.transferCall(this.callId, transferTo);
3029
+ if (this.deps.onTranscript) {
3030
+ await this.deps.onTranscript({ role: "system", text: `Call transferred to ${transferTo}`, call_id: this.callId });
3031
+ }
3032
+ return;
3033
+ }
3034
+ if (fc.name === "end_call") {
3035
+ let endArgs;
3036
+ try {
3037
+ endArgs = JSON.parse(fc.arguments || "{}");
3038
+ } catch {
3039
+ endArgs = {};
3040
+ }
3041
+ const reason = endArgs.reason ?? "conversation_complete";
3042
+ getLogger().info(`Ending call (${this.deps.bridge.label}): ${reason}`);
3043
+ await adapter.sendFunctionResult(fc.call_id, JSON.stringify({ status: "ending", reason }));
3044
+ await this.deps.bridge.endCall(this.callId, this.ws);
3045
+ if (this.deps.onTranscript) {
3046
+ await this.deps.onTranscript({ role: "system", text: `Call ended: ${reason}`, call_id: this.callId });
3047
+ }
3048
+ return;
3049
+ }
3050
+ const toolDef = this.deps.agent.tools?.find((t) => t.name === fc.name);
3051
+ if (toolDef?.webhookUrl) {
3052
+ let parsedArgs;
3053
+ try {
3054
+ parsedArgs = JSON.parse(fc.arguments || "{}");
3055
+ } catch {
3056
+ parsedArgs = {};
3057
+ }
3058
+ const result = await executeToolWebhook(
3059
+ toolDef.webhookUrl,
3060
+ fc.name,
3061
+ parsedArgs,
3062
+ { callId: this.callId, caller: this.caller },
3063
+ this.deps.bridge.label === "Twilio" ? "" : this.deps.bridge.label
3064
+ );
3065
+ await adapter.sendFunctionResult(fc.call_id, result);
3066
+ }
3067
+ }
3068
+ // ---------------------------------------------------------------------------
3069
+ // Private: call end / metrics finalization
3070
+ // ---------------------------------------------------------------------------
3071
+ async fireCallEnd() {
3072
+ if (this.callEndFired) return;
3073
+ this.callEndFired = true;
3074
+ await this.deps.bridge.queryTelephonyCost(this.metricsAcc, this.callId);
3075
+ const deepgramKey = this.deps.agent.deepgramKey;
3076
+ const deepgramRequestId = this.stt?.requestId;
3077
+ if (deepgramKey && deepgramRequestId) {
3078
+ await queryDeepgramCost(this.metricsAcc, deepgramKey, deepgramRequestId);
3079
+ }
3080
+ const finalMetrics = this.metricsAcc.endCall();
3081
+ this.deps.metricsStore.recordCallEnd(
3082
+ { call_id: this.callId, transcript: [...this.history.entries] },
3083
+ finalMetrics
3084
+ );
3085
+ if (this.deps.onCallEnd) {
3086
+ await this.deps.onCallEnd({
3087
+ call_id: this.callId,
3088
+ transcript: [...this.history.entries],
3089
+ metrics: finalMetrics
3090
+ });
3091
+ }
3092
+ }
3093
+ };
3094
+ async function queryDeepgramCost(metricsAcc, deepgramKey, deepgramRequestId) {
3095
+ try {
3096
+ const projResp = await fetch("https://api.deepgram.com/v1/projects", {
3097
+ headers: { "Authorization": `Token ${deepgramKey}` },
3098
+ signal: AbortSignal.timeout(5e3)
3099
+ });
3100
+ if (projResp.ok) {
3101
+ const projData = await projResp.json();
3102
+ const projectId = projData.projects?.[0]?.project_id;
3103
+ if (projectId) {
3104
+ const reqResp = await fetch(
3105
+ `https://api.deepgram.com/v1/projects/${projectId}/requests/${deepgramRequestId}`,
3106
+ {
3107
+ headers: { "Authorization": `Token ${deepgramKey}` },
3108
+ signal: AbortSignal.timeout(5e3)
3109
+ }
3110
+ );
3111
+ if (reqResp.ok) {
3112
+ const reqData = await reqResp.json();
3113
+ const usd = reqData.response?.details?.usd;
3114
+ if (usd != null) {
3115
+ metricsAcc.setActualSttCost(usd);
3116
+ getLogger().info(`Deepgram actual cost: $${usd}`);
3117
+ }
3118
+ }
3119
+ }
3120
+ }
3121
+ } catch {
3122
+ }
3123
+ }
3124
+
3125
+ // src/server.ts
3126
+ init_logger();
3127
+ var TRANSFER_CALL_TOOL = {
3128
+ name: "transfer_call",
3129
+ description: "Transfer the call to a human agent at the specified phone number",
3130
+ parameters: {
3131
+ type: "object",
3132
+ properties: {
3133
+ number: {
3134
+ type: "string",
3135
+ description: "Phone number to transfer to (E.164 format)"
3136
+ }
3137
+ },
3138
+ required: ["number"]
3139
+ }
3140
+ };
3141
+ var END_CALL_TOOL = {
3142
+ name: "end_call",
3143
+ description: "End the current phone call. Use when the conversation is complete or the user says goodbye.",
3144
+ parameters: {
3145
+ type: "object",
3146
+ properties: {
3147
+ reason: {
3148
+ type: "string",
3149
+ description: "Reason for ending the call (e.g., 'conversation_complete', 'user_requested', 'no_response')"
3150
+ }
3151
+ }
3152
+ }
3153
+ };
3154
+ function xmlEscape(s) {
3155
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3156
+ }
3157
+ function validateWebhookUrl(url) {
3158
+ const parsed = new URL(url);
3159
+ if (!["http:", "https:"].includes(parsed.protocol)) {
3160
+ throw new Error(`Invalid webhook URL scheme: ${parsed.protocol}`);
3161
+ }
3162
+ const hostname = parsed.hostname;
3163
+ const blocked = [
3164
+ /^127\./,
3165
+ /^10\./,
3166
+ /^172\.(1[6-9]|2\d|3[01])\./,
3167
+ /^192\.168\./,
3168
+ /^169\.254\./,
3169
+ /^0\./,
3170
+ /^::1$/,
3171
+ /^localhost$/i,
3172
+ /^metadata\.google\.internal$/i
3173
+ ];
3174
+ if (blocked.some((re) => re.test(hostname))) {
3175
+ throw new Error(`Webhook URL blocked: ${hostname} is a private/internal address`);
3176
+ }
3177
+ }
3178
+ function validateTelnyxSignature(rawBody, signature, timestamp, publicKey, toleranceSec = 300) {
3179
+ try {
3180
+ const ts = parseInt(timestamp, 10);
3181
+ if (!Number.isFinite(ts)) return false;
3182
+ const ageMs = Date.now() - ts;
3183
+ if (ageMs < 0 || ageMs > toleranceSec * 1e3) return false;
3184
+ const payload = `${timestamp}|${rawBody}`;
3185
+ const keyBuffer = Buffer.from(publicKey, "base64");
3186
+ const sigBuffer = Buffer.from(signature, "base64");
3187
+ const keyObject = import_node_crypto3.default.createPublicKey({
3188
+ key: keyBuffer,
3189
+ format: "der",
3190
+ type: "spki"
3191
+ });
3192
+ return import_node_crypto3.default.verify(null, Buffer.from(payload), keyObject, sigBuffer);
3193
+ } catch {
3194
+ return false;
3195
+ }
3196
+ }
3197
+ function validateTwilioSignature(url, params, signature, authToken) {
3198
+ const data = url + Object.keys(params).sort().reduce((acc, key) => acc + key + (params[key] ?? ""), "");
3199
+ const expected = import_node_crypto3.default.createHmac("sha1", authToken).update(data).digest("base64");
3200
+ try {
3201
+ return import_node_crypto3.default.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
3202
+ } catch {
3203
+ return false;
3204
+ }
3205
+ }
3206
+ function sanitizeVariables(raw) {
3207
+ const BLOCKED_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
3208
+ const safe = /* @__PURE__ */ Object.create(null);
3209
+ for (const key of Object.keys(raw)) {
3210
+ if (BLOCKED_KEYS.has(key)) continue;
3211
+ const val = raw[key];
3212
+ safe[key] = typeof val === "string" ? val : String(val ?? "");
3213
+ }
3214
+ return safe;
3215
+ }
3216
+ function resolveVariables(template, variables) {
3217
+ let result = template;
3218
+ for (const [key, value] of Object.entries(variables)) {
3219
+ result = result.replaceAll(`{${key}}`, value);
3220
+ }
3221
+ return result;
3222
+ }
3223
+ function buildAIAdapter(config, agent, resolvedPrompt) {
3224
+ if (agent.provider === "elevenlabs_convai") {
3225
+ const key = agent.elevenlabsKey ?? "";
3226
+ return new ElevenLabsConvAIAdapter(
3227
+ key,
3228
+ agent.elevenlabsAgentId ?? "",
3229
+ agent.voice ?? "21m00Tcm4TlvDq8ikWAM",
3230
+ "eleven_turbo_v2_5",
3231
+ agent.language ?? "en",
3232
+ agent.firstMessage ?? ""
3233
+ );
3234
+ }
3235
+ const agentTools = agent.tools?.map((t) => ({
3236
+ name: t.name,
3237
+ description: t.description,
3238
+ parameters: t.parameters
3239
+ })) ?? [];
3240
+ const tools = [...agentTools, TRANSFER_CALL_TOOL, END_CALL_TOOL];
3241
+ return new OpenAIRealtimeAdapter(
3242
+ config.openaiKey ?? "",
3243
+ agent.model,
3244
+ agent.voice,
3245
+ resolvedPrompt ?? agent.systemPrompt,
3246
+ tools
3247
+ );
3248
+ }
3249
+ var TwilioBridge = class {
3250
+ constructor(config) {
3251
+ this.config = config;
3252
+ }
3253
+ label = "Twilio";
3254
+ telephonyProvider = "twilio";
3255
+ sendAudio(ws, audioBase64, streamSid) {
3256
+ ws.send(JSON.stringify({ event: "media", streamSid, media: { payload: audioBase64 } }));
3257
+ }
3258
+ sendMark(ws, markName, streamSid) {
3259
+ ws.send(JSON.stringify({ event: "mark", streamSid, mark: { name: markName } }));
3260
+ }
3261
+ sendClear(ws, streamSid) {
3262
+ ws.send(JSON.stringify({ event: "clear", streamSid }));
3263
+ }
3264
+ async transferCall(callId, toNumber) {
3265
+ if (this.config.twilioSid && this.config.twilioToken && callId) {
3266
+ const transferUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.config.twilioSid}/Calls/${callId}.json`;
3267
+ await fetch(transferUrl, {
3268
+ method: "POST",
3269
+ headers: {
3270
+ "Content-Type": "application/x-www-form-urlencoded",
3271
+ "Authorization": `Basic ${Buffer.from(`${this.config.twilioSid}:${this.config.twilioToken}`).toString("base64")}`
3272
+ },
3273
+ body: new URLSearchParams({ Twiml: `<Response><Dial>${xmlEscape(toNumber)}</Dial></Response>` }).toString()
3274
+ });
3275
+ getLogger().info(`Call transferred to ${toNumber}`);
3276
+ }
3277
+ }
3278
+ async endCall(callId, _ws) {
3279
+ if (this.config.twilioSid && this.config.twilioToken && callId) {
3280
+ const endUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.config.twilioSid}/Calls/${callId}.json`;
3281
+ await fetch(endUrl, {
3282
+ method: "POST",
3283
+ headers: {
3284
+ "Content-Type": "application/x-www-form-urlencoded",
3285
+ "Authorization": `Basic ${Buffer.from(`${this.config.twilioSid}:${this.config.twilioToken}`).toString("base64")}`
3286
+ },
3287
+ body: new URLSearchParams({ Status: "completed" }).toString()
3288
+ });
3289
+ }
3290
+ }
3291
+ createStt(agent) {
3292
+ if (agent.stt) {
3293
+ if (agent.stt.provider === "deepgram") {
3294
+ return DeepgramSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en");
3295
+ } else if (agent.stt.provider === "whisper") {
3296
+ return WhisperSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en");
3297
+ }
3298
+ } else if (agent.deepgramKey) {
3299
+ return DeepgramSTT.forTwilio(agent.deepgramKey, agent.language ?? "en");
3300
+ }
3301
+ return null;
3302
+ }
3303
+ async queryTelephonyCost(metricsAcc, callId) {
3304
+ if (this.config.twilioSid && this.config.twilioToken && callId) {
3305
+ try {
3306
+ const resp = await fetch(
3307
+ `https://api.twilio.com/2010-04-01/Accounts/${this.config.twilioSid}/Calls/${callId}.json`,
3308
+ {
3309
+ headers: {
3310
+ "Authorization": `Basic ${Buffer.from(`${this.config.twilioSid}:${this.config.twilioToken}`).toString("base64")}`
3311
+ },
3312
+ signal: AbortSignal.timeout(5e3)
3313
+ }
3314
+ );
3315
+ if (resp.ok) {
3316
+ const data = await resp.json();
3317
+ if (data.price != null) {
3318
+ metricsAcc.setActualTelephonyCost(Math.abs(parseFloat(data.price)));
3319
+ getLogger().info(`Twilio actual cost: $${Math.abs(parseFloat(data.price))}`);
3320
+ }
3321
+ }
3322
+ } catch {
3323
+ }
3324
+ }
3325
+ }
3326
+ };
3327
+ var TelnyxBridge = class {
3328
+ constructor(config) {
3329
+ this.config = config;
3330
+ }
3331
+ label = "Telnyx";
3332
+ telephonyProvider = "telnyx";
3333
+ sendAudio(ws, audioBase64, _streamSid) {
3334
+ ws.send(JSON.stringify({ event_type: "media", payload: { audio: { chunk: audioBase64 } } }));
3335
+ }
3336
+ sendMark(_ws, _markName, _streamSid) {
3337
+ }
3338
+ sendClear(ws, _streamSid) {
3339
+ ws.send(JSON.stringify({ event_type: "media_stop" }));
3340
+ }
3341
+ async transferCall(callId, toNumber) {
3342
+ const telnyxKey = this.config.telnyxKey ?? "";
3343
+ await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/transfer`, {
3344
+ method: "POST",
3345
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${telnyxKey}` },
3346
+ body: JSON.stringify({ to: toNumber })
3347
+ });
3348
+ getLogger().info(`Telnyx call transferred to ${toNumber}`);
3349
+ }
3350
+ async endCall(_callId, ws) {
3351
+ ws.close();
3352
+ }
3353
+ createStt(agent) {
3354
+ if (agent.stt) {
3355
+ if (agent.stt.provider === "deepgram") {
3356
+ return new DeepgramSTT(agent.stt.apiKey, agent.stt.language ?? "en", "nova-3", "linear16", 16e3);
3357
+ } else if (agent.stt.provider === "whisper") {
3358
+ return new WhisperSTT(agent.stt.apiKey, "whisper-1", agent.stt.language ?? "en");
3359
+ }
3360
+ } else if (agent.deepgramKey) {
3361
+ return new DeepgramSTT(agent.deepgramKey, agent.language ?? "en", "nova-3", "linear16", 16e3);
3362
+ }
3363
+ return null;
3364
+ }
3365
+ async queryTelephonyCost(metricsAcc, callId) {
3366
+ if (this.config.telnyxKey && callId) {
3367
+ try {
3368
+ const resp = await fetch(
3369
+ `https://api.telnyx.com/v2/calls/${callId}`,
3370
+ {
3371
+ headers: { "Authorization": `Bearer ${this.config.telnyxKey}` },
3372
+ signal: AbortSignal.timeout(5e3)
3373
+ }
3374
+ );
3375
+ if (resp.ok) {
3376
+ const body = await resp.json();
3377
+ const amount = body.data?.cost?.amount;
3378
+ if (amount != null) {
3379
+ metricsAcc.setActualTelephonyCost(Math.abs(parseFloat(amount)));
3380
+ getLogger().info(`Telnyx actual cost: $${Math.abs(parseFloat(amount))}`);
3381
+ }
3382
+ }
3383
+ } catch {
3384
+ }
3385
+ }
3386
+ }
3387
+ };
3388
+ var GRACEFUL_SHUTDOWN_TIMEOUT_MS = 1e4;
3389
+ var EmbeddedServer = class {
3390
+ constructor(config, agent, onCallStart, onCallEnd, onTranscript, onMessage, recording = false, voicemailMessage = "", onMetrics, pricingOverrides, dashboard = false, dashboardToken = "") {
3391
+ this.config = config;
3392
+ this.agent = agent;
3393
+ this.onCallStart = onCallStart;
3394
+ this.onCallEnd = onCallEnd;
3395
+ this.onTranscript = onTranscript;
3396
+ this.onMessage = onMessage;
3397
+ this.recording = recording;
3398
+ this.voicemailMessage = voicemailMessage;
3399
+ this.onMetrics = onMetrics;
3400
+ this.dashboard = dashboard;
3401
+ this.dashboardToken = dashboardToken;
3402
+ this.metricsStore = new MetricsStore();
3403
+ this.pricing = mergePricing(pricingOverrides);
3404
+ }
3405
+ server = null;
3406
+ wss = null;
3407
+ twilioTokenWarningLogged = false;
3408
+ metricsStore;
3409
+ pricing;
3410
+ remoteHandler = new RemoteMessageHandler();
3411
+ /** Active WebSocket connections tracked for graceful shutdown. */
3412
+ activeConnections = /* @__PURE__ */ new Set();
3413
+ async start(port = 8e3) {
3414
+ const webhookUrlPattern = /^[a-zA-Z0-9][a-zA-Z0-9.\-]+[a-zA-Z0-9]$/;
3415
+ if (!webhookUrlPattern.test(this.config.webhookUrl)) {
3416
+ throw new Error(`Invalid webhookUrl: must be a hostname with no protocol prefix or path (got: '${this.config.webhookUrl}')`);
3417
+ }
3418
+ const app = (0, import_express.default)();
3419
+ app.use((req, _res, next) => {
3420
+ if (req.path === "/webhooks/telnyx/voice") {
3421
+ let raw = "";
3422
+ req.setEncoding("utf8");
3423
+ req.on("data", (chunk) => {
3424
+ raw += chunk;
3425
+ });
3426
+ req.on("end", () => {
3427
+ req.rawBody = raw;
3428
+ try {
3429
+ req.body = JSON.parse(raw);
3430
+ } catch {
3431
+ req.body = {};
3432
+ }
3433
+ next();
3434
+ });
3435
+ } else {
3436
+ next();
3437
+ }
3438
+ });
3439
+ app.use(import_express.default.json());
3440
+ app.use(import_express.default.urlencoded({ extended: true }));
3441
+ app.get("/health", (_req, res) => {
3442
+ res.json({ status: "ok", mode: "local" });
3443
+ });
3444
+ if (this.dashboard) {
3445
+ if (!this.dashboardToken) {
3446
+ getLogger().warn(
3447
+ "Dashboard is enabled without authentication. Set dashboardToken to protect call data. This is safe for local development but should not be exposed on a public network."
3448
+ );
3449
+ }
3450
+ mountDashboard(app, this.metricsStore, this.dashboardToken);
3451
+ mountApi(app, this.metricsStore, this.dashboardToken);
3452
+ getLogger().info("Dashboard: http://127.0.0.1:" + port + "/");
3453
+ }
3454
+ app.post("/webhooks/twilio/recording", (req, res) => {
3455
+ if (this.config.twilioToken) {
3456
+ const signature = req.headers["x-twilio-signature"] || "";
3457
+ const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
3458
+ const params = req.body ?? {};
3459
+ if (!validateTwilioSignature(url, params, signature, this.config.twilioToken)) {
3460
+ res.status(403).send("Invalid signature");
3461
+ return;
3462
+ }
3463
+ }
3464
+ const body = req.body;
3465
+ const recordingSid = body["RecordingSid"] ?? "";
3466
+ const recordingUrl = body["RecordingUrl"] ?? "";
3467
+ const callSid = body["CallSid"] ?? "";
3468
+ getLogger().info(`Recording ${recordingSid} for call ${callSid}: ${recordingUrl}`);
3469
+ res.status(204).send();
3470
+ });
3471
+ app.post("/webhooks/twilio/amd", async (req, res) => {
3472
+ if (this.config.twilioToken) {
3473
+ const signature = req.headers["x-twilio-signature"] || "";
3474
+ const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
3475
+ const params = req.body ?? {};
3476
+ if (!validateTwilioSignature(url, params, signature, this.config.twilioToken)) {
3477
+ res.status(403).send("Invalid signature");
3478
+ return;
3479
+ }
3480
+ }
3481
+ const body = req.body;
3482
+ const answeredBy = body["AnsweredBy"] ?? "";
3483
+ const callSid = body["CallSid"] ?? "";
3484
+ getLogger().info(`AMD result for ${callSid}: ${answeredBy}`);
3485
+ if ((answeredBy === "machine_end_beep" || answeredBy === "machine_end_silence") && this.voicemailMessage && this.config.twilioSid && this.config.twilioToken) {
3486
+ const twiml = `<Response><Say>${xmlEscape(this.voicemailMessage)}</Say><Hangup/></Response>`;
3487
+ try {
3488
+ const vmUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.config.twilioSid}/Calls/${callSid}.json`;
3489
+ const vmResp = await fetch(vmUrl, {
3490
+ method: "POST",
3491
+ headers: {
3492
+ "Content-Type": "application/x-www-form-urlencoded",
3493
+ "Authorization": `Basic ${Buffer.from(`${this.config.twilioSid}:${this.config.twilioToken}`).toString("base64")}`
3494
+ },
3495
+ body: new URLSearchParams({ Twiml: twiml }).toString()
3496
+ });
3497
+ if (vmResp.ok) {
3498
+ getLogger().info(`Voicemail dropped for ${callSid}`);
3499
+ } else {
3500
+ getLogger().warn(`Could not drop voicemail: ${await vmResp.text()}`);
3501
+ }
3502
+ } catch (e) {
3503
+ getLogger().warn(`Could not drop voicemail: ${String(e)}`);
3504
+ }
3505
+ }
3506
+ res.status(204).send();
3507
+ });
3508
+ app.post("/webhooks/twilio/voice", (req, res) => {
3509
+ if (this.config.twilioToken) {
3510
+ const signature = req.headers["x-twilio-signature"] || "";
3511
+ const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
3512
+ const params = req.body ?? {};
3513
+ if (!validateTwilioSignature(url, params, signature, this.config.twilioToken)) {
3514
+ res.status(403).send("Invalid signature");
3515
+ return;
3516
+ }
3517
+ } else if (!this.twilioTokenWarningLogged) {
3518
+ this.twilioTokenWarningLogged = true;
3519
+ getLogger().warn("Twilio webhook signature validation disabled \u2014 set twilioToken for production");
3520
+ }
3521
+ const callSid = req.body.CallSid || "";
3522
+ const caller = req.body.From || "";
3523
+ const callee = req.body.To || "";
3524
+ const rawStreamUrl = `wss://${this.config.webhookUrl}/ws/stream/${callSid}?caller=${encodeURIComponent(caller)}&callee=${encodeURIComponent(callee)}`;
3525
+ const xmlStreamUrl = xmlEscape(rawStreamUrl);
3526
+ const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${xmlStreamUrl}"/></Connect></Response>`;
3527
+ res.type("text/xml").send(twiml);
3528
+ });
3529
+ app.post("/webhooks/telnyx/voice", (req, res) => {
3530
+ if (this.config.telnyxPublicKey) {
3531
+ const rawBody = req.rawBody ?? "";
3532
+ const signature = req.headers["telnyx-signature-ed25519"] ?? "";
3533
+ const timestamp = req.headers["telnyx-timestamp"] ?? "";
3534
+ if (!signature || !timestamp || !validateTelnyxSignature(rawBody, signature, timestamp, this.config.telnyxPublicKey)) {
3535
+ getLogger().warn("Telnyx webhook rejected: invalid or missing Ed25519 signature");
3536
+ return res.status(403).send("Invalid signature");
3537
+ }
3538
+ } else {
3539
+ getLogger().warn("Telnyx webhook signature verification is disabled. Set telnyxPublicKey in LocalOptions for production use.");
3540
+ }
3541
+ const body = req.body;
3542
+ if (typeof body?.data !== "object" || body.data === null || Array.isArray(body.data)) {
3543
+ return res.status(400).send("Invalid body");
3544
+ }
3545
+ if (typeof body.data.event_type !== "string" || typeof body.data.payload !== "object" || body.data.payload === null) {
3546
+ return res.status(400).send("Invalid body");
3547
+ }
3548
+ const eventType = body?.data?.event_type ?? "";
3549
+ if (eventType === "call.initiated") {
3550
+ const payload = body?.data?.payload ?? {};
3551
+ const callControlId = payload.call_control_id ?? "";
3552
+ const caller = payload.from ?? "";
3553
+ const callee = payload.to ?? "";
3554
+ const streamUrl = `wss://${this.config.webhookUrl}/ws/stream/${encodeURIComponent(callControlId)}?caller=${encodeURIComponent(caller)}&callee=${encodeURIComponent(callee)}`;
3555
+ const commands = [
3556
+ { command: "answer" },
3557
+ {
3558
+ command: "stream_start",
3559
+ params: {
3560
+ stream_url: streamUrl,
3561
+ stream_track: "both_tracks"
3562
+ }
3563
+ }
3564
+ ];
3565
+ res.json({ commands });
3566
+ } else {
3567
+ res.json({ received: true });
3568
+ }
3569
+ });
3570
+ this.server = (0, import_http.createServer)(app);
3571
+ this.wss = new import_ws5.WebSocketServer({ noServer: true });
3572
+ const MAX_WS_PER_IP = 10;
3573
+ const wsConnectionsByIp = /* @__PURE__ */ new Map();
3574
+ this.server.on("upgrade", (req, socket, head) => {
3575
+ const remoteIp = (req.socket?.remoteAddress ?? "unknown").replace(/^::ffff:/, "");
3576
+ const currentCount = wsConnectionsByIp.get(remoteIp) ?? 0;
3577
+ if (currentCount >= MAX_WS_PER_IP) {
3578
+ getLogger().warn(`WebSocket upgrade rejected: too many connections from ${remoteIp}`);
3579
+ socket.write("HTTP/1.1 429 Too Many Requests\r\n\r\n");
3580
+ socket.destroy();
3581
+ return;
3582
+ }
3583
+ getLogger().info(`Upgrade request: ${req.url}`);
3584
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
3585
+ wsConnectionsByIp.set(remoteIp, (wsConnectionsByIp.get(remoteIp) ?? 0) + 1);
3586
+ ws.once("close", () => {
3587
+ const count = (wsConnectionsByIp.get(remoteIp) ?? 1) - 1;
3588
+ if (count <= 0) {
3589
+ wsConnectionsByIp.delete(remoteIp);
3590
+ } else {
3591
+ wsConnectionsByIp.set(remoteIp, count);
3592
+ }
3593
+ });
3594
+ this.wss.emit("connection", ws, req);
3595
+ });
3596
+ });
3597
+ this.wss.on("connection", (ws, req) => {
3598
+ const url = new URL(req.url ?? "", `http://localhost`);
3599
+ getLogger().info(`WebSocket connected: ${req.url}`);
3600
+ this.activeConnections.add(ws);
3601
+ ws.once("close", () => {
3602
+ this.activeConnections.delete(ws);
3603
+ });
3604
+ const isTelnyx = this.config.telephonyProvider === "telnyx";
3605
+ if (isTelnyx) {
3606
+ this.handleTelnyxStream(ws, url);
3607
+ } else {
3608
+ this.handleTwilioStream(ws, url);
3609
+ }
3610
+ });
3611
+ await new Promise((resolve) => {
3612
+ this.server.listen(port, "0.0.0.0", () => {
3613
+ getLogger().info(`
3614
+ \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557
3615
+ \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557
3616
+ \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D
3617
+ \u2588\u2588\u2554\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557
3618
+ \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551
3619
+ \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D
3620
+
3621
+ Connect AI agents to phone numbers with 10 lines of code
3622
+ `);
3623
+ getLogger().info(`Server on port ${port}`);
3624
+ getLogger().info(`Webhook: https://${this.config.webhookUrl}`);
3625
+ getLogger().info(`Phone: ${this.config.phoneNumber}`);
3626
+ resolve();
3627
+ });
3628
+ });
3629
+ }
3630
+ // ---------------------------------------------------------------------------
3631
+ // Stream handler helpers
3632
+ // ---------------------------------------------------------------------------
3633
+ /** Build the shared StreamHandlerDeps for the current server configuration. */
3634
+ buildStreamHandlerDeps(bridge) {
3635
+ return {
3636
+ config: this.config,
3637
+ agent: this.agent,
3638
+ bridge,
3639
+ metricsStore: this.metricsStore,
3640
+ pricing: this.pricing,
3641
+ remoteHandler: this.remoteHandler,
3642
+ onCallStart: this.onCallStart,
3643
+ onCallEnd: this.onCallEnd,
3644
+ onTranscript: this.onTranscript,
3645
+ onMessage: this.onMessage,
3646
+ onMetrics: this.onMetrics,
3647
+ recording: this.recording,
3648
+ buildAIAdapter: (resolvedPrompt) => buildAIAdapter(this.config, this.agent, resolvedPrompt),
3649
+ sanitizeVariables,
3650
+ resolveVariables
3651
+ };
3652
+ }
3653
+ // ---------------------------------------------------------------------------
3654
+ // Twilio WebSocket message parser (thin layer)
3655
+ // ---------------------------------------------------------------------------
3656
+ handleTwilioStream(ws, url) {
3657
+ const caller = url.searchParams.get("caller") ?? "";
3658
+ const callee = url.searchParams.get("callee") ?? "";
3659
+ const bridge = new TwilioBridge(this.config);
3660
+ const handler = new StreamHandler(this.buildStreamHandlerDeps(bridge), ws, caller, callee);
3661
+ ws.on("message", async (raw) => {
3662
+ try {
3663
+ let data;
3664
+ try {
3665
+ data = JSON.parse(raw.toString());
3666
+ } catch (e) {
3667
+ getLogger().error("Failed to parse WS message:", e);
3668
+ return;
3669
+ }
3670
+ const event = data.event;
3671
+ getLogger().info(`WS event: ${event}`);
3672
+ if (event === "start") {
3673
+ handler.setStreamSid(data.streamSid ?? "");
3674
+ const callSid = data.start?.callSid ?? "";
3675
+ const customParameters = data.start?.customParameters ?? {};
3676
+ await handler.handleCallStart(callSid, customParameters);
3677
+ } else if (event === "media") {
3678
+ const payload = data.media?.payload ?? "";
3679
+ handler.handleAudio(Buffer.from(payload, "base64"));
3680
+ } else if (event === "mark") {
3681
+ } else if (event === "dtmf") {
3682
+ const digit = data.dtmf?.digit ?? "";
3683
+ await handler.handleDtmf(digit);
3684
+ } else if (event === "stop") {
3685
+ await handler.handleStop();
3686
+ }
3687
+ } catch (err) {
3688
+ getLogger().error("Stream handler error:", err);
3689
+ }
3690
+ });
3691
+ ws.on("close", async () => {
3692
+ await handler.handleWsClose();
3693
+ });
3694
+ }
3695
+ // ---------------------------------------------------------------------------
3696
+ // Telnyx WebSocket message parser (thin layer)
3697
+ // ---------------------------------------------------------------------------
3698
+ handleTelnyxStream(ws, url) {
3699
+ const caller = url.searchParams.get("caller") ?? "";
3700
+ const callee = url.searchParams.get("callee") ?? "";
3701
+ const bridge = new TelnyxBridge(this.config);
3702
+ const handler = new StreamHandler(this.buildStreamHandlerDeps(bridge), ws, caller, callee);
3703
+ let streamStarted = false;
3704
+ ws.on("message", async (raw) => {
3705
+ try {
3706
+ let data;
3707
+ try {
3708
+ data = JSON.parse(raw.toString());
3709
+ } catch (e) {
3710
+ getLogger().error("Failed to parse Telnyx WS message:", e);
3711
+ return;
3712
+ }
3713
+ const eventType = data.event_type ?? "";
3714
+ getLogger().info(`Telnyx event: ${eventType}`);
3715
+ if (eventType === "stream_started" && !streamStarted) {
3716
+ streamStarted = true;
3717
+ const callControlId = data.payload?.call_control_id ?? "";
3718
+ await handler.handleCallStart(callControlId);
3719
+ } else if (eventType === "media") {
3720
+ const audioChunk = data.payload?.audio?.chunk ?? "";
3721
+ if (!audioChunk) return;
3722
+ handler.handleAudio(Buffer.from(audioChunk, "base64"));
3723
+ } else if (eventType === "stream_stopped") {
3724
+ await handler.handleStop();
3725
+ }
3726
+ } catch (err) {
3727
+ getLogger().error("Stream handler error (Telnyx):", err);
3728
+ }
3729
+ });
3730
+ ws.on("close", async () => {
3731
+ await handler.handleWsClose();
3732
+ });
3733
+ }
3734
+ // ---------------------------------------------------------------------------
3735
+ // Graceful shutdown
3736
+ // ---------------------------------------------------------------------------
3737
+ /**
3738
+ * Gracefully stop the server.
3739
+ *
3740
+ * 1. Stop accepting new connections (close the HTTP server).
3741
+ * 2. Send close to all active WebSockets.
3742
+ * 3. Wait up to 10 seconds for active calls to finish.
3743
+ * 4. Force-close remaining connections.
3744
+ * 5. Close the HTTP server.
3745
+ */
3746
+ async stop() {
3747
+ if (!this.server) return;
3748
+ const httpClosePromise = new Promise((resolve) => {
3749
+ this.server.close(() => resolve());
3750
+ });
3751
+ for (const ws of this.activeConnections) {
3752
+ try {
3753
+ ws.close(1001, "Server shutting down");
3754
+ } catch {
3755
+ }
3756
+ }
3757
+ if (this.activeConnections.size > 0) {
3758
+ getLogger().info(`Waiting for ${this.activeConnections.size} active connection(s) to close...`);
3759
+ await Promise.race([
3760
+ new Promise((resolve) => {
3761
+ const checkInterval = setInterval(() => {
3762
+ if (this.activeConnections.size === 0) {
3763
+ clearInterval(checkInterval);
3764
+ resolve();
3765
+ }
3766
+ }, 100);
3767
+ }),
3768
+ new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_TIMEOUT_MS))
3769
+ ]);
3770
+ }
3771
+ if (this.activeConnections.size > 0) {
3772
+ getLogger().info(`Force-closing ${this.activeConnections.size} remaining connection(s)`);
3773
+ for (const ws of this.activeConnections) {
3774
+ try {
3775
+ ws.terminate();
3776
+ } catch {
3777
+ }
3778
+ }
3779
+ this.activeConnections.clear();
3780
+ }
3781
+ await httpClosePromise;
3782
+ this.server = null;
3783
+ this.wss = null;
3784
+ }
3785
+ };
3786
+
3787
+ // src/client.ts
3788
+ var DEFAULT_BACKEND_URL2 = "wss://api.getpatter.com";
3789
+ var DEFAULT_REST_URL = "https://api.getpatter.com";
3790
+ var Patter = class {
3791
+ apiKey;
3792
+ backendUrl;
3793
+ restUrl;
3794
+ connection;
3795
+ mode;
3796
+ localConfig;
3797
+ embeddedServer = null;
3798
+ constructor(options) {
3799
+ if ("mode" in options && options.mode === "local") {
3800
+ const local = options;
3801
+ if (!local.phoneNumber) {
3802
+ throw new Error("Local mode requires phoneNumber");
3803
+ }
3804
+ if (!local.webhookUrl) {
3805
+ throw new Error("Local mode requires webhookUrl (e.g., your ngrok URL)");
3806
+ }
3807
+ if (!local.twilioSid && !local.telnyxKey) {
3808
+ throw new Error("Local mode requires twilioSid or telnyxKey");
3809
+ }
3810
+ if (local.twilioSid && !local.twilioToken) {
3811
+ throw new Error("twilioToken is required when using twilioSid");
3812
+ }
3813
+ this.mode = "local";
3814
+ this.localConfig = options;
3815
+ this.apiKey = "";
3816
+ this.backendUrl = DEFAULT_BACKEND_URL2;
3817
+ this.restUrl = DEFAULT_REST_URL;
3818
+ this.connection = new PatterConnection("", DEFAULT_BACKEND_URL2);
3819
+ } else {
3820
+ const cloudOpts = options;
3821
+ this.mode = "cloud";
3822
+ this.localConfig = null;
3823
+ this.apiKey = cloudOpts.apiKey;
3824
+ this.backendUrl = cloudOpts.backendUrl ?? DEFAULT_BACKEND_URL2;
3825
+ this.restUrl = cloudOpts.restUrl ?? DEFAULT_REST_URL;
3826
+ this.connection = new PatterConnection(this.apiKey, this.backendUrl);
3827
+ }
3828
+ }
3829
+ // === Local mode ===
3830
+ agent(opts) {
3831
+ if (opts.provider) {
3832
+ const valid = ["openai_realtime", "elevenlabs_convai", "pipeline"];
3833
+ if (!valid.includes(opts.provider)) {
3834
+ throw new Error(`provider must be one of: ${valid.join(", ")}. Got: '${opts.provider}'`);
3835
+ }
3836
+ }
3837
+ if (opts.tools) {
3838
+ if (!Array.isArray(opts.tools)) {
3839
+ throw new TypeError("tools must be an array");
3840
+ }
3841
+ opts.tools.forEach((tool, i) => {
3842
+ if (!tool.name) throw new Error(`tools[${i}] missing required 'name' field`);
3843
+ if (!tool.webhookUrl && !tool.handler) throw new Error(`tools[${i}] requires either 'webhookUrl' or 'handler'`);
3844
+ });
3845
+ }
3846
+ if (opts.variables !== void 0 && (typeof opts.variables !== "object" || Array.isArray(opts.variables))) {
3847
+ throw new TypeError("variables must be an object");
3848
+ }
3849
+ return { ...opts };
3850
+ }
3851
+ async serve(opts) {
3852
+ if (this.mode !== "local" || !this.localConfig) {
3853
+ throw new Error("serve() is only available in local mode");
3854
+ }
3855
+ if (!opts.agent || typeof opts.agent !== "object") {
3856
+ throw new TypeError("agent is required. Use phone.agent() to create one.");
3857
+ }
3858
+ if (!opts.agent.systemPrompt && opts.agent.provider !== "pipeline") {
3859
+ throw new Error("agent.systemPrompt is required");
3860
+ }
3861
+ if (opts.port !== void 0) {
3862
+ if (typeof opts.port !== "number" || opts.port < 1 || opts.port > 65535) {
3863
+ throw new RangeError(`port must be between 1 and 65535, got ${opts.port}`);
3864
+ }
3865
+ }
3866
+ const validProviders = ["openai_realtime", "elevenlabs_convai", "pipeline"];
3867
+ if (opts.agent.provider && !validProviders.includes(opts.agent.provider)) {
3868
+ throw new Error(`agent.provider must be one of: ${validProviders.join(", ")}`);
3869
+ }
3870
+ this.embeddedServer = new EmbeddedServer(
3871
+ {
3872
+ twilioSid: this.localConfig.twilioSid,
3873
+ twilioToken: this.localConfig.twilioToken,
3874
+ openaiKey: this.localConfig.openaiKey,
3875
+ phoneNumber: this.localConfig.phoneNumber,
3876
+ webhookUrl: this.localConfig.webhookUrl,
3877
+ telephonyProvider: this.localConfig.telephonyProvider,
3878
+ telnyxKey: this.localConfig.telnyxKey,
3879
+ telnyxConnectionId: this.localConfig.telnyxConnectionId,
3880
+ telnyxPublicKey: this.localConfig.telnyxPublicKey
3881
+ },
3882
+ opts.agent,
3883
+ opts.onCallStart,
3884
+ opts.onCallEnd,
3885
+ opts.onTranscript,
3886
+ opts.onMessage,
3887
+ opts.recording ?? false,
3888
+ opts.voicemailMessage ?? "",
3889
+ opts.onMetrics,
3890
+ opts.pricing,
3891
+ opts.dashboard ?? false,
3892
+ opts.dashboardToken ?? ""
3893
+ );
3894
+ await this.embeddedServer.start(opts.port ?? 8e3);
3895
+ }
3896
+ async test(opts) {
3897
+ if (this.mode !== "local") {
3898
+ throw new Error("test() is only available in local mode");
3899
+ }
3900
+ const { TestSession: TestSession2 } = await Promise.resolve().then(() => (init_test_mode(), test_mode_exports));
3901
+ const session = new TestSession2();
3902
+ await session.run({
3903
+ agent: opts.agent,
3904
+ openaiKey: this.localConfig?.openaiKey,
3905
+ onMessage: typeof opts.onMessage === "function" ? opts.onMessage : void 0,
3906
+ onCallStart: opts.onCallStart,
3907
+ onCallEnd: opts.onCallEnd
3908
+ });
3909
+ }
3910
+ // === Cloud mode legacy ===
3911
+ async connect(options) {
3912
+ if (options.provider && options.providerKey && options.number) {
3913
+ await this.registerNumber(
3914
+ options.provider,
3915
+ options.providerKey,
3916
+ options.number,
3917
+ options.providerSecret,
3918
+ options.country ?? "US",
3919
+ options.stt,
3920
+ options.tts
3921
+ );
3922
+ }
3923
+ await this.connection.connect({
3924
+ onMessage: options.onMessage,
3925
+ onCallStart: options.onCallStart,
3926
+ onCallEnd: options.onCallEnd
3927
+ });
3928
+ }
3929
+ async call(options) {
3930
+ if (this.mode === "local") {
3931
+ const localOpts = options;
3932
+ if (!localOpts.to) {
3933
+ throw new Error("'to' phone number is required");
3934
+ }
3935
+ if (!localOpts.to.startsWith("+")) {
3936
+ throw new Error(`'to' must be in E.164 format (e.g., '+1234567890'). Got: '${localOpts.to}'`);
3937
+ }
3938
+ if (!this.localConfig) {
3939
+ throw new Error("local config missing");
3940
+ }
3941
+ const { phoneNumber, webhookUrl, telephonyProvider } = this.localConfig;
3942
+ if (telephonyProvider === "telnyx") {
3943
+ const telnyxKey = this.localConfig.telnyxKey ?? "";
3944
+ const connectionId = this.localConfig.telnyxConnectionId ?? "";
3945
+ const streamUrl = `wss://${webhookUrl}/ws/stream/${encodeURIComponent(localOpts.to)}?caller=${encodeURIComponent(phoneNumber)}&callee=${encodeURIComponent(localOpts.to)}`;
3946
+ const response2 = await fetch("https://api.telnyx.com/v2/calls", {
3947
+ method: "POST",
3948
+ headers: {
3949
+ "Content-Type": "application/json",
3950
+ Authorization: `Bearer ${telnyxKey}`
3951
+ },
3952
+ body: JSON.stringify({
3953
+ connection_id: connectionId,
3954
+ from: phoneNumber,
3955
+ to: localOpts.to,
3956
+ stream_url: streamUrl,
3957
+ stream_track: "both_tracks"
3958
+ })
3959
+ });
3960
+ if (!response2.ok) {
3961
+ throw new ProvisionError(`Failed to initiate Telnyx call: ${await response2.text()}`);
3962
+ }
3963
+ return;
3964
+ }
3965
+ const twilioSid = this.localConfig.twilioSid ?? "";
3966
+ const twilioToken = this.localConfig.twilioToken ?? "";
3967
+ const statusCallbackUrl = `https://${webhookUrl}/webhooks/twilio/status`;
3968
+ const url = `https://api.twilio.com/2010-04-01/Accounts/${twilioSid}/Calls.json`;
3969
+ const params = new URLSearchParams({
3970
+ To: localOpts.to,
3971
+ From: phoneNumber,
3972
+ Url: `https://${webhookUrl}/webhooks/twilio/voice`,
3973
+ StatusCallback: statusCallbackUrl,
3974
+ StatusCallbackMethod: "POST"
3975
+ });
3976
+ if (localOpts.machineDetection) {
3977
+ params.append("MachineDetection", "DetectMessageEnd");
3978
+ params.append("AsyncAmd", "true");
3979
+ params.append("AsyncAmdStatusCallback", `https://${webhookUrl}/webhooks/twilio/amd`);
3980
+ }
3981
+ if (localOpts.voicemailMessage && this.embeddedServer) {
3982
+ this.embeddedServer.voicemailMessage = localOpts.voicemailMessage;
3983
+ }
3984
+ const response = await fetch(url, {
3985
+ method: "POST",
3986
+ headers: {
3987
+ "Content-Type": "application/x-www-form-urlencoded",
3988
+ Authorization: `Basic ${Buffer.from(`${twilioSid}:${twilioToken}`).toString("base64")}`
3989
+ },
3990
+ body: params.toString()
3991
+ });
3992
+ if (!response.ok) {
3993
+ throw new ProvisionError(`Failed to initiate call: ${await response.text()}`);
3994
+ }
3995
+ return;
3996
+ }
3997
+ const cloudOpts = options;
3998
+ if (!this.connection.isConnected) {
3999
+ if (cloudOpts.onMessage) {
4000
+ await this.connection.connect({ onMessage: cloudOpts.onMessage });
4001
+ } else {
4002
+ throw new PatterConnectionError(
4003
+ "Not connected. Call connect() first or pass onMessage."
4004
+ );
4005
+ }
4006
+ }
4007
+ await this.connection.requestCall(
4008
+ cloudOpts.fromNumber ?? "",
4009
+ cloudOpts.to,
4010
+ cloudOpts.firstMessage ?? ""
4011
+ );
4012
+ }
4013
+ async disconnect() {
4014
+ await this.connection.disconnect();
4015
+ }
4016
+ // === Agent Management ===
4017
+ async createAgent(opts) {
4018
+ const response = await fetch(`${this.restUrl}/api/agents`, {
4019
+ method: "POST",
4020
+ headers: { "Content-Type": "application/json", "X-API-Key": this.apiKey },
4021
+ body: JSON.stringify({
4022
+ name: opts.name,
4023
+ system_prompt: opts.systemPrompt,
4024
+ model: opts.model ?? "gpt-4o-mini-realtime-preview",
4025
+ voice: opts.voice ?? "alloy",
4026
+ voice_provider: opts.voiceProvider ?? "openai",
4027
+ language: opts.language ?? "en",
4028
+ first_message: opts.firstMessage ?? null,
4029
+ tools: opts.tools?.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters, webhook_url: t.webhookUrl })) ?? null
4030
+ })
4031
+ });
4032
+ if (response.status !== 201) throw new ProvisionError(`Failed to create agent: ${await response.text()}`);
4033
+ const data = await response.json();
4034
+ return { id: data.id, name: data.name, systemPrompt: data.system_prompt, model: data.model, voice: data.voice, voiceProvider: data.voice_provider, language: data.language, firstMessage: data.first_message, tools: data.tools };
4035
+ }
4036
+ async listAgents() {
4037
+ const response = await fetch(`${this.restUrl}/api/agents`, { headers: { "X-API-Key": this.apiKey } });
4038
+ if (!response.ok) throw new ProvisionError(`Failed to list agents: ${response.status}`);
4039
+ const data = await response.json();
4040
+ return data.map((a) => ({ id: a.id, name: a.name, systemPrompt: a.system_prompt, model: a.model, voice: a.voice, voiceProvider: a.voice_provider, language: a.language, firstMessage: a.first_message, tools: a.tools }));
4041
+ }
4042
+ async buyNumber(opts = {}) {
4043
+ const response = await fetch(`${this.restUrl}/api/numbers/buy`, {
4044
+ method: "POST",
4045
+ headers: { "Content-Type": "application/json", "X-API-Key": this.apiKey },
4046
+ body: JSON.stringify({ country: opts.country ?? "US", provider: opts.provider ?? "twilio" })
4047
+ });
4048
+ if (response.status !== 201) throw new ProvisionError(`Failed to buy number: ${await response.text()}`);
4049
+ const data = await response.json();
4050
+ return { id: data.id, number: data.number, provider: data.provider, country: data.country, status: data.status, agentId: data.agent_id };
4051
+ }
4052
+ async assignAgent(numberId, agentId) {
4053
+ const response = await fetch(`${this.restUrl}/api/phone-numbers/${numberId}/assign-agent`, {
4054
+ method: "POST",
4055
+ headers: { "Content-Type": "application/json", "X-API-Key": this.apiKey },
4056
+ body: JSON.stringify({ agent_id: agentId })
4057
+ });
4058
+ if (response.status !== 200) throw new ProvisionError(`Failed to assign agent: ${await response.text()}`);
4059
+ }
4060
+ async listCalls(limit = 50) {
4061
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1e3) {
4062
+ throw new RangeError(`limit must be an integer between 1 and 1000, got ${limit}`);
4063
+ }
4064
+ const response = await fetch(`${this.restUrl}/api/calls?limit=${limit}`, { headers: { "X-API-Key": this.apiKey } });
4065
+ if (!response.ok) throw new ProvisionError(`Failed to list calls: ${response.status}`);
4066
+ const data = await response.json();
4067
+ return data.map((c) => ({ id: c.id, direction: c.direction, caller: c.caller, callee: c.callee, startedAt: c.started_at, endedAt: c.ended_at, durationSeconds: c.duration_seconds, status: c.status, transcript: c.transcript }));
4068
+ }
4069
+ // Provider helpers
4070
+ static deepgram = deepgram;
4071
+ static whisper = whisper;
4072
+ static elevenlabs = elevenlabs;
4073
+ static openaiTts = openaiTts;
4074
+ static guardrail(opts) {
4075
+ return {
4076
+ name: opts.name,
4077
+ blockedTerms: opts.blockedTerms,
4078
+ check: opts.check,
4079
+ replacement: opts.replacement ?? "I'm sorry, I can't respond to that."
4080
+ };
4081
+ }
4082
+ /**
4083
+ * Create a tool definition for use with `agent({ tools: [...] })`.
4084
+ *
4085
+ * Either `handler` (a function) or `webhookUrl` must be provided.
4086
+ *
4087
+ * @param opts.name - Tool name (visible to the LLM).
4088
+ * @param opts.description - What the tool does (visible to the LLM).
4089
+ * @param opts.parameters - JSON Schema for tool arguments.
4090
+ * @param opts.handler - Async function called in-process when the LLM invokes the tool.
4091
+ * @param opts.webhookUrl - URL to POST to when the LLM invokes the tool.
4092
+ *
4093
+ * @example
4094
+ * ```ts
4095
+ * phone.agent({
4096
+ * systemPrompt: 'You are a pizza bot.',
4097
+ * tools: [
4098
+ * Patter.tool({
4099
+ * name: 'check_menu',
4100
+ * description: 'Check available menu items',
4101
+ * handler: async (args) => JSON.stringify({ items: ['margherita'] }),
4102
+ * }),
4103
+ * ],
4104
+ * });
4105
+ * ```
4106
+ */
4107
+ static tool(opts) {
4108
+ if (!opts.handler && !opts.webhookUrl) {
4109
+ throw new Error("tool() requires either handler or webhookUrl");
4110
+ }
4111
+ const t = {
4112
+ name: opts.name,
4113
+ description: opts.description ?? "",
4114
+ parameters: opts.parameters ?? { type: "object", properties: {} }
4115
+ };
4116
+ if (opts.handler) {
4117
+ t.handler = opts.handler;
4118
+ }
4119
+ if (opts.webhookUrl) {
4120
+ t.webhookUrl = opts.webhookUrl;
4121
+ }
4122
+ return t;
4123
+ }
4124
+ // Internal
4125
+ async registerNumber(provider, providerKey, number, providerSecret, country = "US", stt, tts) {
4126
+ const credentials = { api_key: providerKey };
4127
+ if (providerSecret) credentials.api_secret = providerSecret;
4128
+ const response = await fetch(`${this.restUrl}/api/phone-numbers`, {
4129
+ method: "POST",
4130
+ headers: {
4131
+ "Content-Type": "application/json",
4132
+ "X-API-Key": this.apiKey
4133
+ },
4134
+ body: JSON.stringify({
4135
+ number,
4136
+ provider,
4137
+ provider_credentials: credentials,
4138
+ country,
4139
+ stt_config: stt?.toDict() ?? null,
4140
+ tts_config: tts?.toDict() ?? null
4141
+ })
4142
+ });
4143
+ if (response.status === 409) return;
4144
+ if (response.status !== 201) {
4145
+ throw new ProvisionError(
4146
+ `Failed to register number: ${await response.text()}`
4147
+ );
4148
+ }
4149
+ }
4150
+ };
4151
+
4152
+ // src/index.ts
4153
+ init_logger();
4154
+ init_llm_loop();
4155
+ init_test_mode();
4156
+
4157
+ // src/transcoding.ts
4158
+ var MULAW_TO_PCM16_TABLE = (() => {
4159
+ const table = new Int16Array(256);
4160
+ for (let i = 0; i < 256; i++) {
4161
+ const mu = ~i & 255;
4162
+ const sign = mu & 128 ? -1 : 1;
4163
+ const exponent = mu >> 4 & 7;
4164
+ const mantissa = mu & 15;
4165
+ const magnitude = (mantissa << 1 | 33) << exponent + 2;
4166
+ table[i] = sign * (magnitude - 132);
4167
+ }
4168
+ return table;
4169
+ })();
4170
+ var PCM16_TO_MULAW_TABLE = (() => {
4171
+ const BIAS = 132;
4172
+ const CLIP = 32635;
4173
+ const table = new Uint8Array(65536);
4174
+ for (let i = 0; i < 65536; i++) {
4175
+ let sample = i >= 32768 ? i - 65536 : i;
4176
+ const sign = sample < 0 ? 128 : 0;
4177
+ if (sample < 0) sample = -sample;
4178
+ if (sample > CLIP) sample = CLIP;
4179
+ sample += BIAS;
4180
+ let exponent = 7;
4181
+ const exponentMask = 16384;
4182
+ for (let shift = exponentMask; shift > 0 && (sample & shift) === 0; shift >>= 1) {
4183
+ exponent--;
4184
+ }
4185
+ const mantissa = sample >> exponent + 3 & 15;
4186
+ const mulaw = ~(sign | exponent << 4 | mantissa) & 255;
4187
+ table[i] = mulaw;
4188
+ }
4189
+ return table;
4190
+ })();
4191
+ function mulawToPcm16(mulawData) {
4192
+ const out = Buffer.alloc(mulawData.length * 2);
4193
+ for (let i = 0; i < mulawData.length; i++) {
4194
+ out.writeInt16LE(MULAW_TO_PCM16_TABLE[mulawData[i]], i * 2);
4195
+ }
4196
+ return out;
4197
+ }
4198
+ function pcm16ToMulaw(pcmData) {
4199
+ const sampleCount = Math.floor(pcmData.length / 2);
4200
+ const out = Buffer.alloc(sampleCount);
4201
+ for (let i = 0; i < sampleCount; i++) {
4202
+ const sample = pcmData.readInt16LE(i * 2);
4203
+ out[i] = PCM16_TO_MULAW_TABLE[sample + 65536 & 65535];
4204
+ }
4205
+ return out;
4206
+ }
4207
+ function resample8kTo16k(pcm8k) {
4208
+ if (pcm8k.length === 0) return Buffer.alloc(0);
4209
+ const sampleCount = Math.floor(pcm8k.length / 2);
4210
+ const out = Buffer.alloc(sampleCount * 2 * 2);
4211
+ for (let i = 0; i < sampleCount; i++) {
4212
+ const current = pcm8k.readInt16LE(i * 2);
4213
+ const next = i + 1 < sampleCount ? pcm8k.readInt16LE((i + 1) * 2) : current;
4214
+ const interpolated = Math.round((current + next) / 2);
4215
+ out.writeInt16LE(current, i * 4);
4216
+ out.writeInt16LE(interpolated, i * 4 + 2);
4217
+ }
4218
+ return out;
4219
+ }
4220
+ function resample16kTo8k(pcm16k) {
4221
+ if (pcm16k.length === 0) return Buffer.alloc(0);
4222
+ const sampleCount = Math.floor(pcm16k.length / 2);
4223
+ const outSamples = Math.floor(sampleCount / 2);
4224
+ const out = Buffer.alloc(outSamples * 2);
4225
+ for (let i = 0; i < outSamples; i++) {
4226
+ const sample = pcm16k.readInt16LE(i * 2 * 2);
4227
+ out.writeInt16LE(sample, i * 2);
4228
+ }
4229
+ return out;
4230
+ }
4231
+ function resample24kTo16k(pcm24k) {
4232
+ if (pcm24k.length === 0) return Buffer.alloc(0);
4233
+ const sampleCount = Math.floor(pcm24k.length / 2);
4234
+ const outSamples = Math.floor(sampleCount * 2 / 3);
4235
+ const out = Buffer.alloc(outSamples * 2);
4236
+ let outIdx = 0;
4237
+ for (let i = 0; i < sampleCount && outIdx < outSamples; i++) {
4238
+ if (i % 3 === 2) continue;
4239
+ out.writeInt16LE(pcm24k.readInt16LE(i * 2), outIdx * 2);
4240
+ outIdx++;
4241
+ }
4242
+ return out;
4243
+ }
4244
+ // Annotate the CommonJS export names for ESM import in node:
4245
+ 0 && (module.exports = {
4246
+ AuthenticationError,
4247
+ CallMetricsAccumulator,
4248
+ DEFAULT_PRICING,
4249
+ DeepgramSTT,
4250
+ ElevenLabsConvAIAdapter,
4251
+ ElevenLabsTTS,
4252
+ LLMLoop,
4253
+ MetricsStore,
4254
+ OpenAILLMProvider,
4255
+ OpenAIRealtimeAdapter,
4256
+ OpenAITTS,
4257
+ Patter,
4258
+ PatterConnectionError,
4259
+ PatterError,
4260
+ ProvisionError,
4261
+ RemoteMessageHandler,
4262
+ TestSession,
4263
+ WhisperSTT,
4264
+ calculateRealtimeCost,
4265
+ calculateSttCost,
4266
+ calculateTelephonyCost,
4267
+ calculateTtsCost,
4268
+ callsToCsv,
4269
+ callsToJson,
4270
+ deepgram,
4271
+ elevenlabs,
4272
+ getLogger,
4273
+ isRemoteUrl,
4274
+ isWebSocketUrl,
4275
+ makeAuthMiddleware,
4276
+ mergePricing,
4277
+ mountApi,
4278
+ mountDashboard,
4279
+ mulawToPcm16,
4280
+ openaiTts,
4281
+ pcm16ToMulaw,
4282
+ resample16kTo8k,
4283
+ resample24kTo16k,
4284
+ resample8kTo16k,
4285
+ setLogger,
4286
+ whisper
4287
+ });