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