getpatter 0.6.2 → 0.6.4
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/README.md +5 -4
- package/dist/{carrier-config-4ZKVYAWV.mjs → carrier-config-7YGNRBPO.mjs} +60 -12
- package/dist/{chunk-R2T4JABZ.mjs → chunk-3VVATR6A.mjs} +8 -6
- package/dist/{chunk-LE63CSOB.mjs → chunk-7IIV3BY4.mjs} +1679 -228
- package/dist/{chunk-CL2U3YET.mjs → chunk-BO227NTF.mjs} +271 -54
- package/dist/cli.js +63 -20
- package/dist/dashboard/ui.html +10 -10
- package/dist/index.d.mts +4217 -3319
- package/dist/index.d.ts +4217 -3319
- package/dist/index.js +2815 -705
- package/dist/index.mjs +760 -392
- package/dist/{openai-realtime-2-CNFARP25.mjs → openai-realtime-2-L5EKAAUH.mjs} +1 -1
- package/dist/{silero-vad-LNDFGIY7.mjs → silero-vad-RGF5HCIR.mjs} +1 -1
- package/dist/{test-mode-RS57BDM6.mjs → test-mode-4QLLWYVV.mjs} +2 -2
- package/package.json +2 -1
- package/src/dashboard/ui.html +10 -10
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
createResampler8kTo16k,
|
|
6
6
|
mulawToPcm16,
|
|
7
7
|
pcm16ToMulaw
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-BO227NTF.mjs";
|
|
9
9
|
import {
|
|
10
10
|
getLogger
|
|
11
11
|
} from "./chunk-MVOQFAEO.mjs";
|
|
@@ -24,7 +24,7 @@ init_esm_shims();
|
|
|
24
24
|
|
|
25
25
|
// src/server.ts
|
|
26
26
|
init_esm_shims();
|
|
27
|
-
import
|
|
27
|
+
import crypto5 from "crypto";
|
|
28
28
|
import express from "express";
|
|
29
29
|
import { createServer } from "http";
|
|
30
30
|
import { WebSocketServer } from "ws";
|
|
@@ -361,6 +361,157 @@ var ElevenLabsConvAIAdapter = class _ElevenLabsConvAIAdapter {
|
|
|
361
361
|
}
|
|
362
362
|
};
|
|
363
363
|
|
|
364
|
+
// src/providers/plivo-adapter.ts
|
|
365
|
+
init_esm_shims();
|
|
366
|
+
var PLIVO_API_BASE = "https://api.plivo.com/v1";
|
|
367
|
+
async function dropPlivoVoicemail(callUuid, voicemailMessage, authId, authToken) {
|
|
368
|
+
if (!callUuid || !voicemailMessage || !authId || !authToken) return;
|
|
369
|
+
const auth = `Basic ${Buffer.from(`${authId}:${authToken}`).toString("base64")}`;
|
|
370
|
+
const base = `${PLIVO_API_BASE}/Account/${encodeURIComponent(authId)}/Call/${encodeURIComponent(callUuid)}`;
|
|
371
|
+
try {
|
|
372
|
+
const speak = await fetch(`${base}/Speak/`, {
|
|
373
|
+
method: "POST",
|
|
374
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: auth },
|
|
375
|
+
body: new URLSearchParams({ text: voicemailMessage }).toString(),
|
|
376
|
+
signal: AbortSignal.timeout(1e4)
|
|
377
|
+
});
|
|
378
|
+
if (!speak.ok) {
|
|
379
|
+
getLogger().warn(
|
|
380
|
+
`Plivo voicemail Speak failed (${speak.status}): ${(await speak.text()).slice(0, 200)}`
|
|
381
|
+
);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
await new Promise(
|
|
385
|
+
(r) => setTimeout(r, Math.min(3e4, voicemailMessage.length * 60))
|
|
386
|
+
);
|
|
387
|
+
await fetch(`${base}/`, { method: "DELETE", headers: { Authorization: auth } });
|
|
388
|
+
getLogger().info(`Voicemail dropped for ${callUuid}`);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
getLogger().warn(`Could not drop voicemail: ${String(e)}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function xmlEscapePlivo(s) {
|
|
394
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
395
|
+
}
|
|
396
|
+
var PlivoAdapter = class {
|
|
397
|
+
authId;
|
|
398
|
+
baseUrl;
|
|
399
|
+
authHeader;
|
|
400
|
+
constructor(authId, authToken) {
|
|
401
|
+
if (!authId) throw new Error("PlivoAdapter: authId is required");
|
|
402
|
+
if (!authToken) throw new Error("PlivoAdapter: authToken is required");
|
|
403
|
+
this.authId = authId;
|
|
404
|
+
this.baseUrl = `${PLIVO_API_BASE}/Account/${encodeURIComponent(authId)}`;
|
|
405
|
+
this.authHeader = `Basic ${Buffer.from(`${authId}:${authToken}`).toString("base64")}`;
|
|
406
|
+
}
|
|
407
|
+
async request(method, path4, jsonBody) {
|
|
408
|
+
const headers = { Authorization: this.authHeader };
|
|
409
|
+
if (jsonBody !== void 0) headers["Content-Type"] = "application/json";
|
|
410
|
+
const response = await fetch(`${this.baseUrl}${path4}`, {
|
|
411
|
+
method,
|
|
412
|
+
headers,
|
|
413
|
+
body: jsonBody !== void 0 ? JSON.stringify(jsonBody) : void 0,
|
|
414
|
+
signal: AbortSignal.timeout(3e4)
|
|
415
|
+
});
|
|
416
|
+
const text = await response.text();
|
|
417
|
+
if (!response.ok && response.status !== 404) {
|
|
418
|
+
throw new Error(`Plivo ${method} ${path4} failed: ${response.status} ${text}`);
|
|
419
|
+
}
|
|
420
|
+
let data = {};
|
|
421
|
+
if (text) {
|
|
422
|
+
try {
|
|
423
|
+
data = JSON.parse(text);
|
|
424
|
+
} catch {
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return { status: response.status, data };
|
|
428
|
+
}
|
|
429
|
+
/** Search and rent an available Plivo number for the given ISO country. */
|
|
430
|
+
async provisionNumber(countryIso) {
|
|
431
|
+
const { data } = await this.request(
|
|
432
|
+
"GET",
|
|
433
|
+
`/PhoneNumber/?country_iso=${encodeURIComponent(countryIso)}&limit=1`
|
|
434
|
+
);
|
|
435
|
+
const number = data.objects?.[0]?.number;
|
|
436
|
+
if (!number) throw new Error(`PlivoAdapter: no numbers available for ${countryIso}`);
|
|
437
|
+
await this.request("POST", `/PhoneNumber/${encodeURIComponent(number)}/`);
|
|
438
|
+
return number;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Point the inbound answer flow for ``number`` at ``answerUrl`` by creating
|
|
442
|
+
* (or reusing) a Plivo Application and linking the number to it. Most
|
|
443
|
+
* production deployments pre-configure this in the Plivo console; this
|
|
444
|
+
* mirrors Twilio's ``configureNumber`` auto-setup convenience.
|
|
445
|
+
*/
|
|
446
|
+
async configureNumber(number, answerUrl) {
|
|
447
|
+
const { data } = await this.request("POST", "/Application/", {
|
|
448
|
+
app_name: "patter-inbound",
|
|
449
|
+
answer_url: answerUrl,
|
|
450
|
+
answer_method: "POST"
|
|
451
|
+
});
|
|
452
|
+
if (!data.app_id) {
|
|
453
|
+
getLogger().warn("Plivo Application create returned no app_id");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
await this.request("POST", `/Number/${encodeURIComponent(number)}/`, { app_id: data.app_id });
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Place an outbound Plivo call routed through ``answerUrl``. Returns Plivo's
|
|
460
|
+
* ``request_uuid``. The WSS URL travels inside the answer XML, not as a dial
|
|
461
|
+
* parameter — mirroring the Python adapter.
|
|
462
|
+
*/
|
|
463
|
+
async initiateCall(opts) {
|
|
464
|
+
const payload = {
|
|
465
|
+
from: opts.from,
|
|
466
|
+
to: opts.to,
|
|
467
|
+
answer_url: opts.answerUrl,
|
|
468
|
+
answer_method: "POST"
|
|
469
|
+
};
|
|
470
|
+
if (opts.ringTimeout != null) payload.ring_timeout = Math.max(1, Math.floor(opts.ringTimeout));
|
|
471
|
+
if (opts.machineDetection) {
|
|
472
|
+
payload.machine_detection = "true";
|
|
473
|
+
payload.machine_detection_time = 5e3;
|
|
474
|
+
if (opts.machineDetectionUrl) {
|
|
475
|
+
payload.machine_detection_url = opts.machineDetectionUrl;
|
|
476
|
+
payload.machine_detection_method = "POST";
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const { data } = await this.request("POST", "/Call/", payload);
|
|
480
|
+
return { requestUuid: data.request_uuid ?? "" };
|
|
481
|
+
}
|
|
482
|
+
/** Hang up an active Plivo call by CallUUID. 204 and 404 are both success. */
|
|
483
|
+
async endCall(callUuid) {
|
|
484
|
+
if (!callUuid) throw new Error("PlivoAdapter: callUuid is required");
|
|
485
|
+
try {
|
|
486
|
+
await this.request("DELETE", `/Call/${encodeURIComponent(callUuid)}/`);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
getLogger().warn(`[PlivoAdapter] endCall failed for ${callUuid}: ${String(err)}`);
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Build the Plivo answer XML. Unlike Twilio (``url=`` attribute), Plivo's
|
|
494
|
+
* ``<Stream>`` takes the WSS URL as its **text content**. ``bidirectional``
|
|
495
|
+
* enables two-way audio; ``keepCallAlive`` keeps the leg up for the lifetime
|
|
496
|
+
* of the WebSocket. ``extraHeaders`` (comma-separated ``key=value``) is
|
|
497
|
+
* delivered back on the WS ``start`` frame as a caller/callee fallback.
|
|
498
|
+
*
|
|
499
|
+
* Mirrors the Python adapter's ``generate_stream_xml``.
|
|
500
|
+
*/
|
|
501
|
+
static generateStreamXml(streamUrl, contentType = "audio/x-mulaw;rate=8000", extraHeaders) {
|
|
502
|
+
let attrs = `bidirectional="true" keepCallAlive="true" contentType="${xmlEscapePlivo(contentType)}"`;
|
|
503
|
+
if (extraHeaders) {
|
|
504
|
+
const joined = Object.entries(extraHeaders).map(([k, v]) => `${k}=${v}`).join(",");
|
|
505
|
+
attrs += ` extraHeaders="${xmlEscapePlivo(joined)}"`;
|
|
506
|
+
}
|
|
507
|
+
return `<Response><Stream ${attrs}>${xmlEscapePlivo(streamUrl)}</Stream></Response>`;
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// src/telephony/plivo.ts
|
|
512
|
+
init_esm_shims();
|
|
513
|
+
import crypto from "crypto";
|
|
514
|
+
|
|
364
515
|
// src/provider-factory.ts
|
|
365
516
|
init_esm_shims();
|
|
366
517
|
async function createSTT(agent) {
|
|
@@ -370,6 +521,168 @@ async function createTTS(agent) {
|
|
|
370
521
|
return agent.tts ?? null;
|
|
371
522
|
}
|
|
372
523
|
|
|
524
|
+
// src/telephony/plivo.ts
|
|
525
|
+
var Carrier = class {
|
|
526
|
+
kind = "plivo";
|
|
527
|
+
authId;
|
|
528
|
+
authToken;
|
|
529
|
+
constructor(opts = {}) {
|
|
530
|
+
const authId = opts.authId ?? process.env.PLIVO_AUTH_ID;
|
|
531
|
+
const authToken = opts.authToken ?? process.env.PLIVO_AUTH_TOKEN;
|
|
532
|
+
if (!authId) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
"Plivo carrier requires authId. Pass { authId: 'MA...' } or set PLIVO_AUTH_ID in the environment."
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (!authToken) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"Plivo carrier requires authToken. Pass { authToken: '...' } or set PLIVO_AUTH_TOKEN in the environment."
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
this.authId = authId;
|
|
543
|
+
this.authToken = authToken;
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
function classifyPlivoAmd(result) {
|
|
547
|
+
const r = (result || "").trim().toLowerCase();
|
|
548
|
+
if (r === "human" || r === "person") return "human";
|
|
549
|
+
if (r.startsWith("machine") || r === "answering_machine" || r === "amd" || r === "true") {
|
|
550
|
+
return "machine";
|
|
551
|
+
}
|
|
552
|
+
if (r === "fax") return "fax";
|
|
553
|
+
return "unknown";
|
|
554
|
+
}
|
|
555
|
+
function validatePlivoSignature(url, nonce, signature, authToken, params, method = "POST") {
|
|
556
|
+
if (!signature || !nonce || !authToken) return false;
|
|
557
|
+
let base = url;
|
|
558
|
+
if (method === "POST" && params && Object.keys(params).length > 0) {
|
|
559
|
+
const keys = Object.keys(params).sort();
|
|
560
|
+
base += keys.map((k) => `${k}${params[k]}`).join("");
|
|
561
|
+
}
|
|
562
|
+
const signed = `${base}.${nonce}`;
|
|
563
|
+
const expected = crypto.createHmac("sha256", authToken).update(signed).digest("base64");
|
|
564
|
+
const expBuf = Buffer.from(expected);
|
|
565
|
+
for (const rawSig of signature.split(",")) {
|
|
566
|
+
const trimmed = rawSig.trim();
|
|
567
|
+
if (!trimmed) continue;
|
|
568
|
+
try {
|
|
569
|
+
const sigBuf = Buffer.from(trimmed);
|
|
570
|
+
if (sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf)) {
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
var PLIVO_DTMF_ALLOWED = new Set("0123456789*#ABCDabcdwW");
|
|
580
|
+
var PlivoBridge = class {
|
|
581
|
+
constructor(config) {
|
|
582
|
+
this.config = config;
|
|
583
|
+
const authId = config.plivoAuthId ?? "";
|
|
584
|
+
const authToken = config.plivoAuthToken ?? "";
|
|
585
|
+
this.authHeader = `Basic ${Buffer.from(`${authId}:${authToken}`).toString("base64")}`;
|
|
586
|
+
this.apiBase = `https://api.plivo.com/v1/Account/${encodeURIComponent(authId)}`;
|
|
587
|
+
}
|
|
588
|
+
config;
|
|
589
|
+
label = "Plivo";
|
|
590
|
+
telephonyProvider = "plivo";
|
|
591
|
+
inputWireFormat = "ulaw_8000";
|
|
592
|
+
authHeader;
|
|
593
|
+
apiBase;
|
|
594
|
+
sendAudio(ws, audioBase64, _streamSid) {
|
|
595
|
+
ws.send(
|
|
596
|
+
JSON.stringify({
|
|
597
|
+
event: "playAudio",
|
|
598
|
+
media: { contentType: "audio/x-mulaw", sampleRate: 8e3, payload: audioBase64 }
|
|
599
|
+
})
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
sendMark(ws, markName, streamSid) {
|
|
603
|
+
ws.send(JSON.stringify({ event: "checkpoint", streamId: streamSid, name: markName }));
|
|
604
|
+
}
|
|
605
|
+
sendClear(ws, streamSid) {
|
|
606
|
+
ws.send(JSON.stringify({ event: "clearAudio", streamId: streamSid }));
|
|
607
|
+
}
|
|
608
|
+
async transferCall(callId, toNumber) {
|
|
609
|
+
if (!/^\+[1-9]\d{6,14}$/.test(toNumber)) {
|
|
610
|
+
getLogger().warn(`PlivoBridge.transferCall rejected: invalid target ${JSON.stringify(toNumber)}`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (!this.config.plivoAuthId || !this.config.plivoAuthToken || !callId) return;
|
|
614
|
+
if (!this.config.webhookUrl) {
|
|
615
|
+
getLogger().warn("PlivoBridge.transferCall skipped: no webhookUrl for aleg_url");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const alegUrl = `https://${this.config.webhookUrl}/webhooks/plivo/transfer?to=${encodeURIComponent(toNumber)}`;
|
|
619
|
+
await fetch(`${this.apiBase}/Call/${encodeURIComponent(callId)}/`, {
|
|
620
|
+
method: "POST",
|
|
621
|
+
headers: { "Content-Type": "application/json", Authorization: this.authHeader },
|
|
622
|
+
body: JSON.stringify({ legs: "aleg", aleg_url: alegUrl, aleg_method: "GET" })
|
|
623
|
+
});
|
|
624
|
+
getLogger().info(`Call transferred to ${toNumber}`);
|
|
625
|
+
}
|
|
626
|
+
async sendDtmf(ws, _callId, digits, _delayMs) {
|
|
627
|
+
const filtered = Array.from(digits ?? "").filter((d) => PLIVO_DTMF_ALLOWED.has(d)).join("");
|
|
628
|
+
if (!filtered) {
|
|
629
|
+
getLogger().warn(`PlivoBridge.sendDtmf: no valid digits in ${JSON.stringify(digits)}`);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
ws.send(JSON.stringify({ event: "sendDTMF", dtmf: filtered }));
|
|
633
|
+
}
|
|
634
|
+
async startRecording(callId) {
|
|
635
|
+
if (!this.config.plivoAuthId || !this.config.plivoAuthToken || !callId) return;
|
|
636
|
+
try {
|
|
637
|
+
const resp = await fetch(`${this.apiBase}/Call/${encodeURIComponent(callId)}/Record/`, {
|
|
638
|
+
method: "POST",
|
|
639
|
+
headers: { Authorization: this.authHeader }
|
|
640
|
+
});
|
|
641
|
+
if (!resp.ok) {
|
|
642
|
+
getLogger().warn(`Plivo record start failed (${resp.status}): ${(await resp.text()).slice(0, 200)}`);
|
|
643
|
+
} else {
|
|
644
|
+
getLogger().info("Plivo recording started");
|
|
645
|
+
}
|
|
646
|
+
} catch (e) {
|
|
647
|
+
getLogger().warn(`Plivo record start error: ${String(e)}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async endCall(callId, _ws) {
|
|
651
|
+
if (!this.config.plivoAuthId || !this.config.plivoAuthToken || !callId) return;
|
|
652
|
+
try {
|
|
653
|
+
const resp = await fetch(`${this.apiBase}/Call/${encodeURIComponent(callId)}/`, {
|
|
654
|
+
method: "DELETE",
|
|
655
|
+
headers: { Authorization: this.authHeader }
|
|
656
|
+
});
|
|
657
|
+
if (!resp.ok && resp.status !== 404) {
|
|
658
|
+
getLogger().warn(`Plivo hangup returned ${resp.status}`);
|
|
659
|
+
}
|
|
660
|
+
} catch {
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
createStt(agent) {
|
|
664
|
+
return createSTT(agent);
|
|
665
|
+
}
|
|
666
|
+
async queryTelephonyCost(metricsAcc, callId) {
|
|
667
|
+
if (!this.config.plivoAuthId || !this.config.plivoAuthToken || !callId) return;
|
|
668
|
+
try {
|
|
669
|
+
const resp = await fetch(`${this.apiBase}/Call/${encodeURIComponent(callId)}/`, {
|
|
670
|
+
headers: { Authorization: this.authHeader },
|
|
671
|
+
signal: AbortSignal.timeout(5e3)
|
|
672
|
+
});
|
|
673
|
+
if (resp.ok) {
|
|
674
|
+
const data = await resp.json();
|
|
675
|
+
if (data.total_amount != null) {
|
|
676
|
+
metricsAcc.setActualTelephonyCost(Math.abs(parseFloat(data.total_amount)));
|
|
677
|
+
getLogger().info(`Plivo actual cost: $${data.total_amount}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
} catch (err) {
|
|
681
|
+
getLogger().debug(`queryTelephonyCost(plivo) failed: ${err?.message ?? err}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
373
686
|
// src/pricing.ts
|
|
374
687
|
init_esm_shims();
|
|
375
688
|
var PRICING_VERSION = "2026.3";
|
|
@@ -612,7 +925,7 @@ var DEFAULT_PRICING = {
|
|
|
612
925
|
// twilio default = US inbound local (the 99% case for voice agents receiving
|
|
613
926
|
// calls on a local number). For US toll-free inbound ($0.022/min) or US
|
|
614
927
|
// outbound local ($0.0140/min), override via Patter({ pricing: { twilio: {...} } }).
|
|
615
|
-
twilio: { unit: PricingUnit.MINUTE, price: 85e-4 },
|
|
928
|
+
twilio: { unit: PricingUnit.MINUTE, price: 85e-4, roundUp: true },
|
|
616
929
|
// Telnyx — direction-aware rates as of 2026-05-11.
|
|
617
930
|
// Sources:
|
|
618
931
|
// https://telnyx.com/pricing/elastic-sip
|
|
@@ -630,7 +943,17 @@ var DEFAULT_PRICING = {
|
|
|
630
943
|
// price: 0.0035 } } })`` to bill all inbound at the lower rate.
|
|
631
944
|
telnyx: { unit: PricingUnit.MINUTE, price: 7e-3 },
|
|
632
945
|
telnyx_inbound: { unit: PricingUnit.MINUTE, price: 35e-4 },
|
|
633
|
-
telnyx_outbound: { unit: PricingUnit.MINUTE, price: 7e-3 }
|
|
946
|
+
telnyx_outbound: { unit: PricingUnit.MINUTE, price: 7e-3 },
|
|
947
|
+
// Plivo — official US pay-as-you-go voice rates (per minute; Plivo rounds
|
|
948
|
+
// partial minutes up like Twilio). Source: https://www.plivo.com/voice/pricing/
|
|
949
|
+
// US local inbound: $0.0055/min
|
|
950
|
+
// US local outbound: $0.0115/min
|
|
951
|
+
// US toll-free inbound: $0.0180/min (override via new Patter({ pricing }))
|
|
952
|
+
// The flat ``plivo`` key defaults to inbound local; the billed amount is
|
|
953
|
+
// also reconciled post-call from the Plivo CDR (``total_amount``).
|
|
954
|
+
plivo: { unit: PricingUnit.MINUTE, price: 55e-4, roundUp: true },
|
|
955
|
+
plivo_inbound: { unit: PricingUnit.MINUTE, price: 55e-4, roundUp: true },
|
|
956
|
+
plivo_outbound: { unit: PricingUnit.MINUTE, price: 0.0115, roundUp: true }
|
|
634
957
|
};
|
|
635
958
|
function cloneProviderEntry(entry) {
|
|
636
959
|
const out = { ...entry };
|
|
@@ -716,11 +1039,25 @@ function calculateRealtimeCachedSavings(usage, pricing, model) {
|
|
|
716
1039
|
const rates = resolveProviderRates(pricing.openai_realtime, model);
|
|
717
1040
|
if (rates.unit !== "token") return 0;
|
|
718
1041
|
const input = usage.input_token_details ?? {};
|
|
719
|
-
const cached = input.cached_tokens_details ?? {};
|
|
720
1042
|
const cachedAudioRate = rates.cached_audio_input_per_token ?? rates.audio_input_per_token ?? 0;
|
|
721
1043
|
const cachedTextRate = rates.cached_text_input_per_token ?? rates.text_input_per_token ?? 0;
|
|
722
|
-
const
|
|
723
|
-
const
|
|
1044
|
+
const totalAudio = input.audio_tokens ?? 0;
|
|
1045
|
+
const totalText = input.text_tokens ?? 0;
|
|
1046
|
+
let cachedAudio;
|
|
1047
|
+
let cachedText;
|
|
1048
|
+
const details = input.cached_tokens_details;
|
|
1049
|
+
if (details && (details.audio_tokens !== void 0 || details.text_tokens !== void 0)) {
|
|
1050
|
+
cachedAudio = Math.min(details.audio_tokens ?? 0, totalAudio);
|
|
1051
|
+
cachedText = Math.min(details.text_tokens ?? 0, totalText);
|
|
1052
|
+
} else if (input.cached_tokens && input.cached_tokens > 0) {
|
|
1053
|
+
const totalIn = totalAudio + totalText;
|
|
1054
|
+
const ratio = totalIn > 0 ? input.cached_tokens / totalIn : 0;
|
|
1055
|
+
cachedAudio = Math.min(Math.round(totalAudio * ratio), totalAudio);
|
|
1056
|
+
cachedText = Math.min(Math.round(totalText * ratio), totalText);
|
|
1057
|
+
} else {
|
|
1058
|
+
cachedAudio = 0;
|
|
1059
|
+
cachedText = 0;
|
|
1060
|
+
}
|
|
724
1061
|
const fullAudio = cachedAudio * (rates.audio_input_per_token ?? 0);
|
|
725
1062
|
const fullText = cachedText * (rates.text_input_per_token ?? 0);
|
|
726
1063
|
const discountedAudio = cachedAudio * cachedAudioRate;
|
|
@@ -816,7 +1153,7 @@ function calculateLlmCost(provider2, model, inputTokens, outputTokens, cacheRead
|
|
|
816
1153
|
function calculateTelephonyCost(provider2, durationSeconds, pricing) {
|
|
817
1154
|
const config = pricing[provider2];
|
|
818
1155
|
if (!config || config.unit !== "minute") return 0;
|
|
819
|
-
const minutes =
|
|
1156
|
+
const minutes = config.roundUp ? Math.ceil(durationSeconds / 60) : durationSeconds / 60;
|
|
820
1157
|
return minutes * (config.price ?? 0);
|
|
821
1158
|
}
|
|
822
1159
|
|
|
@@ -967,14 +1304,49 @@ var MetricsStore = class extends EventEmitter {
|
|
|
967
1304
|
} else {
|
|
968
1305
|
for (let i = this.calls.length - 1; i >= 0; i--) {
|
|
969
1306
|
if (this.calls[i].call_id === callId) {
|
|
970
|
-
this.calls[i].status
|
|
971
|
-
Object.assign(this.calls[i], extra);
|
|
1307
|
+
this.calls[i] = { ...this.calls[i], status, ...extra };
|
|
972
1308
|
break;
|
|
973
1309
|
}
|
|
974
1310
|
}
|
|
975
1311
|
}
|
|
976
1312
|
this.publish("call_status", { call_id: callId, status, ...extra });
|
|
977
1313
|
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Record a single transcript line (user/assistant) as it becomes known.
|
|
1316
|
+
*
|
|
1317
|
+
* FIX-5 (issue #154): the live forward path for the dashboard transcript.
|
|
1318
|
+
* The Realtime stream handler calls this the moment each line is known — the
|
|
1319
|
+
* user line right after the hallucination filter accepts it, the assistant
|
|
1320
|
+
* line when its turn flushes — keyed by the monotonic ``turnIndex`` reserved
|
|
1321
|
+
* at turn-open (``reserveTurnIndex``). Each line is appended to the active
|
|
1322
|
+
* call's ``transcript`` array and broadcast over SSE as a ``transcript_line``
|
|
1323
|
+
* event so the dashboard can render lines as they arrive and re-sort by
|
|
1324
|
+
* ``(turnIndex, user<assistant)`` — making a late-arriving user line land
|
|
1325
|
+
* ABOVE its agent line. ``recordTurn`` de-dups against the lines pushed here
|
|
1326
|
+
* by ``(turnIndex, role)`` so the metrics path never double-pushes the same
|
|
1327
|
+
* text. Parity with Python ``record_transcript_line``.
|
|
1328
|
+
*/
|
|
1329
|
+
recordTranscriptLine(data) {
|
|
1330
|
+
const callId = data.call_id || "";
|
|
1331
|
+
const { role, text, turnIndex } = data;
|
|
1332
|
+
if (!callId || role !== "user" && role !== "assistant" || !text) return;
|
|
1333
|
+
const active = this.activeCalls.get(callId);
|
|
1334
|
+
if (active) {
|
|
1335
|
+
if (!active.transcript) active.transcript = [];
|
|
1336
|
+
active.transcript.push({
|
|
1337
|
+
role,
|
|
1338
|
+
text,
|
|
1339
|
+
timestamp: Date.now() / 1e3,
|
|
1340
|
+
turnIndex
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
this.publish("transcript_line", {
|
|
1344
|
+
call_id: callId,
|
|
1345
|
+
turnIndex,
|
|
1346
|
+
role,
|
|
1347
|
+
text
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
978
1350
|
/** Append a single conversation turn to an active call and broadcast it via SSE. */
|
|
979
1351
|
recordTurn(data) {
|
|
980
1352
|
const callId = data.call_id || "";
|
|
@@ -989,14 +1361,19 @@ var MetricsStore = class extends EventEmitter {
|
|
|
989
1361
|
const userText = typeof turnRecord.user_text === "string" ? turnRecord.user_text : "";
|
|
990
1362
|
const agentText = typeof turnRecord.agent_text === "string" ? turnRecord.agent_text : "";
|
|
991
1363
|
const ts = typeof turnRecord.timestamp === "number" ? turnRecord.timestamp : Date.now() / 1e3;
|
|
992
|
-
|
|
993
|
-
|
|
1364
|
+
const turnIndex = typeof turnRecord.turn_index === "number" ? turnRecord.turn_index : void 0;
|
|
1365
|
+
const alreadyLive = (role) => turnIndex !== void 0 && (active.transcript ?? []).some(
|
|
1366
|
+
(e) => e.turnIndex === turnIndex && e.role === role
|
|
1367
|
+
);
|
|
1368
|
+
if (userText.length > 0 && !alreadyLive("user")) {
|
|
1369
|
+
active.transcript.push({ role: "user", text: userText, timestamp: ts, turnIndex });
|
|
994
1370
|
}
|
|
995
|
-
if (agentText.length > 0 && agentText !== "[interrupted]") {
|
|
1371
|
+
if (agentText.length > 0 && agentText !== "[interrupted]" && !alreadyLive("assistant")) {
|
|
996
1372
|
active.transcript.push({
|
|
997
1373
|
role: "assistant",
|
|
998
1374
|
text: agentText,
|
|
999
|
-
timestamp: ts
|
|
1375
|
+
timestamp: ts,
|
|
1376
|
+
turnIndex
|
|
1000
1377
|
});
|
|
1001
1378
|
}
|
|
1002
1379
|
}
|
|
@@ -1069,7 +1446,7 @@ var MetricsStore = class extends EventEmitter {
|
|
|
1069
1446
|
getCall(callId) {
|
|
1070
1447
|
if (this.deletedCallIds.has(callId)) return null;
|
|
1071
1448
|
for (let i = this.calls.length - 1; i >= 0; i--) {
|
|
1072
|
-
if (this.calls[i].call_id === callId) return this.calls[i];
|
|
1449
|
+
if (this.calls[i].call_id === callId) return { ...this.calls[i] };
|
|
1073
1450
|
}
|
|
1074
1451
|
return null;
|
|
1075
1452
|
}
|
|
@@ -1111,7 +1488,9 @@ var MetricsStore = class extends EventEmitter {
|
|
|
1111
1488
|
}
|
|
1112
1489
|
if (accepted.length === 0) return [];
|
|
1113
1490
|
accepted.sort();
|
|
1114
|
-
this.persistDeletedIds()
|
|
1491
|
+
this.persistDeletedIds().catch(
|
|
1492
|
+
(err) => getLogger().debug(`MetricsStore.deleteCalls: persistDeletedIds failed: ${String(err)}`)
|
|
1493
|
+
);
|
|
1115
1494
|
this.publish("calls_deleted", { call_ids: accepted });
|
|
1116
1495
|
return accepted;
|
|
1117
1496
|
}
|
|
@@ -1123,19 +1502,19 @@ var MetricsStore = class extends EventEmitter {
|
|
|
1123
1502
|
getDeletedCallIds() {
|
|
1124
1503
|
return Array.from(this.deletedCallIds).sort();
|
|
1125
1504
|
}
|
|
1126
|
-
/** Atomically persist the deleted-ids set to disk. Best-effort. */
|
|
1127
|
-
persistDeletedIds() {
|
|
1505
|
+
/** Atomically persist the deleted-ids set to disk. Best-effort async. */
|
|
1506
|
+
async persistDeletedIds() {
|
|
1128
1507
|
if (this.deletedIdsPath === null) return;
|
|
1129
1508
|
try {
|
|
1130
1509
|
const dir = path2.dirname(this.deletedIdsPath);
|
|
1131
|
-
fs2.
|
|
1510
|
+
await fs2.promises.mkdir(dir, { recursive: true });
|
|
1132
1511
|
const tmp = this.deletedIdsPath + ".tmp";
|
|
1133
1512
|
const payload = {
|
|
1134
1513
|
version: 1,
|
|
1135
1514
|
deleted_call_ids: Array.from(this.deletedCallIds).sort()
|
|
1136
1515
|
};
|
|
1137
|
-
fs2.
|
|
1138
|
-
fs2.
|
|
1516
|
+
await fs2.promises.writeFile(tmp, JSON.stringify(payload, null, 2), "utf8");
|
|
1517
|
+
await fs2.promises.rename(tmp, this.deletedIdsPath);
|
|
1139
1518
|
} catch (err) {
|
|
1140
1519
|
getLogger().debug(
|
|
1141
1520
|
`MetricsStore.persistDeletedIds: ${String(err)}`
|
|
@@ -1144,7 +1523,8 @@ var MetricsStore = class extends EventEmitter {
|
|
|
1144
1523
|
}
|
|
1145
1524
|
/** Look up an active call by id (returns undefined if not active or unknown). */
|
|
1146
1525
|
getActive(callId) {
|
|
1147
|
-
|
|
1526
|
+
const rec = this.activeCalls.get(callId);
|
|
1527
|
+
return rec !== void 0 ? { ...rec } : void 0;
|
|
1148
1528
|
}
|
|
1149
1529
|
/** Return all currently active (not yet ended) calls. */
|
|
1150
1530
|
getActiveCalls() {
|
|
@@ -1389,8 +1769,8 @@ function loadTranscriptJsonl(filePath) {
|
|
|
1389
1769
|
} catch {
|
|
1390
1770
|
continue;
|
|
1391
1771
|
}
|
|
1392
|
-
const tsIso = typeof row.ts === "string" ? Date.parse(row.ts) : NaN;
|
|
1393
|
-
const tsNumeric = typeof row.timestamp === "number" ? row.timestamp
|
|
1772
|
+
const tsIso = typeof row.ts === "string" ? Date.parse(row.ts) / 1e3 : NaN;
|
|
1773
|
+
const tsNumeric = typeof row.timestamp === "number" ? row.timestamp : NaN;
|
|
1394
1774
|
const timestamp = Number.isFinite(tsIso) ? tsIso : Number.isFinite(tsNumeric) ? tsNumeric : 0;
|
|
1395
1775
|
const userText = typeof row.user_text === "string" ? row.user_text : "";
|
|
1396
1776
|
const agentText = typeof row.agent_text === "string" ? row.agent_text : "";
|
|
@@ -1422,15 +1802,15 @@ init_esm_shims();
|
|
|
1422
1802
|
|
|
1423
1803
|
// src/dashboard/auth.ts
|
|
1424
1804
|
init_esm_shims();
|
|
1425
|
-
import
|
|
1805
|
+
import crypto2 from "crypto";
|
|
1426
1806
|
function timingSafeCompare(a, b) {
|
|
1427
1807
|
const aBuf = Buffer.from(a);
|
|
1428
1808
|
const bBuf = Buffer.from(b);
|
|
1429
1809
|
if (aBuf.length !== bBuf.length) {
|
|
1430
|
-
|
|
1810
|
+
crypto2.timingSafeEqual(aBuf, aBuf);
|
|
1431
1811
|
return false;
|
|
1432
1812
|
}
|
|
1433
|
-
return
|
|
1813
|
+
return crypto2.timingSafeEqual(aBuf, bBuf);
|
|
1434
1814
|
}
|
|
1435
1815
|
function makeAuthMiddleware(token = "") {
|
|
1436
1816
|
return (req, res, next) => {
|
|
@@ -1547,8 +1927,8 @@ function mountDashboard(app, store, token = "") {
|
|
|
1547
1927
|
res.type("text/html").send(DASHBOARD_HTML);
|
|
1548
1928
|
});
|
|
1549
1929
|
app.get("/api/dashboard/calls", auth, (req, res) => {
|
|
1550
|
-
const limit = Math.min(parseInt(req.query.limit || "50", 10) || 50, 1e3);
|
|
1551
|
-
const offset = parseInt(req.query.offset || "0", 10) || 0;
|
|
1930
|
+
const limit = Math.min(Math.max(0, parseInt(req.query.limit || "50", 10) || 50), 1e3);
|
|
1931
|
+
const offset = Math.max(0, parseInt(req.query.offset || "0", 10) || 0);
|
|
1552
1932
|
res.json(store.getCalls(limit, offset));
|
|
1553
1933
|
});
|
|
1554
1934
|
app.get("/api/dashboard/calls/:callId", auth, (req, res) => {
|
|
@@ -1638,8 +2018,8 @@ data: ${data}
|
|
|
1638
2018
|
function mountApi(app, store, token = "") {
|
|
1639
2019
|
const auth = makeAuthMiddleware(token);
|
|
1640
2020
|
app.get("/api/v1/calls", auth, (req, res) => {
|
|
1641
|
-
const limit = Math.min(parseInt(req.query.limit || "50", 10) || 50, 1e3);
|
|
1642
|
-
const offset = parseInt(req.query.offset || "0", 10) || 0;
|
|
2021
|
+
const limit = Math.min(Math.max(0, parseInt(req.query.limit || "50", 10) || 50), 1e3);
|
|
2022
|
+
const offset = Math.max(0, parseInt(req.query.offset || "0", 10) || 0);
|
|
1643
2023
|
const calls = store.getCalls(limit, offset);
|
|
1644
2024
|
res.json({
|
|
1645
2025
|
data: calls,
|
|
@@ -1719,7 +2099,7 @@ function mountApi(app, store, token = "") {
|
|
|
1719
2099
|
|
|
1720
2100
|
// src/remote-message.ts
|
|
1721
2101
|
init_esm_shims();
|
|
1722
|
-
import
|
|
2102
|
+
import crypto3 from "crypto";
|
|
1723
2103
|
var MAX_RESPONSE_BYTES = 64 * 1024;
|
|
1724
2104
|
function validateWebSocketUrl(url) {
|
|
1725
2105
|
let translated = url;
|
|
@@ -1747,7 +2127,7 @@ var RemoteMessageHandler = class {
|
|
|
1747
2127
|
if (!this.webhookSecret) {
|
|
1748
2128
|
throw new Error("Cannot sign without a webhookSecret");
|
|
1749
2129
|
}
|
|
1750
|
-
return
|
|
2130
|
+
return crypto3.createHmac("sha256", this.webhookSecret).update(body).digest("hex");
|
|
1751
2131
|
}
|
|
1752
2132
|
/**
|
|
1753
2133
|
* Release resources held by this handler.
|
|
@@ -1896,14 +2276,31 @@ var RemoteMessageHandler = class {
|
|
|
1896
2276
|
while (chunks.length > 0) {
|
|
1897
2277
|
yield chunks.shift();
|
|
1898
2278
|
}
|
|
2279
|
+
const READ_TIMEOUT_MS = 3e4;
|
|
1899
2280
|
while (!done && !error) {
|
|
1900
|
-
const
|
|
2281
|
+
const messagePromise = new Promise((resolve2) => {
|
|
1901
2282
|
if (chunks.length > 0) {
|
|
1902
2283
|
resolve2(chunks.shift());
|
|
1903
2284
|
} else {
|
|
1904
2285
|
resolveNext = resolve2;
|
|
1905
2286
|
}
|
|
1906
2287
|
});
|
|
2288
|
+
let timeoutHandle;
|
|
2289
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
2290
|
+
timeoutHandle = setTimeout(
|
|
2291
|
+
() => reject(new Error("WebSocket read timeout: no frame received within 30 s")),
|
|
2292
|
+
READ_TIMEOUT_MS
|
|
2293
|
+
);
|
|
2294
|
+
});
|
|
2295
|
+
let text;
|
|
2296
|
+
try {
|
|
2297
|
+
text = await Promise.race([messagePromise, timeoutPromise]);
|
|
2298
|
+
} catch (timeoutErr) {
|
|
2299
|
+
resolveNext = null;
|
|
2300
|
+
throw timeoutErr;
|
|
2301
|
+
} finally {
|
|
2302
|
+
clearTimeout(timeoutHandle);
|
|
2303
|
+
}
|
|
1907
2304
|
if (text === null) break;
|
|
1908
2305
|
yield text;
|
|
1909
2306
|
}
|
|
@@ -1963,6 +2360,12 @@ var PatterError = class extends Error {
|
|
|
1963
2360
|
this.code = options?.code ?? ErrorCode.INTERNAL;
|
|
1964
2361
|
}
|
|
1965
2362
|
};
|
|
2363
|
+
var PatterConfigError = class extends PatterError {
|
|
2364
|
+
constructor(message, options) {
|
|
2365
|
+
super(message, { code: options?.code ?? ErrorCode.CONFIG });
|
|
2366
|
+
this.name = "PatterConfigError";
|
|
2367
|
+
}
|
|
2368
|
+
};
|
|
1966
2369
|
var PatterConnectionError = class extends PatterError {
|
|
1967
2370
|
constructor(message, options) {
|
|
1968
2371
|
super(message, { code: options?.code ?? ErrorCode.CONNECTION });
|
|
@@ -2207,18 +2610,6 @@ var DeepgramSTT = class _DeepgramSTT {
|
|
|
2207
2610
|
} catch {
|
|
2208
2611
|
return;
|
|
2209
2612
|
}
|
|
2210
|
-
const dataType = String(data.type ?? "unknown");
|
|
2211
|
-
if (dataType === "Results") {
|
|
2212
|
-
const transcript2 = (data.channel?.alternatives?.[0]?.transcript ?? "").trim();
|
|
2213
|
-
const isFinal = Boolean(data.is_final);
|
|
2214
|
-
const speechFinal2 = Boolean(data.speech_final);
|
|
2215
|
-
const fromFinalize = Boolean(data.from_finalize);
|
|
2216
|
-
getLogger().info(
|
|
2217
|
-
`[DIAG] DG Results text=${JSON.stringify(transcript2.slice(0, 60))} isFinal=${isFinal} speechFinal=${speechFinal2} fromFinalize=${fromFinalize}`
|
|
2218
|
-
);
|
|
2219
|
-
} else if (dataType !== "Metadata") {
|
|
2220
|
-
getLogger().info(`[DIAG] DG event type=${dataType}`);
|
|
2221
|
-
}
|
|
2222
2613
|
if (data.type === "Metadata" && data.request_id) {
|
|
2223
2614
|
this.requestId = data.request_id;
|
|
2224
2615
|
return;
|
|
@@ -2308,7 +2699,7 @@ var DeepgramSTT = class _DeepgramSTT {
|
|
|
2308
2699
|
if (!this.ws || this.ws.readyState !== WebSocket2.OPEN) {
|
|
2309
2700
|
this.audioDroppedCount++;
|
|
2310
2701
|
if (this.audioDroppedCount === 1 || this.audioDroppedCount % 50 === 0) {
|
|
2311
|
-
getLogger().
|
|
2702
|
+
getLogger().debug(
|
|
2312
2703
|
`[DIAG] DeepgramSTT.sendAudio dropped (ws state=${this.ws?.readyState ?? "null"}) \u2014 total dropped=${this.audioDroppedCount}`
|
|
2313
2704
|
);
|
|
2314
2705
|
}
|
|
@@ -2317,7 +2708,7 @@ var DeepgramSTT = class _DeepgramSTT {
|
|
|
2317
2708
|
if (audio.length === 0) return;
|
|
2318
2709
|
this.audioSentCount++;
|
|
2319
2710
|
if (this.audioSentCount === 1 || this.audioSentCount % 100 === 0) {
|
|
2320
|
-
getLogger().
|
|
2711
|
+
getLogger().debug(
|
|
2321
2712
|
`[DIAG] DeepgramSTT.sendAudio: total chunks sent=${this.audioSentCount} (last=${audio.length} bytes)`
|
|
2322
2713
|
);
|
|
2323
2714
|
}
|
|
@@ -2355,16 +2746,16 @@ var DeepgramSTT = class _DeepgramSTT {
|
|
|
2355
2746
|
finalize() {
|
|
2356
2747
|
const ws = this.ws;
|
|
2357
2748
|
if (!ws || ws.readyState !== WebSocket2.OPEN) {
|
|
2358
|
-
getLogger().
|
|
2749
|
+
getLogger().debug(
|
|
2359
2750
|
`[DIAG] DeepgramSTT.finalize SKIPPED (ws state=${ws?.readyState ?? "null"})`
|
|
2360
2751
|
);
|
|
2361
2752
|
return;
|
|
2362
2753
|
}
|
|
2363
2754
|
try {
|
|
2364
2755
|
ws.send(JSON.stringify({ type: "Finalize" }));
|
|
2365
|
-
getLogger().
|
|
2756
|
+
getLogger().debug("[DIAG] DeepgramSTT.finalize sent {type:Finalize}");
|
|
2366
2757
|
} catch (err) {
|
|
2367
|
-
getLogger().
|
|
2758
|
+
getLogger().debug(`[DIAG] DeepgramSTT.finalize send failed: ${String(err)}`);
|
|
2368
2759
|
}
|
|
2369
2760
|
}
|
|
2370
2761
|
/** Send Finalize, briefly drain trailing transcripts, then close the socket. */
|
|
@@ -2437,6 +2828,7 @@ var CallMetricsAccumulator = class {
|
|
|
2437
2828
|
_pricing;
|
|
2438
2829
|
_callStart;
|
|
2439
2830
|
_turns = [];
|
|
2831
|
+
// mutable internal array; immutable when exposed via TurnMetrics[] → readonly TurnMetrics[]
|
|
2440
2832
|
// Per-turn timing state
|
|
2441
2833
|
_turnStart = null;
|
|
2442
2834
|
_sttComplete = null;
|
|
@@ -2523,6 +2915,16 @@ var CallMetricsAccumulator = class {
|
|
|
2523
2915
|
* (the common cause of missing endpoint signals).
|
|
2524
2916
|
*/
|
|
2525
2917
|
_endpointSignalMissingCount = 0;
|
|
2918
|
+
/**
|
|
2919
|
+
* Monotonic per-call turn counter. Reserved at turn OPEN
|
|
2920
|
+
* (``onAdapterSpeechStopped`` / ``speech_stopped``) via
|
|
2921
|
+
* ``reserveTurnIndex()`` and threaded through the buffering pipeline into
|
|
2922
|
+
* ``recordTurnComplete`` / ``recordTurnInterrupted`` as ``preReservedIndex``.
|
|
2923
|
+
* This makes ``turn_index`` stable under drops / interrupts (previously it
|
|
2924
|
+
* was assigned at completion as ``this._turns.length``, which shifted when a
|
|
2925
|
+
* turn was dropped). Parity with Python ``_next_turn_index``.
|
|
2926
|
+
*/
|
|
2927
|
+
_nextTurnIndex = 0;
|
|
2526
2928
|
constructor(opts) {
|
|
2527
2929
|
this.callId = opts.callId;
|
|
2528
2930
|
this.providerMode = opts.providerMode;
|
|
@@ -2571,12 +2973,27 @@ var CallMetricsAccumulator = class {
|
|
|
2571
2973
|
this._turnUserText = "";
|
|
2572
2974
|
this._turnSttAudioSeconds = 0;
|
|
2573
2975
|
this._turnAlreadyClosed = false;
|
|
2976
|
+
this._initialTtfbEmitted = false;
|
|
2574
2977
|
this._vadStoppedAt = null;
|
|
2575
2978
|
this._sttFinalAt = null;
|
|
2576
2979
|
this._turnCommittedAt = null;
|
|
2577
2980
|
this._onUserTurnCompletedDelayMs = null;
|
|
2578
2981
|
this._eventBus?.emit("turn_started", { callId: this.callId });
|
|
2579
2982
|
}
|
|
2983
|
+
/**
|
|
2984
|
+
* Reserve and return the next monotonic turn index.
|
|
2985
|
+
*
|
|
2986
|
+
* Called once per turn at the moment the turn OPENS (Realtime:
|
|
2987
|
+
* ``onAdapterSpeechStopped``). The returned index is threaded through the
|
|
2988
|
+
* buffering pipeline and handed back to ``recordTurnComplete`` /
|
|
2989
|
+
* ``recordTurnInterrupted`` as ``preReservedIndex`` so the emitted
|
|
2990
|
+
* ``turn_index`` matches the live per-line transcript ordering even when a
|
|
2991
|
+
* turn is dropped or interrupted between open and close. Parity with Python
|
|
2992
|
+
* ``reserve_turn_index``.
|
|
2993
|
+
*/
|
|
2994
|
+
reserveTurnIndex() {
|
|
2995
|
+
return this._nextTurnIndex++;
|
|
2996
|
+
}
|
|
2580
2997
|
/**
|
|
2581
2998
|
* Start a new turn only if no turn is currently open.
|
|
2582
2999
|
* Use this at inbound-audio ingestion points so the turn timer begins
|
|
@@ -2614,6 +3031,7 @@ var CallMetricsAccumulator = class {
|
|
|
2614
3031
|
anchorUserSpeechStart() {
|
|
2615
3032
|
if (this._turnCommittedMono !== null) return;
|
|
2616
3033
|
this._turnStart = hrTimeMs();
|
|
3034
|
+
this._turnAlreadyClosed = false;
|
|
2617
3035
|
this._endpointSignalAt = null;
|
|
2618
3036
|
this._vadStoppedAt = null;
|
|
2619
3037
|
this._sttFinalAt = null;
|
|
@@ -2737,11 +3155,14 @@ var CallMetricsAccumulator = class {
|
|
|
2737
3155
|
* ``user_text=''``. The caller treats ``null`` as "nothing to emit";
|
|
2738
3156
|
* ``emitTurnMetrics`` is already null-safe.
|
|
2739
3157
|
*/
|
|
2740
|
-
recordTurnComplete(agentText) {
|
|
3158
|
+
recordTurnComplete(agentText, preReservedIndex) {
|
|
2741
3159
|
if (this._turnAlreadyClosed) return null;
|
|
2742
3160
|
const latency = this._computeTurnLatency();
|
|
2743
3161
|
const turn = {
|
|
2744
|
-
|
|
3162
|
+
// Use the pre-reserved index (stable across drops/interrupts) when the
|
|
3163
|
+
// caller threaded one through; otherwise fall back to the append
|
|
3164
|
+
// position for back-compat with callers that never reserved.
|
|
3165
|
+
turn_index: preReservedIndex ?? this._turns.length,
|
|
2745
3166
|
user_text: this._turnUserText,
|
|
2746
3167
|
agent_text: agentText,
|
|
2747
3168
|
latency,
|
|
@@ -2750,10 +3171,10 @@ var CallMetricsAccumulator = class {
|
|
|
2750
3171
|
timestamp: Date.now() / 1e3
|
|
2751
3172
|
};
|
|
2752
3173
|
this._turns.push(turn);
|
|
2753
|
-
this._resetTurnState();
|
|
2754
|
-
this._turnAlreadyClosed = true;
|
|
2755
3174
|
this._eventBus?.emit("turn_ended", { callId: this.callId, turn });
|
|
2756
3175
|
this._eventBus?.emit("metrics_collected", { callId: this.callId, turn });
|
|
3176
|
+
this._resetTurnState();
|
|
3177
|
+
this._turnAlreadyClosed = true;
|
|
2757
3178
|
return turn;
|
|
2758
3179
|
}
|
|
2759
3180
|
/**
|
|
@@ -2765,12 +3186,12 @@ var CallMetricsAccumulator = class {
|
|
|
2765
3186
|
* a future refactor that reorders the bargein + LLM-unwind paths)
|
|
2766
3187
|
* from overwriting a turn that the complete path already emitted.
|
|
2767
3188
|
*/
|
|
2768
|
-
recordTurnInterrupted() {
|
|
3189
|
+
recordTurnInterrupted(preReservedIndex) {
|
|
2769
3190
|
if (this._turnStart === null) return null;
|
|
2770
3191
|
if (this._turnAlreadyClosed) return null;
|
|
2771
3192
|
const latency = this._computeTurnLatency();
|
|
2772
3193
|
const turn = {
|
|
2773
|
-
turn_index: this._turns.length,
|
|
3194
|
+
turn_index: preReservedIndex ?? this._turns.length,
|
|
2774
3195
|
user_text: this._turnUserText,
|
|
2775
3196
|
agent_text: "[interrupted]",
|
|
2776
3197
|
latency,
|
|
@@ -2822,8 +3243,10 @@ var CallMetricsAccumulator = class {
|
|
|
2822
3243
|
}
|
|
2823
3244
|
/**
|
|
2824
3245
|
* Record the delta (ms) between turn-committed and when on_user_turn_completed
|
|
2825
|
-
* pipeline hook finished.
|
|
2826
|
-
*
|
|
3246
|
+
* pipeline hook finished. Does NOT re-emit: like Python's
|
|
3247
|
+
* ``record_on_user_turn_completed_delay``, this only stores the value; the
|
|
3248
|
+
* single EOU emission happens on ``recordTurnCommitted`` (3-timestamp guard,
|
|
3249
|
+
* delay defaults to 0 if not yet recorded).
|
|
2827
3250
|
*/
|
|
2828
3251
|
recordOnUserTurnCompletedDelay(delayMs) {
|
|
2829
3252
|
this._onUserTurnCompletedDelayMs = delayMs;
|
|
@@ -2836,7 +3259,7 @@ var CallMetricsAccumulator = class {
|
|
|
2836
3259
|
* ``transcriptionDelay`` = turnCommitted − vadStopped (ms)
|
|
2837
3260
|
* ``onUserTurnCompletedDelay`` = caller-supplied delta (ms) or 0
|
|
2838
3261
|
*/
|
|
2839
|
-
/** Emit `EOUMetrics` once VAD-stop, STT-final,
|
|
3262
|
+
/** Emit `EOUMetrics` once VAD-stop, STT-final, turn-committed, and on_user_turn_completed delay are all known. */
|
|
2840
3263
|
emitEouMetrics() {
|
|
2841
3264
|
if (this._vadStoppedAt === null || this._sttFinalAt === null || this._turnCommittedAt === null) {
|
|
2842
3265
|
return;
|
|
@@ -3252,10 +3675,16 @@ var MCPManager = class {
|
|
|
3252
3675
|
}
|
|
3253
3676
|
const aggregatedTools = [];
|
|
3254
3677
|
for (const cfg of this.configs) {
|
|
3678
|
+
try {
|
|
3679
|
+
validateWebhookUrl(cfg.url);
|
|
3680
|
+
} catch (e) {
|
|
3681
|
+
getLogger().error(`MCP server '${cfg.name}' (${cfg.url}) rejected by SSRF guard: ${String(e)}`);
|
|
3682
|
+
continue;
|
|
3683
|
+
}
|
|
3255
3684
|
const transport = new transportModule.StreamableHTTPClientTransport(new URL(cfg.url), {
|
|
3256
3685
|
requestInit: { headers: cfg.headers }
|
|
3257
3686
|
});
|
|
3258
|
-
const client = new mcpModule.Client({ name: "patter", version:
|
|
3687
|
+
const client = new mcpModule.Client({ name: "patter", version: VERSION });
|
|
3259
3688
|
try {
|
|
3260
3689
|
await client.connect(transport);
|
|
3261
3690
|
} catch (e) {
|
|
@@ -3327,6 +3756,268 @@ var MCPManager = class {
|
|
|
3327
3756
|
}
|
|
3328
3757
|
};
|
|
3329
3758
|
|
|
3759
|
+
// src/consult.ts
|
|
3760
|
+
init_esm_shims();
|
|
3761
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
3762
|
+
var DEFAULT_TOOL_NAME = "consult_agent";
|
|
3763
|
+
var DEFAULT_DESCRIPTION = "Consult your back-office agent for deeper reasoning, fresh information, or actions beyond this call. Use when the caller asks something you cannot answer directly.";
|
|
3764
|
+
var MAX_RESPONSE_CHARS = 1e6;
|
|
3765
|
+
var REPLY_KEYS = ["reply", "response", "text", "result", "answer", "message"];
|
|
3766
|
+
var GRACEFUL_FALLBACK = "I wasn't able to reach the system to get that answer right now.";
|
|
3767
|
+
var OPENCLAW_DEFAULT_BASE_URL = "http://127.0.0.1:18789/v1";
|
|
3768
|
+
var OPENCLAW_API_KEY_ENV = "OPENCLAW_API_KEY";
|
|
3769
|
+
var OPENCLAW_SESSION_HEADER = "x-openclaw-session-key";
|
|
3770
|
+
var OPENCLAW_DESCRIPTION = "Consult your OpenClaw agent for anything account-specific \u2014 appointments, customer records, schedules, or actions in the back-office system. NEVER state an appointment time, customer detail, or schedule fact from your own memory; ALWAYS call this tool for those and read back what it returns.";
|
|
3771
|
+
var OPENCLAW_REASSURANCE = "Let me check on that for you, one moment.";
|
|
3772
|
+
var OPENCLAW_AGENT_RE = /^[A-Za-z0-9._:/-]+$/;
|
|
3773
|
+
var PARAMETERS = {
|
|
3774
|
+
type: "object",
|
|
3775
|
+
properties: {
|
|
3776
|
+
request: {
|
|
3777
|
+
type: "string",
|
|
3778
|
+
description: "The question or task to send to your back-office agent for deeper reasoning, fresh information, or an action beyond this call. State it self-containedly \u2014 the dialog history is not forwarded with the consult."
|
|
3779
|
+
}
|
|
3780
|
+
},
|
|
3781
|
+
required: ["request"]
|
|
3782
|
+
};
|
|
3783
|
+
function isLoopbackOrPrivateHost(baseUrl) {
|
|
3784
|
+
let host;
|
|
3785
|
+
try {
|
|
3786
|
+
host = new URL(baseUrl).hostname.toLowerCase();
|
|
3787
|
+
} catch {
|
|
3788
|
+
return false;
|
|
3789
|
+
}
|
|
3790
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
3791
|
+
if (host === "localhost" || host === "0.0.0.0" || host === "::1") return true;
|
|
3792
|
+
if (host.endsWith(".local")) return true;
|
|
3793
|
+
if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) return true;
|
|
3794
|
+
if (/^169\.254\./.test(host)) return true;
|
|
3795
|
+
const m = host.match(/^172\.(\d+)\./);
|
|
3796
|
+
if (m) {
|
|
3797
|
+
const octet = Number(m[1]);
|
|
3798
|
+
if (octet >= 16 && octet <= 31) return true;
|
|
3799
|
+
}
|
|
3800
|
+
if (host.includes(":") && (/^f[cd][0-9a-f]{2}:/.test(host) || /^fe[89ab][0-9a-f]:/.test(host))) {
|
|
3801
|
+
return true;
|
|
3802
|
+
}
|
|
3803
|
+
return false;
|
|
3804
|
+
}
|
|
3805
|
+
function openclawConsult(agent, opts = {}) {
|
|
3806
|
+
if (!agent || !OPENCLAW_AGENT_RE.test(agent)) {
|
|
3807
|
+
throw new Error(
|
|
3808
|
+
"OpenClaw agent must be a non-empty id of letters, digits, and ._:/- only"
|
|
3809
|
+
);
|
|
3810
|
+
}
|
|
3811
|
+
const baseUrl = opts.baseUrl ?? OPENCLAW_DEFAULT_BASE_URL;
|
|
3812
|
+
const model = agent.includes("/") || agent.includes(":") ? agent : `openclaw/${agent}`;
|
|
3813
|
+
return {
|
|
3814
|
+
openaiCompatible: {
|
|
3815
|
+
baseUrl,
|
|
3816
|
+
model,
|
|
3817
|
+
apiKey: opts.apiKey,
|
|
3818
|
+
apiKeyEnv: OPENCLAW_API_KEY_ENV,
|
|
3819
|
+
sessionHeader: OPENCLAW_SESSION_HEADER
|
|
3820
|
+
},
|
|
3821
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3822
|
+
toolName: opts.toolName ?? DEFAULT_TOOL_NAME,
|
|
3823
|
+
description: opts.description ?? OPENCLAW_DESCRIPTION,
|
|
3824
|
+
reassurance: opts.reassurance ?? OPENCLAW_REASSURANCE,
|
|
3825
|
+
headers: opts.headers,
|
|
3826
|
+
allowLoopback: opts.allowLoopback ?? isLoopbackOrPrivateHost(baseUrl)
|
|
3827
|
+
};
|
|
3828
|
+
}
|
|
3829
|
+
function buildConsultTool(config) {
|
|
3830
|
+
const hasUrl = config.url != null;
|
|
3831
|
+
const hasOpenAI = config.openaiCompatible != null;
|
|
3832
|
+
if (hasUrl === hasOpenAI) {
|
|
3833
|
+
throw new Error("ConsultConfig requires exactly one of url or openaiCompatible");
|
|
3834
|
+
}
|
|
3835
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
3836
|
+
const baseHeaders = {
|
|
3837
|
+
...config.headers ?? {},
|
|
3838
|
+
"Content-Type": "application/json"
|
|
3839
|
+
};
|
|
3840
|
+
const handler = hasOpenAI ? buildOpenAIHandler(config.openaiCompatible, baseHeaders, timeoutMs, config.allowLoopback ?? false) : buildWebhookHandler(config.url, baseHeaders, timeoutMs, config.allowLoopback ?? false);
|
|
3841
|
+
const tool = {
|
|
3842
|
+
name: config.toolName ?? DEFAULT_TOOL_NAME,
|
|
3843
|
+
description: config.description ?? DEFAULT_DESCRIPTION,
|
|
3844
|
+
parameters: PARAMETERS,
|
|
3845
|
+
handler
|
|
3846
|
+
};
|
|
3847
|
+
return config.reassurance != null ? { ...tool, reassurance: config.reassurance } : tool;
|
|
3848
|
+
}
|
|
3849
|
+
function buildWebhookHandler(url, headers, timeoutMs, allowLoopback) {
|
|
3850
|
+
validateWebhookUrl(url, allowLoopback);
|
|
3851
|
+
return async (args, context) => {
|
|
3852
|
+
const requestText = typeof args?.request === "string" ? args.request : "";
|
|
3853
|
+
const payload = {
|
|
3854
|
+
request: requestText,
|
|
3855
|
+
call_id: context?.call_id ?? "",
|
|
3856
|
+
caller: context?.caller ?? "",
|
|
3857
|
+
callee: context?.callee ?? ""
|
|
3858
|
+
};
|
|
3859
|
+
let body;
|
|
3860
|
+
try {
|
|
3861
|
+
const resp = await fetch(url, {
|
|
3862
|
+
method: "POST",
|
|
3863
|
+
headers,
|
|
3864
|
+
body: JSON.stringify(payload),
|
|
3865
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3866
|
+
});
|
|
3867
|
+
if (!resp.ok) {
|
|
3868
|
+
getLogger().warn(`consult tool: orchestrator returned HTTP ${resp.status}`);
|
|
3869
|
+
return GRACEFUL_FALLBACK;
|
|
3870
|
+
}
|
|
3871
|
+
body = (await resp.text()).slice(0, MAX_RESPONSE_CHARS);
|
|
3872
|
+
} catch (e) {
|
|
3873
|
+
getLogger().warn(
|
|
3874
|
+
`consult tool: orchestrator call failed: ${e instanceof Error ? e.name : "error"}`
|
|
3875
|
+
);
|
|
3876
|
+
return GRACEFUL_FALLBACK;
|
|
3877
|
+
}
|
|
3878
|
+
try {
|
|
3879
|
+
const data = JSON.parse(body);
|
|
3880
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
3881
|
+
const obj = data;
|
|
3882
|
+
for (const key of REPLY_KEYS) {
|
|
3883
|
+
if (typeof obj[key] === "string") return obj[key];
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
return JSON.stringify(data);
|
|
3887
|
+
} catch {
|
|
3888
|
+
return body;
|
|
3889
|
+
}
|
|
3890
|
+
};
|
|
3891
|
+
}
|
|
3892
|
+
function buildOpenAIHandler(oc, baseHeaders, timeoutMs, allowLoopback) {
|
|
3893
|
+
const endpoint = oc.baseUrl.replace(/\/+$/, "") + "/chat/completions";
|
|
3894
|
+
validateWebhookUrl(endpoint, allowLoopback);
|
|
3895
|
+
const apiKey = oc.apiKey ?? (oc.apiKeyEnv ? process.env[oc.apiKeyEnv] : void 0);
|
|
3896
|
+
const headers = { ...baseHeaders };
|
|
3897
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
3898
|
+
const sessionHeader = oc.sessionHeader;
|
|
3899
|
+
const model = oc.model;
|
|
3900
|
+
return async (args, context) => {
|
|
3901
|
+
const requestText = typeof args?.request === "string" ? args.request : "";
|
|
3902
|
+
const callId = context?.call_id ?? "";
|
|
3903
|
+
const caller = context?.caller ?? "";
|
|
3904
|
+
const callee = context?.callee ?? "";
|
|
3905
|
+
const contextLines = ["You are answering an inbound phone call relayed by a voice agent."];
|
|
3906
|
+
if (caller) contextLines.push(`Caller: ${caller}`);
|
|
3907
|
+
if (callee) contextLines.push(`Line dialed: ${callee}`);
|
|
3908
|
+
contextLines.push(
|
|
3909
|
+
"Reply concisely in a spoken, conversational style \u2014 it is read aloud to the caller."
|
|
3910
|
+
);
|
|
3911
|
+
const reqHeaders = { ...headers };
|
|
3912
|
+
if (sessionHeader && callId) reqHeaders[sessionHeader] = callId;
|
|
3913
|
+
const payload = {
|
|
3914
|
+
model,
|
|
3915
|
+
messages: [
|
|
3916
|
+
{ role: "system", content: contextLines.join("\n") },
|
|
3917
|
+
{ role: "user", content: requestText }
|
|
3918
|
+
],
|
|
3919
|
+
stream: false
|
|
3920
|
+
};
|
|
3921
|
+
if (callId) payload.user = callId;
|
|
3922
|
+
try {
|
|
3923
|
+
const resp = await fetch(endpoint, {
|
|
3924
|
+
method: "POST",
|
|
3925
|
+
headers: reqHeaders,
|
|
3926
|
+
body: JSON.stringify(payload),
|
|
3927
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3928
|
+
});
|
|
3929
|
+
if (resp.status === 404) {
|
|
3930
|
+
getLogger().warn(
|
|
3931
|
+
"consult tool: OpenAI-compatible endpoint returned 404 \u2014 is it enabled? (OpenClaw: set gateway.http.endpoints.chatCompletions.enabled = true)"
|
|
3932
|
+
);
|
|
3933
|
+
return GRACEFUL_FALLBACK;
|
|
3934
|
+
}
|
|
3935
|
+
if (!resp.ok) {
|
|
3936
|
+
getLogger().warn(`consult tool: openai-compatible returned HTTP ${resp.status}`);
|
|
3937
|
+
return GRACEFUL_FALLBACK;
|
|
3938
|
+
}
|
|
3939
|
+
const data = await resp.json();
|
|
3940
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
3941
|
+
if (typeof content === "string" && content.trim()) {
|
|
3942
|
+
return content.trim().slice(0, MAX_RESPONSE_CHARS);
|
|
3943
|
+
}
|
|
3944
|
+
getLogger().warn("consult tool: response missing choices[0].message.content");
|
|
3945
|
+
return GRACEFUL_FALLBACK;
|
|
3946
|
+
} catch (e) {
|
|
3947
|
+
getLogger().warn(
|
|
3948
|
+
`consult tool: openai-compatible call failed: ${e instanceof Error ? e.name : "error"}`
|
|
3949
|
+
);
|
|
3950
|
+
return GRACEFUL_FALLBACK;
|
|
3951
|
+
}
|
|
3952
|
+
};
|
|
3953
|
+
}
|
|
3954
|
+
var POSTCALL_INSTRUCTION = "A phone call handled by the voice agent has just ended. Here is the record of the call. Log it and follow up if anything needs action.";
|
|
3955
|
+
var POSTCALL_MAX_TRANSCRIPT_CHARS = 12e3;
|
|
3956
|
+
function buildPostCallRecord(data, includeTranscript) {
|
|
3957
|
+
const lines = [];
|
|
3958
|
+
const caller = data.caller;
|
|
3959
|
+
const callee = data.callee;
|
|
3960
|
+
if (caller) lines.push(`Caller: ${caller}`);
|
|
3961
|
+
if (callee) lines.push(`Line dialed: ${callee}`);
|
|
3962
|
+
const metrics = data.metrics;
|
|
3963
|
+
const duration = metrics?.durationSeconds ?? metrics?.duration_seconds;
|
|
3964
|
+
if (typeof duration === "number") lines.push(`Duration: ${Math.round(duration)}s`);
|
|
3965
|
+
if (includeTranscript) {
|
|
3966
|
+
const entries = data.transcript ?? [];
|
|
3967
|
+
const rendered = entries.filter((e) => e && typeof e === "object").map((e) => `${e.role ?? "?"}: ${e.text ?? ""}`).join("\n");
|
|
3968
|
+
if (rendered) lines.push("Transcript:\n" + rendered.slice(0, POSTCALL_MAX_TRANSCRIPT_CHARS));
|
|
3969
|
+
}
|
|
3970
|
+
return lines.length ? lines.join("\n") : "(no call details available)";
|
|
3971
|
+
}
|
|
3972
|
+
function openclawPostCallNotifier(agent, opts = {}) {
|
|
3973
|
+
const cfg = openclawConsult(agent, {
|
|
3974
|
+
baseUrl: opts.baseUrl,
|
|
3975
|
+
apiKey: opts.apiKey,
|
|
3976
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3977
|
+
allowLoopback: opts.allowLoopback
|
|
3978
|
+
});
|
|
3979
|
+
const oc = cfg.openaiCompatible;
|
|
3980
|
+
const endpoint = oc.baseUrl.replace(/\/+$/, "") + "/chat/completions";
|
|
3981
|
+
validateWebhookUrl(endpoint, cfg.allowLoopback ?? false);
|
|
3982
|
+
const apiKey = oc.apiKey ?? (oc.apiKeyEnv ? process.env[oc.apiKeyEnv] : void 0);
|
|
3983
|
+
const sessionHeader = oc.sessionHeader;
|
|
3984
|
+
const model = oc.model;
|
|
3985
|
+
const timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
3986
|
+
const includeTranscript = opts.includeTranscript ?? true;
|
|
3987
|
+
const instruction = opts.instruction ?? POSTCALL_INSTRUCTION;
|
|
3988
|
+
return async (data) => {
|
|
3989
|
+
const callId = (data ?? {}).call_id ?? "";
|
|
3990
|
+
const record = buildPostCallRecord(data ?? {}, includeTranscript);
|
|
3991
|
+
const headers = { "Content-Type": "application/json" };
|
|
3992
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
3993
|
+
if (sessionHeader && callId) headers[sessionHeader] = callId;
|
|
3994
|
+
const payload = {
|
|
3995
|
+
model,
|
|
3996
|
+
messages: [
|
|
3997
|
+
{ role: "system", content: instruction },
|
|
3998
|
+
{ role: "user", content: record }
|
|
3999
|
+
],
|
|
4000
|
+
stream: false
|
|
4001
|
+
};
|
|
4002
|
+
if (callId) payload.user = callId;
|
|
4003
|
+
try {
|
|
4004
|
+
const resp = await fetch(endpoint, {
|
|
4005
|
+
method: "POST",
|
|
4006
|
+
headers,
|
|
4007
|
+
body: JSON.stringify(payload),
|
|
4008
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
4009
|
+
});
|
|
4010
|
+
if (!resp.ok) {
|
|
4011
|
+
getLogger().warn(`openclaw post-call notify: HTTP ${resp.status}`);
|
|
4012
|
+
}
|
|
4013
|
+
} catch (e) {
|
|
4014
|
+
getLogger().warn(
|
|
4015
|
+
`openclaw post-call notify failed: ${e instanceof Error ? e.name : "error"}`
|
|
4016
|
+
);
|
|
4017
|
+
}
|
|
4018
|
+
};
|
|
4019
|
+
}
|
|
4020
|
+
|
|
3330
4021
|
// src/sentence-chunker.ts
|
|
3331
4022
|
init_esm_shims();
|
|
3332
4023
|
var DEFAULT_MIN_SENTENCE_LEN = 20;
|
|
@@ -4028,6 +4719,52 @@ async function withSpan(name, attrs, fn) {
|
|
|
4028
4719
|
}
|
|
4029
4720
|
|
|
4030
4721
|
// src/stream-handler.ts
|
|
4722
|
+
var DEFAULT_TOOL_CALL_PREAMBLE_BLOCK = `# Preambles
|
|
4723
|
+
|
|
4724
|
+
Use short preambles only when they help the user understand that work is happening. A preamble is one short spoken update describing the action you are about to take \u2014 not hidden reasoning, and never a claim about the result.
|
|
4725
|
+
|
|
4726
|
+
## When to use a preamble
|
|
4727
|
+
Use a preamble when:
|
|
4728
|
+
- you are about to call a tool that may take noticeable time;
|
|
4729
|
+
- you need to reason through a multi-step request;
|
|
4730
|
+
- you are checking records, availability, account state, or policy details;
|
|
4731
|
+
- you are preparing an escalation or handoff;
|
|
4732
|
+
- silence would make the assistant feel unresponsive.
|
|
4733
|
+
|
|
4734
|
+
When a preamble is needed, output it immediately before the reasoning or tool call.
|
|
4735
|
+
|
|
4736
|
+
## When to NOT use a preamble
|
|
4737
|
+
Do not use a preamble when:
|
|
4738
|
+
- the answer is direct and can be given immediately;
|
|
4739
|
+
- the user is only confirming, correcting, or declining something;
|
|
4740
|
+
- the audio is unclear and you need clarification instead;
|
|
4741
|
+
- the tool call is lightweight and the user would not benefit from an update.
|
|
4742
|
+
|
|
4743
|
+
## Style
|
|
4744
|
+
- Keep it to one short sentence (two only before a high-impact action).
|
|
4745
|
+
- Vary the wording across turns; do not reuse the same opener.
|
|
4746
|
+
- Describe the action, not the internal reasoning.
|
|
4747
|
+
- Never imply success or failure before the tool returns.
|
|
4748
|
+
|
|
4749
|
+
Prefer:
|
|
4750
|
+
- "I'll check that order now."
|
|
4751
|
+
- "I'll look up your appointment details."
|
|
4752
|
+
- "I'll verify that before we make any changes."
|
|
4753
|
+
- "I'll check the policy and then give you the next step."
|
|
4754
|
+
- "I'll pull that up so we can make sure it's the right account."
|
|
4755
|
+
|
|
4756
|
+
Avoid:
|
|
4757
|
+
- "Let me think about that for a second."
|
|
4758
|
+
- "Please wait while I process your request."
|
|
4759
|
+
- "I'm going to use my tools now."
|
|
4760
|
+
- "Hmm..." / "One moment while I process that..."`;
|
|
4761
|
+
function applyToolCallPreambles(prompt, knob) {
|
|
4762
|
+
if (!knob) return prompt;
|
|
4763
|
+
const block = typeof knob === "string" ? knob : DEFAULT_TOOL_CALL_PREAMBLE_BLOCK;
|
|
4764
|
+
return prompt ? `${block}
|
|
4765
|
+
|
|
4766
|
+
${prompt}` : block;
|
|
4767
|
+
}
|
|
4031
4768
|
function checkGuardrails(text, guardrails) {
|
|
4032
4769
|
if (!guardrails) return null;
|
|
4033
4770
|
for (const guard of guardrails) {
|
|
@@ -4055,40 +4792,93 @@ function maskPhoneNumber(number) {
|
|
|
4055
4792
|
function isValidE164(number) {
|
|
4056
4793
|
return /^\+[1-9]\d{6,14}$/.test(number);
|
|
4057
4794
|
}
|
|
4795
|
+
function augmentWithBuiltinHandoffTools(userTools, callbacks) {
|
|
4796
|
+
const out = [...userTools ?? []];
|
|
4797
|
+
if (callbacks.transferCall) {
|
|
4798
|
+
const transferCall = callbacks.transferCall;
|
|
4799
|
+
out.push({
|
|
4800
|
+
...TRANSFER_CALL_TOOL,
|
|
4801
|
+
handler: async (args) => {
|
|
4802
|
+
const number = typeof args.number === "string" ? args.number : "";
|
|
4803
|
+
if (!isValidE164(number)) {
|
|
4804
|
+
return JSON.stringify({ error: "Invalid phone number format", status: "rejected" });
|
|
4805
|
+
}
|
|
4806
|
+
await transferCall(number);
|
|
4807
|
+
return JSON.stringify({ status: "transferring", to: number });
|
|
4808
|
+
}
|
|
4809
|
+
});
|
|
4810
|
+
}
|
|
4811
|
+
if (callbacks.endCall) {
|
|
4812
|
+
const endCall = callbacks.endCall;
|
|
4813
|
+
out.push({
|
|
4814
|
+
...END_CALL_TOOL,
|
|
4815
|
+
handler: async (args) => {
|
|
4816
|
+
const reason = typeof args.reason === "string" ? args.reason : "conversation_complete";
|
|
4817
|
+
await endCall(reason);
|
|
4818
|
+
return JSON.stringify({ status: "ending", reason });
|
|
4819
|
+
}
|
|
4820
|
+
});
|
|
4821
|
+
}
|
|
4822
|
+
return out;
|
|
4823
|
+
}
|
|
4058
4824
|
var HALLUCINATIONS = /* @__PURE__ */ new Set([
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
"cool",
|
|
4075
|
-
// Whisper YouTube-caption hallucinations
|
|
4825
|
+
// Issue #154: the hallucination filter is now DISPLAY-ONLY — it no longer
|
|
4826
|
+
// gates response creation (the server drives the response on
|
|
4827
|
+
// ``input_audio_buffer.committed`` by default). Dropping a phrase here
|
|
4828
|
+
// therefore deletes the user's transcript line (recordSttComplete never
|
|
4829
|
+
// fires → empty user_text → dashboard skips the user line). So this set is
|
|
4830
|
+
// restricted to genuine NON-SPEECH artefacts that Whisper emits on
|
|
4831
|
+
// silence / TTS echo, NOT real conversational words. Standalone words like
|
|
4832
|
+
// 'yes', 'no', 'okay', 'right', 'you', 'thanks' were REMOVED — they are
|
|
4833
|
+
// legitimate user replies and must reach the transcript. Parity with
|
|
4834
|
+
// Python ``_STT_HALLUCINATIONS``.
|
|
4835
|
+
//
|
|
4836
|
+
// Whisper caption / training-set hallucinations. Whisper was trained heavily
|
|
4837
|
+
// on captioned video, so on silence / PSTN echo it falls back to the most
|
|
4838
|
+
// common caption credits + sign-offs. Curated from widely-reported
|
|
4839
|
+
// Whisper-on-silence outputs across the open-source ASR community.
|
|
4076
4840
|
"thank you for watching",
|
|
4077
4841
|
"thanks for watching",
|
|
4078
4842
|
"thank you for watching!",
|
|
4079
4843
|
"thanks for watching!",
|
|
4080
4844
|
"thank you so much for watching",
|
|
4845
|
+
"thank you for watching please subscribe",
|
|
4846
|
+
"thanks for watching please subscribe",
|
|
4081
4847
|
"thanks for listening",
|
|
4848
|
+
"we'll see you next time",
|
|
4849
|
+
"see you next time",
|
|
4850
|
+
"bye bye",
|
|
4082
4851
|
"please subscribe",
|
|
4852
|
+
"please subscribe to my channel",
|
|
4853
|
+
"don't forget to subscribe",
|
|
4854
|
+
"like and subscribe",
|
|
4083
4855
|
"subscribe",
|
|
4856
|
+
"subtitles by the amara.org community",
|
|
4857
|
+
"subtitles by the amara org community",
|
|
4858
|
+
"subtitles by",
|
|
4859
|
+
"transcribed by",
|
|
4860
|
+
"transcription by castingwords",
|
|
4861
|
+
"the end",
|
|
4862
|
+
// Music / sound markers.
|
|
4084
4863
|
"music",
|
|
4085
4864
|
"[music]",
|
|
4865
|
+
"piano music",
|
|
4866
|
+
"applause",
|
|
4867
|
+
"[applause]",
|
|
4086
4868
|
"\u266A",
|
|
4869
|
+
// Silence markers.
|
|
4087
4870
|
"[no audio]",
|
|
4088
4871
|
"[silence]",
|
|
4089
4872
|
"[blank_audio]",
|
|
4090
4873
|
"(silence)"
|
|
4091
4874
|
]);
|
|
4875
|
+
function isSttHallucination(text) {
|
|
4876
|
+
const stripped = text.trim().toLowerCase().replace(/[.,!?;:…。!?\s]+$/u, "").trim();
|
|
4877
|
+
if (stripped === "") return true;
|
|
4878
|
+
if (HALLUCINATIONS.has(stripped)) return true;
|
|
4879
|
+
const pieces = stripped.split(/[.!?…。!?]+/u).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
4880
|
+
return pieces.length > 1 && pieces.every((p) => HALLUCINATIONS.has(p));
|
|
4881
|
+
}
|
|
4092
4882
|
var StreamHandler = class _StreamHandler {
|
|
4093
4883
|
deps;
|
|
4094
4884
|
ws;
|
|
@@ -4387,7 +5177,14 @@ var StreamHandler = class _StreamHandler {
|
|
|
4387
5177
|
* barge-in armed during the audible tail. Tunable via env.
|
|
4388
5178
|
*/
|
|
4389
5179
|
endSpeakingWithGrace() {
|
|
4390
|
-
const
|
|
5180
|
+
const rawGrace = process.env.PATTER_TTS_TAIL_GRACE_MS;
|
|
5181
|
+
const parsedGrace = rawGrace !== void 0 ? Number(rawGrace) : NaN;
|
|
5182
|
+
const grace = rawGrace !== void 0 && Number.isFinite(parsedGrace) ? parsedGrace : 1500;
|
|
5183
|
+
if (rawGrace !== void 0 && !Number.isFinite(parsedGrace)) {
|
|
5184
|
+
getLogger().warn(
|
|
5185
|
+
`PATTER_TTS_TAIL_GRACE_MS="${rawGrace}" is not a valid number \u2014 using default 1500ms`
|
|
5186
|
+
);
|
|
5187
|
+
}
|
|
4391
5188
|
if (grace > 0) {
|
|
4392
5189
|
const gen = this.speakingGeneration;
|
|
4393
5190
|
this.clearGraceTimer();
|
|
@@ -4481,6 +5278,14 @@ var StreamHandler = class _StreamHandler {
|
|
|
4481
5278
|
`[DIAG] Flushed ${replayed} pre-barge-in frame(s) (~${replayed * 20} ms) to STT`
|
|
4482
5279
|
);
|
|
4483
5280
|
}
|
|
5281
|
+
/**
|
|
5282
|
+
* Per-call resolved tool list. Starts as ``null`` (falls back to
|
|
5283
|
+
* ``deps.agent.tools``). Populated by ``initMcpTools`` when MCP servers
|
|
5284
|
+
* are configured so discovered tools are merged in without mutating the
|
|
5285
|
+
* shared ``AgentOptions`` object. Code that needs the effective tool list
|
|
5286
|
+
* should read ``this.resolvedTools ?? this.deps.agent.tools``.
|
|
5287
|
+
*/
|
|
5288
|
+
resolvedTools = null;
|
|
4484
5289
|
llmLoop = null;
|
|
4485
5290
|
/**
|
|
4486
5291
|
* Per-call tool executor — provides retry-with-exponential-backoff and a
|
|
@@ -4524,6 +5329,17 @@ var StreamHandler = class _StreamHandler {
|
|
|
4524
5329
|
userTranscriptPending = false;
|
|
4525
5330
|
pendingAssistantTurn = null;
|
|
4526
5331
|
pendingAssistantTimer = null;
|
|
5332
|
+
/**
|
|
5333
|
+
* Reserved monotonic turn index for the in-flight Realtime turn (issue
|
|
5334
|
+
* #154, fix 5/6). Reserved in ``onAdapterSpeechStopped`` via
|
|
5335
|
+
* ``metricsAcc.reserveTurnIndex()`` the moment the turn OPENS, then threaded
|
|
5336
|
+
* through to the live per-line transcript events (``recordTranscriptLine``)
|
|
5337
|
+
* and into ``recordTurnComplete`` / ``recordTurnInterrupted`` so the
|
|
5338
|
+
* dashboard can sort a late-arriving user line ABOVE its agent line by
|
|
5339
|
+
* ``(turnIndex, role)``. ``null`` until the first turn opens. Parity with
|
|
5340
|
+
* Python ``_current_turn_index``.
|
|
5341
|
+
*/
|
|
5342
|
+
currentTurnIndex = null;
|
|
4527
5343
|
/**
|
|
4528
5344
|
* Hard cap on how long we wait for the user transcript before flushing
|
|
4529
5345
|
* the buffered assistant turn alone. 3 s covers OpenAI Whisper's typical
|
|
@@ -4605,6 +5421,23 @@ var StreamHandler = class _StreamHandler {
|
|
|
4605
5421
|
* streaming/regular LLM, WebSocket remote, Realtime response_done) so the
|
|
4606
5422
|
* payload shape lives in one place.
|
|
4607
5423
|
*/
|
|
5424
|
+
/**
|
|
5425
|
+
* Emit a live per-line transcript event to the dashboard store (issue #154,
|
|
5426
|
+
* fix 5). Routed through a single helper so the call shape lives in one
|
|
5427
|
+
* place. ``recordTranscriptLine`` appends the line to the active call's
|
|
5428
|
+
* transcript and publishes a ``transcript_line`` SSE event; the dashboard
|
|
5429
|
+
* sorts by (turnIndex, user<assistant) so a late user line lands above its
|
|
5430
|
+
* agent line. No-op when no turn index has been reserved yet.
|
|
5431
|
+
*/
|
|
5432
|
+
emitTranscriptLine(role, text) {
|
|
5433
|
+
if (this.currentTurnIndex === null) return;
|
|
5434
|
+
this.deps.metricsStore.recordTranscriptLine({
|
|
5435
|
+
call_id: this.callId,
|
|
5436
|
+
turnIndex: this.currentTurnIndex,
|
|
5437
|
+
role,
|
|
5438
|
+
text
|
|
5439
|
+
});
|
|
5440
|
+
}
|
|
4608
5441
|
async emitTurnMetrics(turn) {
|
|
4609
5442
|
if (turn == null) return;
|
|
4610
5443
|
this.deps.metricsStore.recordTurn({ call_id: this.callId, turn });
|
|
@@ -4646,8 +5479,8 @@ var StreamHandler = class _StreamHandler {
|
|
|
4646
5479
|
this.ttsByteCarry = null;
|
|
4647
5480
|
}
|
|
4648
5481
|
/**
|
|
4649
|
-
* Start call recording when configured.
|
|
4650
|
-
*
|
|
5482
|
+
* Start call recording when configured. Bridges expose
|
|
5483
|
+
* ``startRecording`` for carrier parity (Twilio and Telnyx supported).
|
|
4651
5484
|
*/
|
|
4652
5485
|
async startRecordingIfRequested(callId) {
|
|
4653
5486
|
const { recording, config } = this.deps;
|
|
@@ -4711,7 +5544,7 @@ var StreamHandler = class _StreamHandler {
|
|
|
4711
5544
|
if (customParams.callee && !this.callee) this.callee = customParams.callee;
|
|
4712
5545
|
const mode = this.deps.agent.engine ? `engine=${this.deps.agent.engine.kind ?? "unknown"}` : "pipeline";
|
|
4713
5546
|
getLogger().info(
|
|
4714
|
-
`Call started: ${callId} (${this.deps.bridge.label}, ${mode}, ${
|
|
5547
|
+
`Call started: ${callId} (${this.deps.bridge.label}, ${mode}, ${maskPhoneNumber(this.caller || "?")} \u2192 ${maskPhoneNumber(this.callee || "?")})`
|
|
4715
5548
|
);
|
|
4716
5549
|
if (Object.keys(customParams).length > 0) {
|
|
4717
5550
|
getLogger().debug(`Custom params: ${sanitizeLogValue(JSON.stringify(customParams))}`);
|
|
@@ -4756,10 +5589,13 @@ var StreamHandler = class _StreamHandler {
|
|
|
4756
5589
|
const resolvedPrompt = Object.keys(allVars).length > 0 ? this.deps.resolveVariables(this.deps.agent.systemPrompt, allVars) : this.deps.agent.systemPrompt;
|
|
4757
5590
|
const provider2 = this.deps.agent.provider ?? "openai_realtime";
|
|
4758
5591
|
await this.initMcpTools();
|
|
5592
|
+
this.injectConsultTool();
|
|
4759
5593
|
if (provider2 === "pipeline") {
|
|
4760
5594
|
await this.initPipeline(resolvedPrompt);
|
|
4761
5595
|
} else {
|
|
4762
|
-
await this.initRealtimeAdapter(
|
|
5596
|
+
await this.initRealtimeAdapter(
|
|
5597
|
+
applyToolCallPreambles(resolvedPrompt, this.deps.agent.toolCallPreambles)
|
|
5598
|
+
);
|
|
4763
5599
|
}
|
|
4764
5600
|
}
|
|
4765
5601
|
/**
|
|
@@ -4784,10 +5620,25 @@ var StreamHandler = class _StreamHandler {
|
|
|
4784
5620
|
}
|
|
4785
5621
|
if (discovered.length === 0) return;
|
|
4786
5622
|
MCPManager.assertNoConflicts(this.deps.agent.tools, discovered);
|
|
4787
|
-
|
|
4788
|
-
mutableAgent.tools = [...mutableAgent.tools ?? [], ...discovered];
|
|
5623
|
+
this.resolvedTools = [...this.deps.agent.tools ?? [], ...discovered];
|
|
4789
5624
|
getLogger().info(`MCP: merged ${discovered.length} tool(s) into agent`);
|
|
4790
5625
|
}
|
|
5626
|
+
/**
|
|
5627
|
+
* Merge the built-in ``consult`` tool into the per-call tool list when
|
|
5628
|
+
* ``agent.consult`` is set, mirroring {@link initMcpTools}: the shared
|
|
5629
|
+
* ``deps.agent`` is NOT mutated; the merged list is stored on
|
|
5630
|
+
* ``this.resolvedTools`` so ``buildAIAdapter`` (Realtime) and the pipeline
|
|
5631
|
+
* ``LLMLoop`` both see it. Idempotent — a no-op if a tool with the same name
|
|
5632
|
+
* is already present.
|
|
5633
|
+
*/
|
|
5634
|
+
injectConsultTool() {
|
|
5635
|
+
const consult = this.deps.agent.consult;
|
|
5636
|
+
if (!consult) return;
|
|
5637
|
+
const consultTool = buildConsultTool(consult);
|
|
5638
|
+
const base = this.resolvedTools ?? (this.deps.agent.tools ?? []);
|
|
5639
|
+
if (base.some((t) => t.name === consultTool.name)) return;
|
|
5640
|
+
this.resolvedTools = [...base, consultTool];
|
|
5641
|
+
}
|
|
4791
5642
|
/** Set the stream SID (Twilio only, called after parsing 'start' event). */
|
|
4792
5643
|
/** Set the carrier-side stream id (Twilio `streamSid` / Telnyx stream identifier). */
|
|
4793
5644
|
setStreamSid(sid) {
|
|
@@ -4807,8 +5658,12 @@ var StreamHandler = class _StreamHandler {
|
|
|
4807
5658
|
if (activeVad && !this.vadDisabled) {
|
|
4808
5659
|
try {
|
|
4809
5660
|
const vadPromise = activeVad.processFrame(pcm16k, 16e3);
|
|
4810
|
-
|
|
5661
|
+
let vadTimeoutId;
|
|
5662
|
+
const timeoutPromise = new Promise((resolve2) => {
|
|
5663
|
+
vadTimeoutId = setTimeout(() => resolve2(null), 25);
|
|
5664
|
+
});
|
|
4811
5665
|
const evt = await Promise.race([vadPromise, timeoutPromise]);
|
|
5666
|
+
clearTimeout(vadTimeoutId);
|
|
4812
5667
|
if (evt) {
|
|
4813
5668
|
getLogger().info(
|
|
4814
5669
|
`[VAD] ${evt.type} agentSpeaking=${this.isSpeaking}`
|
|
@@ -4881,7 +5736,7 @@ var StreamHandler = class _StreamHandler {
|
|
|
4881
5736
|
if ((this.deps.agent.bargeInThresholdMs ?? 300) === 0) return;
|
|
4882
5737
|
}
|
|
4883
5738
|
const hooks = this.deps.agent.hooks;
|
|
4884
|
-
if (hooks) {
|
|
5739
|
+
if (hooks?.beforeSendToStt) {
|
|
4885
5740
|
const hookExecutor = new PipelineHookExecutor(hooks);
|
|
4886
5741
|
const hookCtx = this.buildHookContext();
|
|
4887
5742
|
const processed = await hookExecutor.runBeforeSendToStt(pcm16k, hookCtx);
|
|
@@ -4893,7 +5748,7 @@ var StreamHandler = class _StreamHandler {
|
|
|
4893
5748
|
this.metricsAcc.addSttAudioBytes(pcm16k.length);
|
|
4894
5749
|
}
|
|
4895
5750
|
} else if (this.adapter) {
|
|
4896
|
-
if (this.adapter instanceof ElevenLabsConvAIAdapter && this.deps.bridge.
|
|
5751
|
+
if (this.adapter instanceof ElevenLabsConvAIAdapter && this.deps.bridge.inputWireFormat === "ulaw_8000" && this.adapter.inputAudioFormat !== "ulaw_8000") {
|
|
4897
5752
|
const pcm8k = mulawToPcm16(audioBuffer);
|
|
4898
5753
|
const pcm16k = this.inboundResampler.process(pcm8k);
|
|
4899
5754
|
this.adapter.sendAudio(pcm16k);
|
|
@@ -5054,6 +5909,7 @@ var StreamHandler = class _StreamHandler {
|
|
|
5054
5909
|
const carrier = this.deps.bridge.telephonyProvider;
|
|
5055
5910
|
if (carrier === "twilio") return fmt === "ulaw_8000";
|
|
5056
5911
|
if (carrier === "telnyx") return fmt === "pcm_16000";
|
|
5912
|
+
if (carrier === "plivo") return fmt === "ulaw_8000";
|
|
5057
5913
|
return false;
|
|
5058
5914
|
}
|
|
5059
5915
|
/**
|
|
@@ -5151,7 +6007,7 @@ var StreamHandler = class _StreamHandler {
|
|
|
5151
6007
|
}
|
|
5152
6008
|
if (!this.deps.agent.vad) {
|
|
5153
6009
|
try {
|
|
5154
|
-
const { SileroVAD } = await import("./silero-vad-
|
|
6010
|
+
const { SileroVAD } = await import("./silero-vad-RGF5HCIR.mjs");
|
|
5155
6011
|
this.autoVad = await SileroVAD.forPhoneCall();
|
|
5156
6012
|
getLogger().info(
|
|
5157
6013
|
`auto-VAD enabled (SileroVAD, phone preset). Pass agent.vad=\u2026 to override.`
|
|
@@ -5170,12 +6026,9 @@ var StreamHandler = class _StreamHandler {
|
|
|
5170
6026
|
}
|
|
5171
6027
|
}
|
|
5172
6028
|
if (this.deps.agent.echoCancellation) {
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
`echoCancellation: true on ${carrier} (PSTN). Server-side NLMS cannot model PSTN's ~250\u20131500 ms round-trip echo with a 32 ms filter window \u2014 it will silently no-op. Best practice: keep echoCancellation: false; rely on the carrier + caller device's built-in echo suppression and Patter's self-hearing guard. Enable AEC only for browser/native deployments where the SDK owns the audio path end-to-end.`
|
|
5177
|
-
);
|
|
5178
|
-
}
|
|
6029
|
+
getLogger().warn(
|
|
6030
|
+
`echoCancellation: true on ${this.deps.bridge.telephonyProvider} (PSTN). Server-side NLMS cannot model PSTN's ~250\u20131500 ms round-trip echo with a 32 ms filter window \u2014 it will silently no-op. Best practice: keep echoCancellation: false; rely on the carrier + caller device's built-in echo suppression and Patter's self-hearing guard. Enable AEC only for browser/native deployments where the SDK owns the audio path end-to-end.`
|
|
6031
|
+
);
|
|
5179
6032
|
try {
|
|
5180
6033
|
const { NlmsEchoCanceller } = await import("./aec-PJJMUM5E.mjs");
|
|
5181
6034
|
this.aec = new NlmsEchoCanceller({ sampleRate: 16e3 });
|
|
@@ -5308,13 +6161,20 @@ var StreamHandler = class _StreamHandler {
|
|
|
5308
6161
|
);
|
|
5309
6162
|
}
|
|
5310
6163
|
const providerModel = this.deps.agent.llm?.model ?? "";
|
|
6164
|
+
const augmentedTools = augmentWithBuiltinHandoffTools(
|
|
6165
|
+
this.resolvedTools ?? this.deps.agent.tools,
|
|
6166
|
+
{
|
|
6167
|
+
transferCall: (number) => this.deps.bridge.transferCall(this.callId, number),
|
|
6168
|
+
endCall: () => this.deps.bridge.endCall(this.callId, this.ws)
|
|
6169
|
+
}
|
|
6170
|
+
);
|
|
5311
6171
|
this.llmLoop = new LLMLoop(
|
|
5312
6172
|
"",
|
|
5313
6173
|
// apiKey unused when llmProvider is supplied
|
|
5314
6174
|
providerModel,
|
|
5315
6175
|
// propagate so calculateLlmCost can match the price row
|
|
5316
6176
|
resolvedPrompt,
|
|
5317
|
-
|
|
6177
|
+
augmentedTools,
|
|
5318
6178
|
this.deps.agent.llm,
|
|
5319
6179
|
this.deps.agent.disablePhonePreamble ?? false
|
|
5320
6180
|
);
|
|
@@ -5325,11 +6185,18 @@ var StreamHandler = class _StreamHandler {
|
|
|
5325
6185
|
} else if (!this.deps.onMessage && this.deps.config.openaiKey) {
|
|
5326
6186
|
let llmModel = this.deps.agent.model || "gpt-4o-mini";
|
|
5327
6187
|
if (llmModel.includes("realtime")) llmModel = "gpt-4o-mini";
|
|
6188
|
+
const augmentedTools = augmentWithBuiltinHandoffTools(
|
|
6189
|
+
this.resolvedTools ?? this.deps.agent.tools,
|
|
6190
|
+
{
|
|
6191
|
+
transferCall: (number) => this.deps.bridge.transferCall(this.callId, number),
|
|
6192
|
+
endCall: () => this.deps.bridge.endCall(this.callId, this.ws)
|
|
6193
|
+
}
|
|
6194
|
+
);
|
|
5328
6195
|
this.llmLoop = new LLMLoop(
|
|
5329
6196
|
this.deps.config.openaiKey,
|
|
5330
6197
|
llmModel,
|
|
5331
6198
|
resolvedPrompt,
|
|
5332
|
-
|
|
6199
|
+
augmentedTools,
|
|
5333
6200
|
void 0,
|
|
5334
6201
|
this.deps.agent.disablePhonePreamble ?? false
|
|
5335
6202
|
);
|
|
@@ -5843,7 +6710,7 @@ var StreamHandler = class _StreamHandler {
|
|
|
5843
6710
|
// ---------------------------------------------------------------------------
|
|
5844
6711
|
async initRealtimeAdapter(resolvedPrompt) {
|
|
5845
6712
|
const label = this.deps.bridge.label;
|
|
5846
|
-
this.adapter = this.deps.buildAIAdapter(resolvedPrompt);
|
|
6713
|
+
this.adapter = this.deps.buildAIAdapter(resolvedPrompt, this.resolvedTools ?? void 0);
|
|
5847
6714
|
let parked;
|
|
5848
6715
|
if (typeof this.deps.popPrewarmedConnections === "function") {
|
|
5849
6716
|
try {
|
|
@@ -5916,6 +6783,7 @@ var StreamHandler = class _StreamHandler {
|
|
|
5916
6783
|
response_done: async (eventData) => this.onAdapterResponseDone(eventData),
|
|
5917
6784
|
speech_started: async () => this.onAdapterSpeechInterrupt(),
|
|
5918
6785
|
interruption: async () => this.onAdapterSpeechInterrupt(),
|
|
6786
|
+
error: async (eventData) => this.onAdapterError(eventData),
|
|
5919
6787
|
function_call: async (eventData) => {
|
|
5920
6788
|
if (this.adapter instanceof OpenAIRealtimeAdapter) {
|
|
5921
6789
|
await this.handleFunctionCall(eventData);
|
|
@@ -6002,21 +6870,31 @@ var StreamHandler = class _StreamHandler {
|
|
|
6002
6870
|
if (!this.metricsAcc.turnActive) this.metricsAcc.startTurn();
|
|
6003
6871
|
this.currentAgentText = "";
|
|
6004
6872
|
this.responseAudioStarted = false;
|
|
6873
|
+
this.currentTurnIndex = this.metricsAcc.reserveTurnIndex();
|
|
6005
6874
|
this.userTranscriptPending = true;
|
|
6006
6875
|
await this.emitUserSpeechEnded();
|
|
6007
6876
|
}
|
|
6008
6877
|
async onAdapterTranscriptInput(inputText) {
|
|
6009
|
-
|
|
6010
|
-
if (HALLUCINATIONS.has(stripped) || stripped === "") {
|
|
6878
|
+
if (isSttHallucination(inputText)) {
|
|
6011
6879
|
getLogger().debug(
|
|
6012
6880
|
`Realtime transcript_input dropped (likely Whisper hallucination on silence/echo): ${sanitizeLogValue(inputText.slice(0, 60))}`
|
|
6013
6881
|
);
|
|
6014
6882
|
this.userTranscriptPending = false;
|
|
6883
|
+
if (this.pendingAssistantTurn !== null) {
|
|
6884
|
+
const buffered = this.pendingAssistantTurn;
|
|
6885
|
+
this.pendingAssistantTurn = null;
|
|
6886
|
+
if (this.pendingAssistantTimer) {
|
|
6887
|
+
clearTimeout(this.pendingAssistantTimer);
|
|
6888
|
+
this.pendingAssistantTimer = null;
|
|
6889
|
+
}
|
|
6890
|
+
await this.flushAssistantTurn(buffered);
|
|
6891
|
+
}
|
|
6015
6892
|
return;
|
|
6016
6893
|
}
|
|
6017
6894
|
getLogger().debug(`User (${this.deps.bridge.label}): ${sanitizeLogValue(inputText)}`);
|
|
6018
6895
|
this.history.push({ role: "user", text: inputText, timestamp: Date.now() });
|
|
6019
|
-
|
|
6896
|
+
this.emitTranscriptLine("user", inputText);
|
|
6897
|
+
if (this.adapter instanceof OpenAIRealtimeAdapter && this.adapter.getGateResponseOnTranscript()) {
|
|
6020
6898
|
void this.adapter.requestResponse().catch(
|
|
6021
6899
|
(err) => getLogger().debug(`Realtime requestResponse failed: ${String(err)}`)
|
|
6022
6900
|
);
|
|
@@ -6063,8 +6941,12 @@ var StreamHandler = class _StreamHandler {
|
|
|
6063
6941
|
history: [...this.history.entries]
|
|
6064
6942
|
});
|
|
6065
6943
|
}
|
|
6944
|
+
const reservedIndex = this.currentTurnIndex;
|
|
6945
|
+
this.emitTranscriptLine("assistant", text);
|
|
6066
6946
|
this.responseAudioStarted = false;
|
|
6067
|
-
await this.emitTurnMetrics(
|
|
6947
|
+
await this.emitTurnMetrics(
|
|
6948
|
+
this.metricsAcc.recordTurnComplete(text, reservedIndex ?? void 0)
|
|
6949
|
+
);
|
|
6068
6950
|
}
|
|
6069
6951
|
/**
|
|
6070
6952
|
* Push an assistant turn into history and fire `onTranscript` so host
|
|
@@ -6163,7 +7045,9 @@ var StreamHandler = class _StreamHandler {
|
|
|
6163
7045
|
this.pendingAssistantTimer = null;
|
|
6164
7046
|
this.userTranscriptPending = false;
|
|
6165
7047
|
if (buffered !== null) {
|
|
6166
|
-
|
|
7048
|
+
this.flushAssistantTurn(buffered).catch(
|
|
7049
|
+
(err) => getLogger().error("flushAssistantTurn (fallback timer) failed:", err)
|
|
7050
|
+
);
|
|
6167
7051
|
}
|
|
6168
7052
|
}, _StreamHandler.REALTIME_USER_TRANSCRIPT_WAIT_MS);
|
|
6169
7053
|
this.responseAudioStarted = false;
|
|
@@ -6172,7 +7056,9 @@ var StreamHandler = class _StreamHandler {
|
|
|
6172
7056
|
await this.flushAssistantTurn(text);
|
|
6173
7057
|
}
|
|
6174
7058
|
async onAdapterSpeechInterrupt() {
|
|
6175
|
-
|
|
7059
|
+
const isEngine = this.adapter instanceof OpenAIRealtimeAdapter;
|
|
7060
|
+
const clientManaged = isEngine && this.adapter.getGateResponseOnTranscript();
|
|
7061
|
+
if (clientManaged) {
|
|
6176
7062
|
const startedAt = this.adapter.currentResponseFirstAudioAt;
|
|
6177
7063
|
if (startedAt !== null) {
|
|
6178
7064
|
const elapsedMs = Date.now() - startedAt;
|
|
@@ -6185,12 +7071,20 @@ var StreamHandler = class _StreamHandler {
|
|
|
6185
7071
|
}
|
|
6186
7072
|
}
|
|
6187
7073
|
this.deps.bridge.sendClear(this.ws, this.streamSid);
|
|
6188
|
-
if (
|
|
7074
|
+
if (clientManaged) {
|
|
7075
|
+
this.metricsAcc.recordBargeinDetected();
|
|
7076
|
+
this.adapter.cancelResponse();
|
|
7077
|
+
} else if (isEngine) {
|
|
7078
|
+
this.adapter.truncate();
|
|
7079
|
+
}
|
|
6189
7080
|
this.metricsAcc.recordTurnInterrupted();
|
|
6190
7081
|
if (this.responseAudioStarted) {
|
|
6191
7082
|
await this.emitAgentSpeechEnded(true);
|
|
6192
7083
|
}
|
|
6193
7084
|
await this.emitUserSpeechStarted();
|
|
7085
|
+
if (clientManaged) {
|
|
7086
|
+
this.metricsAcc.anchorUserSpeechStart();
|
|
7087
|
+
}
|
|
6194
7088
|
this.currentAgentText = "";
|
|
6195
7089
|
this.responseAudioStarted = false;
|
|
6196
7090
|
this.pendingAssistantTurn = null;
|
|
@@ -6200,6 +7094,28 @@ var StreamHandler = class _StreamHandler {
|
|
|
6200
7094
|
}
|
|
6201
7095
|
this.userTranscriptPending = false;
|
|
6202
7096
|
}
|
|
7097
|
+
/**
|
|
7098
|
+
* Handle a Realtime ``error`` event (issue #154, fix 4).
|
|
7099
|
+
*
|
|
7100
|
+
* Both Realtime providers dispatch ``('error', …)`` for server-side errors,
|
|
7101
|
+
* non-normal socket closes, and socket errors, but the stream handler
|
|
7102
|
+
* previously had no entry for it in the dispatch table so these were
|
|
7103
|
+
* silently swallowed. We surface them at WARN level with ONLY the error
|
|
7104
|
+
* envelope fields (``type`` / ``code`` / ``message``) — never any audio or
|
|
7105
|
+
* transcript body, to avoid logging PII. The call is NOT terminated: the
|
|
7106
|
+
* provider decides whether to recover, and many of these (e.g. a transient
|
|
7107
|
+
* ``input_audio_buffer_commit_empty``) are non-fatal. Parity with the
|
|
7108
|
+
* Python ``elif ev_type == 'error'`` branches.
|
|
7109
|
+
*/
|
|
7110
|
+
async onAdapterError(eventData) {
|
|
7111
|
+
const err = eventData ?? {};
|
|
7112
|
+
const type = typeof err.type === "string" ? err.type : "unknown";
|
|
7113
|
+
const code = typeof err.code === "string" ? err.code : "";
|
|
7114
|
+
const message = typeof err.message === "string" ? err.message : "";
|
|
7115
|
+
getLogger().warn(
|
|
7116
|
+
`Realtime error (${this.deps.bridge.label}) type=${type} code=${code} message=${sanitizeLogValue(message)}`
|
|
7117
|
+
);
|
|
7118
|
+
}
|
|
6203
7119
|
/**
|
|
6204
7120
|
* Emit a tool-invocation event into the transcript timeline. Pushes a
|
|
6205
7121
|
* `role=tool` entry into `history` (so it appears in the dashboard
|
|
@@ -6267,7 +7183,8 @@ var StreamHandler = class _StreamHandler {
|
|
|
6267
7183
|
}
|
|
6268
7184
|
return;
|
|
6269
7185
|
}
|
|
6270
|
-
const
|
|
7186
|
+
const effectiveTools = this.resolvedTools ?? this.deps.agent.tools;
|
|
7187
|
+
const toolDef = effectiveTools?.find((t) => t.name === fc.name);
|
|
6271
7188
|
if (!toolDef) {
|
|
6272
7189
|
getLogger().warn(`Realtime tool '${fc.name}' not found in agent.tools \u2014 skipping`);
|
|
6273
7190
|
const result2 = JSON.stringify({ error: `Tool '${fc.name}' not registered`, fallback: true });
|
|
@@ -6290,7 +7207,8 @@ var StreamHandler = class _StreamHandler {
|
|
|
6290
7207
|
if (msg && this.adapter instanceof OpenAIRealtimeAdapter) {
|
|
6291
7208
|
const realtimeAdapter = this.adapter;
|
|
6292
7209
|
reassuranceTimer = setTimeout(() => {
|
|
6293
|
-
realtimeAdapter.
|
|
7210
|
+
const fire = typeof realtimeAdapter.sendReassurance === "function" ? realtimeAdapter.sendReassurance(msg) : realtimeAdapter.sendText(msg);
|
|
7211
|
+
fire.catch((e) => {
|
|
6294
7212
|
getLogger().warn(`Reassurance message failed for tool '${fc.name}': ${String(e)}`);
|
|
6295
7213
|
});
|
|
6296
7214
|
}, afterMs);
|
|
@@ -6310,7 +7228,8 @@ var StreamHandler = class _StreamHandler {
|
|
|
6310
7228
|
parsedArgs,
|
|
6311
7229
|
{
|
|
6312
7230
|
call_id: this.callId,
|
|
6313
|
-
caller: this.caller
|
|
7231
|
+
caller: this.caller,
|
|
7232
|
+
callee: this.callee
|
|
6314
7233
|
},
|
|
6315
7234
|
onProgress
|
|
6316
7235
|
);
|
|
@@ -6418,7 +7337,7 @@ async function queryDeepgramCost(metricsAcc, deepgramKey, deepgramRequestId) {
|
|
|
6418
7337
|
|
|
6419
7338
|
// src/services/call-log.ts
|
|
6420
7339
|
init_esm_shims();
|
|
6421
|
-
import * as
|
|
7340
|
+
import * as crypto4 from "crypto";
|
|
6422
7341
|
import * as fs3 from "fs";
|
|
6423
7342
|
import { promises as fsp } from "fs";
|
|
6424
7343
|
import * as os from "os";
|
|
@@ -6463,7 +7382,7 @@ function redactPhone(raw) {
|
|
|
6463
7382
|
const mode = redactMode();
|
|
6464
7383
|
if (mode === "full") return raw;
|
|
6465
7384
|
if (mode === "hash_only") {
|
|
6466
|
-
return "sha256:" +
|
|
7385
|
+
return "sha256:" + crypto4.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 16);
|
|
6467
7386
|
}
|
|
6468
7387
|
return maskPhoneNumber(raw);
|
|
6469
7388
|
}
|
|
@@ -6474,7 +7393,7 @@ function utcIso(tsSeconds) {
|
|
|
6474
7393
|
async function atomicWriteJson(filePath, payload) {
|
|
6475
7394
|
const dir = path3.dirname(filePath);
|
|
6476
7395
|
await fsp.mkdir(dir, { recursive: true });
|
|
6477
|
-
const tmp = path3.join(dir, `.tmp.${process.pid}.${
|
|
7396
|
+
const tmp = path3.join(dir, `.tmp.${process.pid}.${crypto4.randomBytes(4).toString("hex")}.json`);
|
|
6478
7397
|
try {
|
|
6479
7398
|
const handle = await fsp.open(tmp, "w");
|
|
6480
7399
|
try {
|
|
@@ -6559,8 +7478,10 @@ var CallLogger = class {
|
|
|
6559
7478
|
} catch (err) {
|
|
6560
7479
|
getLogger().warn(`call_log write failed (${sanitizeLogValue(callId)}): ${sanitizeLogValue(String(err))}`);
|
|
6561
7480
|
}
|
|
6562
|
-
if (
|
|
6563
|
-
this.sweepOldDays()
|
|
7481
|
+
if (crypto4.randomBytes(1)[0] < 5) {
|
|
7482
|
+
void this.sweepOldDays().catch(
|
|
7483
|
+
(e) => getLogger().debug(`call_log sweep failed: ${sanitizeLogValue(String(e))}`)
|
|
7484
|
+
);
|
|
6564
7485
|
}
|
|
6565
7486
|
}
|
|
6566
7487
|
/** Append a single turn record to the call's `transcript.jsonl`. */
|
|
@@ -6635,23 +7556,27 @@ var CallLogger = class {
|
|
|
6635
7556
|
}
|
|
6636
7557
|
}
|
|
6637
7558
|
// --- Retention ---------------------------------------------------------
|
|
6638
|
-
sweepOldDays() {
|
|
7559
|
+
async sweepOldDays() {
|
|
6639
7560
|
if (this.root === null) return;
|
|
6640
7561
|
const days = retentionDays();
|
|
6641
7562
|
if (days === 0) return;
|
|
6642
7563
|
const cutoff = Date.now() / 1e3 - days * 86400;
|
|
6643
7564
|
const callsRoot = path3.join(this.root, "calls");
|
|
6644
|
-
if (!fs3.existsSync(callsRoot)) return;
|
|
6645
7565
|
try {
|
|
6646
|
-
|
|
7566
|
+
await fsp.access(callsRoot);
|
|
7567
|
+
} catch {
|
|
7568
|
+
return;
|
|
7569
|
+
}
|
|
7570
|
+
try {
|
|
7571
|
+
for (const yearName of await fsp.readdir(callsRoot)) {
|
|
6647
7572
|
if (!/^\d+$/.test(yearName)) continue;
|
|
6648
7573
|
const yearDir = path3.join(callsRoot, yearName);
|
|
6649
|
-
if (!
|
|
6650
|
-
for (const monthName of
|
|
7574
|
+
if (!(await fsp.stat(yearDir)).isDirectory()) continue;
|
|
7575
|
+
for (const monthName of await fsp.readdir(yearDir)) {
|
|
6651
7576
|
if (!/^\d+$/.test(monthName)) continue;
|
|
6652
7577
|
const monthDir = path3.join(yearDir, monthName);
|
|
6653
|
-
if (!
|
|
6654
|
-
for (const dayName of
|
|
7578
|
+
if (!(await fsp.stat(monthDir)).isDirectory()) continue;
|
|
7579
|
+
for (const dayName of await fsp.readdir(monthDir)) {
|
|
6655
7580
|
if (!/^\d+$/.test(dayName)) continue;
|
|
6656
7581
|
const dayDir = path3.join(monthDir, dayName);
|
|
6657
7582
|
const y = Number.parseInt(yearName, 10);
|
|
@@ -6659,16 +7584,16 @@ var CallLogger = class {
|
|
|
6659
7584
|
const d = Number.parseInt(dayName, 10);
|
|
6660
7585
|
const ts = Date.UTC(y, m - 1, d) / 1e3;
|
|
6661
7586
|
if (ts < cutoff) {
|
|
6662
|
-
|
|
7587
|
+
await rmTreeAsync(dayDir);
|
|
6663
7588
|
}
|
|
6664
7589
|
}
|
|
6665
7590
|
try {
|
|
6666
|
-
if (
|
|
7591
|
+
if ((await fsp.readdir(monthDir)).length === 0) await fsp.rmdir(monthDir);
|
|
6667
7592
|
} catch {
|
|
6668
7593
|
}
|
|
6669
7594
|
}
|
|
6670
7595
|
try {
|
|
6671
|
-
if (
|
|
7596
|
+
if ((await fsp.readdir(yearDir)).length === 0) await fsp.rmdir(yearDir);
|
|
6672
7597
|
} catch {
|
|
6673
7598
|
}
|
|
6674
7599
|
}
|
|
@@ -6677,21 +7602,21 @@ var CallLogger = class {
|
|
|
6677
7602
|
}
|
|
6678
7603
|
}
|
|
6679
7604
|
};
|
|
6680
|
-
function
|
|
7605
|
+
async function rmTreeAsync(target) {
|
|
6681
7606
|
try {
|
|
6682
|
-
for (const child of
|
|
7607
|
+
for (const child of await fsp.readdir(target)) {
|
|
6683
7608
|
const childPath = path3.join(target, child);
|
|
6684
|
-
const stat =
|
|
7609
|
+
const stat = await fsp.lstat(childPath);
|
|
6685
7610
|
if (stat.isDirectory()) {
|
|
6686
|
-
|
|
7611
|
+
await rmTreeAsync(childPath);
|
|
6687
7612
|
} else {
|
|
6688
7613
|
try {
|
|
6689
|
-
|
|
7614
|
+
await fsp.unlink(childPath);
|
|
6690
7615
|
} catch {
|
|
6691
7616
|
}
|
|
6692
7617
|
}
|
|
6693
7618
|
}
|
|
6694
|
-
|
|
7619
|
+
await fsp.rmdir(target);
|
|
6695
7620
|
} catch {
|
|
6696
7621
|
}
|
|
6697
7622
|
}
|
|
@@ -6739,13 +7664,29 @@ function classifyTelnyxAmd(result) {
|
|
|
6739
7664
|
if (result === "fax") return "fax";
|
|
6740
7665
|
return "unknown";
|
|
6741
7666
|
}
|
|
6742
|
-
function
|
|
7667
|
+
function twilioStatusToOutcome(callStatus) {
|
|
7668
|
+
const s = (callStatus || "").toLowerCase();
|
|
7669
|
+
if (s === "no-answer") return "no_answer";
|
|
7670
|
+
if (s === "busy") return "busy";
|
|
7671
|
+
return "failed";
|
|
7672
|
+
}
|
|
7673
|
+
function telnyxHangupOutcome(cause) {
|
|
7674
|
+
const c = (cause || "").toLowerCase();
|
|
7675
|
+
if (c === "no_answer" || c === "timeout" || c === "no_user_response") return "no_answer";
|
|
7676
|
+
if (c === "user_busy" || c === "busy") return "busy";
|
|
7677
|
+
if (c === "call_rejected" || c === "rejected" || c === "destination_out_of_order") return "failed";
|
|
7678
|
+
return null;
|
|
7679
|
+
}
|
|
7680
|
+
function validateWebhookUrl(url, allowLoopback = false) {
|
|
6743
7681
|
const parsed = new URL(url);
|
|
6744
7682
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
6745
7683
|
throw new Error(`Invalid webhook URL scheme: ${parsed.protocol}`);
|
|
6746
7684
|
}
|
|
6747
7685
|
const rawHost = parsed.hostname;
|
|
6748
7686
|
const host = rawHost.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
7687
|
+
if (allowLoopback) {
|
|
7688
|
+
return;
|
|
7689
|
+
}
|
|
6749
7690
|
const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
6750
7691
|
"localhost",
|
|
6751
7692
|
"ip6-localhost",
|
|
@@ -6787,6 +7728,34 @@ function validateWebhookUrl(url) {
|
|
|
6787
7728
|
}
|
|
6788
7729
|
}
|
|
6789
7730
|
}
|
|
7731
|
+
function extractHost(value) {
|
|
7732
|
+
const trimmed = value.trim();
|
|
7733
|
+
if (!trimmed) return "";
|
|
7734
|
+
let host = trimmed.replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "");
|
|
7735
|
+
if (host.startsWith("[")) {
|
|
7736
|
+
return host.slice(1).split("]", 1)[0].toLowerCase();
|
|
7737
|
+
}
|
|
7738
|
+
if (!host.includes("::")) {
|
|
7739
|
+
const lastColon = host.lastIndexOf(":");
|
|
7740
|
+
if (lastColon !== -1 && /^\d+$/.test(host.slice(lastColon + 1))) {
|
|
7741
|
+
host = host.slice(0, lastColon);
|
|
7742
|
+
}
|
|
7743
|
+
}
|
|
7744
|
+
return host.toLowerCase();
|
|
7745
|
+
}
|
|
7746
|
+
function isLoopbackHost(value) {
|
|
7747
|
+
const host = extractHost(value);
|
|
7748
|
+
if (!host) return false;
|
|
7749
|
+
if (host === "localhost" || host === "ip6-localhost" || host === "ip6-loopback") {
|
|
7750
|
+
return true;
|
|
7751
|
+
}
|
|
7752
|
+
if (host === "::1" || host === "::ffff:127.0.0.1") return true;
|
|
7753
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
7754
|
+
if (v4) {
|
|
7755
|
+
return parseInt(v4[1], 10) === 127;
|
|
7756
|
+
}
|
|
7757
|
+
return false;
|
|
7758
|
+
}
|
|
6790
7759
|
function validateTelnyxSignature(rawBody, signature, timestamp, publicKey, toleranceSec = 300) {
|
|
6791
7760
|
try {
|
|
6792
7761
|
const ts = parseInt(timestamp, 10);
|
|
@@ -6796,7 +7765,7 @@ function validateTelnyxSignature(rawBody, signature, timestamp, publicKey, toler
|
|
|
6796
7765
|
if (ageMs < 0 || ageMs > toleranceSec * 1e3) return false;
|
|
6797
7766
|
const payload = `${timestamp}|${rawBody}`;
|
|
6798
7767
|
const keyBuffer = Buffer.from(publicKey, "base64");
|
|
6799
|
-
const keyObject =
|
|
7768
|
+
const keyObject = crypto5.createPublicKey({
|
|
6800
7769
|
key: keyBuffer,
|
|
6801
7770
|
format: "der",
|
|
6802
7771
|
type: "spki"
|
|
@@ -6806,7 +7775,7 @@ function validateTelnyxSignature(rawBody, signature, timestamp, publicKey, toler
|
|
|
6806
7775
|
if (!trimmed) continue;
|
|
6807
7776
|
try {
|
|
6808
7777
|
const sigBuffer = Buffer.from(trimmed, "base64");
|
|
6809
|
-
if (
|
|
7778
|
+
if (crypto5.verify(null, Buffer.from(payload), keyObject, sigBuffer)) {
|
|
6810
7779
|
return true;
|
|
6811
7780
|
}
|
|
6812
7781
|
} catch {
|
|
@@ -6823,12 +7792,12 @@ function validateTwilioSid(sid, prefix = "CA") {
|
|
|
6823
7792
|
}
|
|
6824
7793
|
function validateTwilioSignature(url, params, signature, authToken) {
|
|
6825
7794
|
const data = url + Object.keys(params).sort().reduce((acc, key) => acc + key + (params[key] ?? ""), "");
|
|
6826
|
-
const expected =
|
|
7795
|
+
const expected = crypto5.createHmac("sha1", authToken).update(data).digest("base64");
|
|
6827
7796
|
try {
|
|
6828
7797
|
const sigBuf = Buffer.from(signature);
|
|
6829
7798
|
const expBuf = Buffer.from(expected);
|
|
6830
7799
|
if (sigBuf.length !== expBuf.length) return false;
|
|
6831
|
-
return
|
|
7800
|
+
return crypto5.timingSafeEqual(sigBuf, expBuf);
|
|
6832
7801
|
} catch {
|
|
6833
7802
|
return false;
|
|
6834
7803
|
}
|
|
@@ -6850,7 +7819,7 @@ function resolveVariables(template, variables) {
|
|
|
6850
7819
|
}
|
|
6851
7820
|
return result;
|
|
6852
7821
|
}
|
|
6853
|
-
function buildAIAdapter(config, agent, resolvedPrompt) {
|
|
7822
|
+
function buildAIAdapter(config, agent, resolvedPrompt, toolsOverride) {
|
|
6854
7823
|
const engine = agent.engine;
|
|
6855
7824
|
if (agent.provider === "elevenlabs_convai") {
|
|
6856
7825
|
if (!engine || engine.kind !== "elevenlabs_convai") {
|
|
@@ -6865,12 +7834,24 @@ function buildAIAdapter(config, agent, resolvedPrompt) {
|
|
|
6865
7834
|
agent.firstMessage ?? ""
|
|
6866
7835
|
);
|
|
6867
7836
|
}
|
|
6868
|
-
const
|
|
6869
|
-
|
|
6870
|
-
description
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
7837
|
+
const preamblesOn = Boolean(agent.toolCallPreambles);
|
|
7838
|
+
const agentTools = (toolsOverride ?? agent.tools)?.map((t) => {
|
|
7839
|
+
let description = t.description;
|
|
7840
|
+
const reassurance = t.reassurance;
|
|
7841
|
+
const sample = typeof reassurance === "string" ? reassurance : void 0;
|
|
7842
|
+
if (preamblesOn && sample) {
|
|
7843
|
+
description = `${description}
|
|
7844
|
+
|
|
7845
|
+
Preamble sample phrases:
|
|
7846
|
+
- ${sample}`;
|
|
7847
|
+
}
|
|
7848
|
+
return {
|
|
7849
|
+
name: t.name,
|
|
7850
|
+
description,
|
|
7851
|
+
parameters: t.parameters,
|
|
7852
|
+
strict: t.strict
|
|
7853
|
+
};
|
|
7854
|
+
}) ?? [];
|
|
6874
7855
|
const tools = [...agentTools, TRANSFER_CALL_TOOL, END_CALL_TOOL];
|
|
6875
7856
|
const isOpenAIEngine = engine && (engine.kind === "openai_realtime" || engine.kind === "openai_realtime_2");
|
|
6876
7857
|
const openaiKey = isOpenAIEngine ? engine.apiKey : config.openaiKey ?? "";
|
|
@@ -6882,8 +7863,27 @@ function buildAIAdapter(config, agent, resolvedPrompt) {
|
|
|
6882
7863
|
if (engine.inputAudioTranscriptionModel !== void 0) {
|
|
6883
7864
|
adapterOptions.inputAudioTranscriptionModel = engine.inputAudioTranscriptionModel;
|
|
6884
7865
|
}
|
|
7866
|
+
if (engine.noiseReduction !== void 0) {
|
|
7867
|
+
adapterOptions.noiseReduction = engine.noiseReduction;
|
|
7868
|
+
}
|
|
7869
|
+
if (engine.turnDetection !== void 0) {
|
|
7870
|
+
adapterOptions.turnDetection = engine.turnDetection;
|
|
7871
|
+
}
|
|
7872
|
+
if (engine.gateResponseOnTranscript !== void 0) {
|
|
7873
|
+
adapterOptions.gateResponseOnTranscript = engine.gateResponseOnTranscript;
|
|
7874
|
+
}
|
|
7875
|
+
}
|
|
7876
|
+
const agentOpts = agent;
|
|
7877
|
+
if (agentOpts.openaiRealtimeNoiseReduction !== void 0) {
|
|
7878
|
+
adapterOptions.noiseReduction = agentOpts.openaiRealtimeNoiseReduction;
|
|
7879
|
+
}
|
|
7880
|
+
if (agentOpts.realtimeTurnDetection !== void 0) {
|
|
7881
|
+
adapterOptions.turnDetection = agentOpts.realtimeTurnDetection;
|
|
7882
|
+
}
|
|
7883
|
+
if (agentOpts.openaiRealtimeGateResponseOnTranscript !== void 0) {
|
|
7884
|
+
adapterOptions.gateResponseOnTranscript = agentOpts.openaiRealtimeGateResponseOnTranscript;
|
|
6885
7885
|
}
|
|
6886
|
-
const AdapterCtor =
|
|
7886
|
+
const AdapterCtor = OpenAIRealtime2Adapter;
|
|
6887
7887
|
return new AdapterCtor(
|
|
6888
7888
|
openaiKey,
|
|
6889
7889
|
agent.model,
|
|
@@ -6901,6 +7901,7 @@ var TwilioBridge = class {
|
|
|
6901
7901
|
config;
|
|
6902
7902
|
label = "Twilio";
|
|
6903
7903
|
telephonyProvider = "twilio";
|
|
7904
|
+
inputWireFormat = "ulaw_8000";
|
|
6904
7905
|
sendAudio(ws, audioBase64, streamSid) {
|
|
6905
7906
|
ws.send(JSON.stringify({ event: "media", streamSid, media: { payload: audioBase64 } }));
|
|
6906
7907
|
}
|
|
@@ -6916,6 +7917,11 @@ var TwilioBridge = class {
|
|
|
6916
7917
|
getLogger().warn(`TwilioBridge.transferCall rejected: invalid CallSid ${JSON.stringify(callId)}`);
|
|
6917
7918
|
return;
|
|
6918
7919
|
}
|
|
7920
|
+
const E164_RE = /^\+[1-9]\d{6,14}$/;
|
|
7921
|
+
if (!E164_RE.test(toNumber)) {
|
|
7922
|
+
getLogger().warn(`TwilioBridge.transferCall rejected: invalid target ${JSON.stringify(toNumber)}`);
|
|
7923
|
+
return;
|
|
7924
|
+
}
|
|
6919
7925
|
const transferUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.config.twilioSid}/Calls/${callId}.json`;
|
|
6920
7926
|
await fetch(transferUrl, {
|
|
6921
7927
|
method: "POST",
|
|
@@ -6997,6 +8003,11 @@ var TelnyxBridge = class {
|
|
|
6997
8003
|
config;
|
|
6998
8004
|
label = "Telnyx";
|
|
6999
8005
|
telephonyProvider = "telnyx";
|
|
8006
|
+
// ``streaming_start`` negotiates PCMU bidirectional by default — keeping
|
|
8007
|
+
// ``ulaw_8000`` here matches what TwilioBridge does and keeps the stream
|
|
8008
|
+
// handler's input-transcode branch in the right shape. If a deployment
|
|
8009
|
+
// overrides the negotiation to L16, this should flip to ``pcm_16000``.
|
|
8010
|
+
inputWireFormat = "ulaw_8000";
|
|
7000
8011
|
sendAudio(ws, audioBase64, _streamSid) {
|
|
7001
8012
|
ws.send(JSON.stringify({ event: "media", media: { payload: audioBase64 } }));
|
|
7002
8013
|
}
|
|
@@ -7018,7 +8029,7 @@ var TelnyxBridge = class {
|
|
|
7018
8029
|
});
|
|
7019
8030
|
getLogger().info(`Telnyx call transferred to ${toNumber}`);
|
|
7020
8031
|
}
|
|
7021
|
-
async sendDtmf(callId, digits, delayMs) {
|
|
8032
|
+
async sendDtmf(_ws, callId, digits, delayMs) {
|
|
7022
8033
|
if (!digits) {
|
|
7023
8034
|
getLogger().warn("TelnyxBridge.sendDtmf called with empty digits");
|
|
7024
8035
|
return;
|
|
@@ -7126,7 +8137,7 @@ var TelnyxBridge = class {
|
|
|
7126
8137
|
};
|
|
7127
8138
|
var GRACEFUL_SHUTDOWN_TIMEOUT_MS = 1e4;
|
|
7128
8139
|
var EmbeddedServer = class {
|
|
7129
|
-
constructor(config, agent, onCallStart, onCallEnd, onTranscript, onMessage, recording = false, voicemailMessage = "", onMetrics, pricingOverrides, dashboard = true, dashboardToken = "") {
|
|
8140
|
+
constructor(config, agent, onCallStart, onCallEnd, onTranscript, onMessage, recording = false, voicemailMessage = "", onMetrics, pricingOverrides, dashboard = true, dashboardToken = "", allowInsecureDashboard = false) {
|
|
7130
8141
|
this.config = config;
|
|
7131
8142
|
this.agent = agent;
|
|
7132
8143
|
this.onCallStart = onCallStart;
|
|
@@ -7138,6 +8149,7 @@ var EmbeddedServer = class {
|
|
|
7138
8149
|
this.onMetrics = onMetrics;
|
|
7139
8150
|
this.dashboard = dashboard;
|
|
7140
8151
|
this.dashboardToken = dashboardToken;
|
|
8152
|
+
this.allowInsecureDashboard = allowInsecureDashboard;
|
|
7141
8153
|
this.metricsStore = new MetricsStore();
|
|
7142
8154
|
this.pricing = mergePricing(pricingOverrides);
|
|
7143
8155
|
const logRoot = config.persistRoot === void 0 ? resolveLogRoot() : config.persistRoot;
|
|
@@ -7164,8 +8176,31 @@ var EmbeddedServer = class {
|
|
|
7164
8176
|
onMetrics;
|
|
7165
8177
|
dashboard;
|
|
7166
8178
|
dashboardToken;
|
|
8179
|
+
allowInsecureDashboard;
|
|
7167
8180
|
server = null;
|
|
7168
8181
|
wss = null;
|
|
8182
|
+
/**
|
|
8183
|
+
* Whether the dashboard + ``/api/*`` routes were mounted in ``start()``.
|
|
8184
|
+
* The dashboard is now ALWAYS mounted when enabled (it never 404s): an
|
|
8185
|
+
* exposed, token-less bind is protected with an auto-generated token
|
|
8186
|
+
* rather than refused. This flag is therefore ``true`` whenever the
|
|
8187
|
+
* dashboard is enabled — kept so the startup banner can gate on it.
|
|
8188
|
+
*/
|
|
8189
|
+
dashboardMounted = false;
|
|
8190
|
+
/**
|
|
8191
|
+
* The token actually in effect for the dashboard + ``/api/*`` routes,
|
|
8192
|
+
* resolved in ``start()``. One of: the explicit ``dashboardToken`` if set;
|
|
8193
|
+
* a freshly generated UUID when the bind is exposed and
|
|
8194
|
+
* ``allowInsecureDashboard`` is ``false``; or ``''`` (OPEN) for loopback
|
|
8195
|
+
* local dev and for an exposed bind with ``allowInsecureDashboard=true``.
|
|
8196
|
+
* Read by the startup banner (to print the ready URL with ``?token=``) and
|
|
8197
|
+
* by authentic tests (to authenticate).
|
|
8198
|
+
*/
|
|
8199
|
+
effectiveDashboardToken = "";
|
|
8200
|
+
/** The token in effect for the dashboard, resolved at ``start()``. Empty string = served OPEN. */
|
|
8201
|
+
get resolvedDashboardToken() {
|
|
8202
|
+
return this.effectiveDashboardToken;
|
|
8203
|
+
}
|
|
7169
8204
|
twilioTokenWarningLogged = false;
|
|
7170
8205
|
telnyxSigWarningLogged = false;
|
|
7171
8206
|
metricsStore;
|
|
@@ -7183,12 +8218,14 @@ var EmbeddedServer = class {
|
|
|
7183
8218
|
activeConnections = /* @__PURE__ */ new Set();
|
|
7184
8219
|
activeCallIds = /* @__PURE__ */ new Map();
|
|
7185
8220
|
/**
|
|
7186
|
-
* Per-call AMD result
|
|
7187
|
-
*
|
|
7188
|
-
*
|
|
7189
|
-
*
|
|
8221
|
+
* Per-call AMD result callbacks keyed by CallSid / call_control_id.
|
|
8222
|
+
* Public so ``client.ts`` can register a callback per outbound call.
|
|
8223
|
+
* The Map slot is deleted after the callback fires once — preventing
|
|
8224
|
+
* cross-call misfires when multiple concurrent outbound calls are in
|
|
8225
|
+
* flight (single-slot was a race condition: the last registered callback
|
|
8226
|
+
* would win for every in-flight AMD result).
|
|
7190
8227
|
*/
|
|
7191
|
-
|
|
8228
|
+
onMachineDetectionByCallSid = /* @__PURE__ */ new Map();
|
|
7192
8229
|
/**
|
|
7193
8230
|
* Pre-warm first-message audio accessor wired by ``Patter.serve()``.
|
|
7194
8231
|
* The per-call StreamHandler invokes this with its ``callId`` at the
|
|
@@ -7216,6 +8253,135 @@ var EmbeddedServer = class {
|
|
|
7216
8253
|
* (tests) work without further setup. See FIX #91.
|
|
7217
8254
|
*/
|
|
7218
8255
|
recordPrewarmWaste = () => void 0;
|
|
8256
|
+
/**
|
|
8257
|
+
* Per-callId completion deferreds for ``Patter.call({ wait: true })``.
|
|
8258
|
+
* Resolved by the FIRST terminal signal: the Twilio/Telnyx status callback
|
|
8259
|
+
* for no-media outcomes (no-answer / busy / failed), or ``onCallEnd`` for a
|
|
8260
|
+
* connected call (answered / voicemail). The AMD classification is recorded
|
|
8261
|
+
* per callId so the connected-call path can distinguish ``answered`` from
|
|
8262
|
+
* ``voicemail``. This is what lets ``call({ wait: true })`` resolve to a
|
|
8263
|
+
* structured {@link CallResult} without the caller hand-wiring ``onCallEnd``
|
|
8264
|
+
* to a promise. Public so ``client.ts`` can register/await + fail in-flight
|
|
8265
|
+
* waiters on ``disconnect()``. Mirrors Python's ``EmbeddedServer._completions``.
|
|
8266
|
+
*/
|
|
8267
|
+
completions = /* @__PURE__ */ new Map();
|
|
8268
|
+
/** AMD classification recorded per callId, used by the connected-call path. */
|
|
8269
|
+
amdClass = /* @__PURE__ */ new Map();
|
|
8270
|
+
// === Outbound completion registry (call({ wait: true })) ===
|
|
8271
|
+
/**
|
|
8272
|
+
* Register (or return) a completion promise for an outbound call.
|
|
8273
|
+
*
|
|
8274
|
+
* Called by ``Patter.call({ wait: true })`` immediately after the carrier
|
|
8275
|
+
* accepts the dial — the promise resolves to a {@link CallResult} once a
|
|
8276
|
+
* terminal signal arrives. Idempotent: returns the existing pending promise
|
|
8277
|
+
* if one is already registered for ``callId``. Mirrors Python's
|
|
8278
|
+
* ``register_completion``.
|
|
8279
|
+
*/
|
|
8280
|
+
registerCompletion(callId) {
|
|
8281
|
+
const existing = this.completions.get(callId);
|
|
8282
|
+
if (existing && !existing.done) {
|
|
8283
|
+
return existing.promise;
|
|
8284
|
+
}
|
|
8285
|
+
let resolve2;
|
|
8286
|
+
let reject;
|
|
8287
|
+
const promise = new Promise((res, rej) => {
|
|
8288
|
+
resolve2 = res;
|
|
8289
|
+
reject = rej;
|
|
8290
|
+
});
|
|
8291
|
+
this.completions.set(callId, { promise, resolve: resolve2, reject, done: false });
|
|
8292
|
+
return promise;
|
|
8293
|
+
}
|
|
8294
|
+
/** Drop a registered completion (e.g. on a backstop timeout) without resolving it. */
|
|
8295
|
+
deleteCompletion(callId) {
|
|
8296
|
+
this.completions.delete(callId);
|
|
8297
|
+
this.amdClass.delete(callId);
|
|
8298
|
+
}
|
|
8299
|
+
/**
|
|
8300
|
+
* Resolve a pending completion with a {@link CallResult}.
|
|
8301
|
+
*
|
|
8302
|
+
* No-op when no completion is registered for ``callId`` (the common case —
|
|
8303
|
+
* most calls are placed without ``wait: true``) or it is already done.
|
|
8304
|
+
* Builds the result from the ``onCallEnd`` payload when ``data`` is provided
|
|
8305
|
+
* (connected calls carry transcript + {@link CallMetrics}); no-media
|
|
8306
|
+
* outcomes pass ``data`` undefined and yield an empty transcript / no cost.
|
|
8307
|
+
* Mirrors Python's ``_resolve_completion``.
|
|
8308
|
+
*/
|
|
8309
|
+
resolveCompletion(callId, args) {
|
|
8310
|
+
const entry = this.completions.get(callId);
|
|
8311
|
+
if (!entry || entry.done) return;
|
|
8312
|
+
const data = args.data;
|
|
8313
|
+
const metrics = data?.metrics ?? null;
|
|
8314
|
+
const cost = metrics?.cost ?? null;
|
|
8315
|
+
const durationRaw = metrics?.duration_seconds;
|
|
8316
|
+
const duration = typeof durationRaw === "number" ? durationRaw : 0;
|
|
8317
|
+
const transcriptRaw = data?.transcript;
|
|
8318
|
+
const transcript = Array.isArray(transcriptRaw) ? transcriptRaw : [];
|
|
8319
|
+
const result = {
|
|
8320
|
+
callId,
|
|
8321
|
+
outcome: args.outcome,
|
|
8322
|
+
status: args.status,
|
|
8323
|
+
durationSeconds: duration,
|
|
8324
|
+
transcript,
|
|
8325
|
+
cost,
|
|
8326
|
+
metrics
|
|
8327
|
+
};
|
|
8328
|
+
entry.done = true;
|
|
8329
|
+
entry.resolve(result);
|
|
8330
|
+
this.completions.delete(callId);
|
|
8331
|
+
this.amdClass.delete(callId);
|
|
8332
|
+
}
|
|
8333
|
+
/**
|
|
8334
|
+
* Fail every in-flight completion with ``error``. Called by
|
|
8335
|
+
* ``Patter.disconnect()`` so a ``call({ wait: true })`` awaiter does not
|
|
8336
|
+
* hang until its backstop timeout once the server is gone. Mirrors the
|
|
8337
|
+
* Python ``disconnect()`` change that fails in-flight ``wait=True`` awaiters.
|
|
8338
|
+
*/
|
|
8339
|
+
failPendingCompletions(error) {
|
|
8340
|
+
for (const entry of this.completions.values()) {
|
|
8341
|
+
if (!entry.done) {
|
|
8342
|
+
entry.done = true;
|
|
8343
|
+
entry.reject(error);
|
|
8344
|
+
}
|
|
8345
|
+
}
|
|
8346
|
+
this.completions.clear();
|
|
8347
|
+
this.amdClass.clear();
|
|
8348
|
+
}
|
|
8349
|
+
/**
|
|
8350
|
+
* Decide whether this server is reachable beyond loopback (127.0.0.1).
|
|
8351
|
+
*
|
|
8352
|
+
* The dashboard serves call transcripts and metadata (PII), so before
|
|
8353
|
+
* mounting it unauthenticated we must know whether anyone off-host can
|
|
8354
|
+
* reach the port. Signals (in order):
|
|
8355
|
+
*
|
|
8356
|
+
* (a)+(b) — a public webhook URL. ``client.ts`` resolves
|
|
8357
|
+
* ``config.webhookUrl`` to the live hostname for every serve path:
|
|
8358
|
+
* a cloudflared quick-tunnel host, a {@link StaticTunnel} hostname,
|
|
8359
|
+
* or an explicit ``webhookUrl``. A tunnel directive (signal a) and a
|
|
8360
|
+
* public webhook URL (signal b) therefore both surface here as a
|
|
8361
|
+
* non-loopback, non-private webhook host. This is the case that
|
|
8362
|
+
* matters for tunnels — the whole port (dashboard included) is
|
|
8363
|
+
* published on a public ``*.trycloudflare.com`` URL.
|
|
8364
|
+
*
|
|
8365
|
+
* (c) — an EXPLICIT non-loopback bind override via ``PATTER_BIND_HOST``.
|
|
8366
|
+
* Node's ``http.Server.listen(port, host)`` defaults to 127.0.0.1
|
|
8367
|
+
* here (see ``start()``), so plain local dev is never flagged; only
|
|
8368
|
+
* an operator who set ``PATTER_BIND_HOST`` to e.g. ``0.0.0.0`` is.
|
|
8369
|
+
*
|
|
8370
|
+
* Only loopback webhook hosts (127.0.0.0/8, localhost, ::1) are treated as
|
|
8371
|
+
* not-exposed. RFC1918 / LAN hosts ARE exposure — they are reachable by
|
|
8372
|
+
* other machines on the network — matching the Python SDK's gate.
|
|
8373
|
+
*/
|
|
8374
|
+
isExposed() {
|
|
8375
|
+
const bindOverride = process.env.PATTER_BIND_HOST;
|
|
8376
|
+
if (bindOverride && !isLoopbackHost(bindOverride)) {
|
|
8377
|
+
return true;
|
|
8378
|
+
}
|
|
8379
|
+
const host = extractHost(this.config.webhookUrl ?? "");
|
|
8380
|
+
if (host && !isLoopbackHost(host)) {
|
|
8381
|
+
return true;
|
|
8382
|
+
}
|
|
8383
|
+
return false;
|
|
8384
|
+
}
|
|
7219
8385
|
/** Bind HTTP + WebSocket listeners on `port`, mount carrier webhooks and dashboard routes. */
|
|
7220
8386
|
async start(port = 8e3) {
|
|
7221
8387
|
const webhookUrlPattern = /^[a-zA-Z0-9][a-zA-Z0-9.\-]+[a-zA-Z0-9]$/;
|
|
@@ -7251,6 +8417,9 @@ var EmbeddedServer = class {
|
|
|
7251
8417
|
}
|
|
7252
8418
|
next();
|
|
7253
8419
|
});
|
|
8420
|
+
req.on("error", (err) => {
|
|
8421
|
+
next(err);
|
|
8422
|
+
});
|
|
7254
8423
|
} else {
|
|
7255
8424
|
next();
|
|
7256
8425
|
}
|
|
@@ -7261,8 +8430,25 @@ var EmbeddedServer = class {
|
|
|
7261
8430
|
res.json({ status: "ok", mode: "local" });
|
|
7262
8431
|
});
|
|
7263
8432
|
if (this.dashboard) {
|
|
7264
|
-
|
|
7265
|
-
|
|
8433
|
+
const exposed = this.isExposed();
|
|
8434
|
+
if (this.dashboardToken) {
|
|
8435
|
+
this.effectiveDashboardToken = this.dashboardToken;
|
|
8436
|
+
} else if (exposed && !this.allowInsecureDashboard) {
|
|
8437
|
+
this.effectiveDashboardToken = crypto5.randomUUID();
|
|
8438
|
+
getLogger().warn(
|
|
8439
|
+
`Dashboard is reachable beyond 127.0.0.1 without a configured token; protecting it with an auto-generated token. Open: http://127.0.0.1:${port}/?token=${this.effectiveDashboardToken} Set dashboardToken for a stable token, or allowInsecureDashboard=true to serve it open.`
|
|
8440
|
+
);
|
|
8441
|
+
} else if (exposed && this.allowInsecureDashboard) {
|
|
8442
|
+
this.effectiveDashboardToken = "";
|
|
8443
|
+
getLogger().warn(
|
|
8444
|
+
"Dashboard served WITHOUT authentication on a publicly-reachable bind (allowInsecureDashboard=true). Call transcripts and metadata are exposed to anyone who can reach this URL."
|
|
8445
|
+
);
|
|
8446
|
+
} else {
|
|
8447
|
+
this.effectiveDashboardToken = "";
|
|
8448
|
+
}
|
|
8449
|
+
mountDashboard(app, this.metricsStore, this.effectiveDashboardToken);
|
|
8450
|
+
mountApi(app, this.metricsStore, this.effectiveDashboardToken);
|
|
8451
|
+
this.dashboardMounted = true;
|
|
7266
8452
|
}
|
|
7267
8453
|
app.post("/webhooks/twilio/status", (req, res) => {
|
|
7268
8454
|
if (this.config.twilioToken) {
|
|
@@ -7279,8 +8465,10 @@ var EmbeddedServer = class {
|
|
|
7279
8465
|
return;
|
|
7280
8466
|
}
|
|
7281
8467
|
const body = req.body;
|
|
7282
|
-
const
|
|
7283
|
-
const
|
|
8468
|
+
const rawCallSid = body["CallSid"] ?? "";
|
|
8469
|
+
const rawCallStatus = body["CallStatus"] ?? "";
|
|
8470
|
+
const callSid = sanitizeLogValue(rawCallSid);
|
|
8471
|
+
const callStatus = sanitizeLogValue(rawCallStatus);
|
|
7284
8472
|
const duration = body["CallDuration"] ?? body["Duration"] ?? "";
|
|
7285
8473
|
getLogger().info(
|
|
7286
8474
|
`Twilio status ${callStatus} for call ${callSid} (duration=${duration})`
|
|
@@ -7297,6 +8485,10 @@ var EmbeddedServer = class {
|
|
|
7297
8485
|
} catch (err) {
|
|
7298
8486
|
getLogger().debug(`recordPrewarmWaste threw: ${String(err)}`);
|
|
7299
8487
|
}
|
|
8488
|
+
this.resolveCompletion(rawCallSid, {
|
|
8489
|
+
outcome: twilioStatusToOutcome(rawCallStatus),
|
|
8490
|
+
status: rawCallStatus
|
|
8491
|
+
});
|
|
7300
8492
|
}
|
|
7301
8493
|
res.status(204).send();
|
|
7302
8494
|
});
|
|
@@ -7339,8 +8531,12 @@ var EmbeddedServer = class {
|
|
|
7339
8531
|
const answeredBy = body["AnsweredBy"] ?? "";
|
|
7340
8532
|
const callSid = body["CallSid"] ?? "";
|
|
7341
8533
|
getLogger().info(`AMD result for ${sanitizeLogValue(callSid)}: ${sanitizeLogValue(answeredBy)}`);
|
|
7342
|
-
|
|
8534
|
+
if (callSid) {
|
|
8535
|
+
this.amdClass.set(callSid, classifyTwilioAmd(answeredBy));
|
|
8536
|
+
}
|
|
8537
|
+
const cb = callSid ? this.onMachineDetectionByCallSid.get(callSid) : void 0;
|
|
7343
8538
|
if (cb && callSid) {
|
|
8539
|
+
this.onMachineDetectionByCallSid.delete(callSid);
|
|
7344
8540
|
try {
|
|
7345
8541
|
await cb({
|
|
7346
8542
|
call_id: callSid,
|
|
@@ -7464,8 +8660,12 @@ var EmbeddedServer = class {
|
|
|
7464
8660
|
getLogger().info(
|
|
7465
8661
|
`Telnyx AMD result for ${sanitizeLogValue(amdCallId)}: ${sanitizeLogValue(amdResult)}`
|
|
7466
8662
|
);
|
|
7467
|
-
|
|
8663
|
+
if (amdCallId) {
|
|
8664
|
+
this.amdClass.set(amdCallId, classifyTelnyxAmd(amdResult));
|
|
8665
|
+
}
|
|
8666
|
+
const cbTx = amdCallId ? this.onMachineDetectionByCallSid.get(amdCallId) : void 0;
|
|
7468
8667
|
if (cbTx && amdCallId) {
|
|
8668
|
+
this.onMachineDetectionByCallSid.delete(amdCallId);
|
|
7469
8669
|
try {
|
|
7470
8670
|
await cbTx({
|
|
7471
8671
|
call_id: amdCallId,
|
|
@@ -7500,6 +8700,13 @@ var EmbeddedServer = class {
|
|
|
7500
8700
|
} catch (err) {
|
|
7501
8701
|
getLogger().debug(`recordPrewarmWaste threw: ${String(err)}`);
|
|
7502
8702
|
}
|
|
8703
|
+
const noMediaOutcome = telnyxHangupOutcome(hangupCause);
|
|
8704
|
+
if (noMediaOutcome !== null) {
|
|
8705
|
+
this.resolveCompletion(hangupCallId, {
|
|
8706
|
+
outcome: noMediaOutcome,
|
|
8707
|
+
status: hangupCause
|
|
8708
|
+
});
|
|
8709
|
+
}
|
|
7503
8710
|
}
|
|
7504
8711
|
return res.status(200).send();
|
|
7505
8712
|
}
|
|
@@ -7552,6 +8759,126 @@ var EmbeddedServer = class {
|
|
|
7552
8759
|
}
|
|
7553
8760
|
return res.status(200).send();
|
|
7554
8761
|
});
|
|
8762
|
+
const validatePlivoRequest = (req, res) => {
|
|
8763
|
+
const authToken = this.config.plivoAuthToken;
|
|
8764
|
+
if (!authToken) {
|
|
8765
|
+
if (this.config.requireSignature !== false) {
|
|
8766
|
+
getLogger().error(
|
|
8767
|
+
"Plivo webhook rejected: plivoAuthToken not configured and requireSignature is not false"
|
|
8768
|
+
);
|
|
8769
|
+
res.status(503).send("Webhook signature required");
|
|
8770
|
+
return false;
|
|
8771
|
+
}
|
|
8772
|
+
return true;
|
|
8773
|
+
}
|
|
8774
|
+
const method = req.method.toUpperCase();
|
|
8775
|
+
const params = method === "POST" && req.body && typeof req.body === "object" ? Object.fromEntries(
|
|
8776
|
+
Object.entries(req.body).map(([k, v]) => [k, String(v)])
|
|
8777
|
+
) : {};
|
|
8778
|
+
const signature = req.headers["x-plivo-signature-v3"] || "";
|
|
8779
|
+
const nonce = req.headers["x-plivo-signature-v3-nonce"] || "";
|
|
8780
|
+
const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
|
|
8781
|
+
if (!validatePlivoSignature(url, nonce, signature, authToken, params, method)) {
|
|
8782
|
+
getLogger().warn("Plivo webhook rejected: invalid or missing V3 signature");
|
|
8783
|
+
res.status(403).send("Invalid signature");
|
|
8784
|
+
return false;
|
|
8785
|
+
}
|
|
8786
|
+
return true;
|
|
8787
|
+
};
|
|
8788
|
+
app.post("/webhooks/plivo/voice", (req, res) => {
|
|
8789
|
+
if (!validatePlivoRequest(req, res)) return;
|
|
8790
|
+
const body = req.body ?? {};
|
|
8791
|
+
const callUuid = body["CallUUID"] ?? "";
|
|
8792
|
+
const caller = body["From"] ?? "";
|
|
8793
|
+
const callee = body["To"] ?? "";
|
|
8794
|
+
const qs = `?caller=${encodeURIComponent(caller)}&callee=${encodeURIComponent(callee)}`;
|
|
8795
|
+
const streamUrl = `wss://${this.config.webhookUrl}/ws/plivo/stream/${callUuid || "outbound"}${qs}`;
|
|
8796
|
+
const xml = PlivoAdapter.generateStreamXml(streamUrl, "audio/x-mulaw;rate=8000", {
|
|
8797
|
+
"X-PH-caller": caller,
|
|
8798
|
+
"X-PH-callee": callee
|
|
8799
|
+
});
|
|
8800
|
+
res.type("text/xml").send(xml);
|
|
8801
|
+
});
|
|
8802
|
+
app.post("/webhooks/plivo/status", (req, res) => {
|
|
8803
|
+
if (!validatePlivoRequest(req, res)) return;
|
|
8804
|
+
const body = req.body ?? {};
|
|
8805
|
+
const callUuid = body["CallUUID"] ?? "";
|
|
8806
|
+
const callStatus = body["CallStatus"] ?? body["Status"] ?? "";
|
|
8807
|
+
const duration = body["Duration"] ?? body["BillDuration"] ?? "";
|
|
8808
|
+
getLogger().info(
|
|
8809
|
+
`Plivo status ${sanitizeLogValue(callStatus)} for call ${sanitizeLogValue(callUuid)} (duration=${duration})`
|
|
8810
|
+
);
|
|
8811
|
+
if (callUuid && callStatus) {
|
|
8812
|
+
const extra = {};
|
|
8813
|
+
const parsed = parseFloat(duration);
|
|
8814
|
+
if (!Number.isNaN(parsed)) extra.duration_seconds = parsed;
|
|
8815
|
+
this.metricsStore.updateCallStatus(callUuid, callStatus, extra);
|
|
8816
|
+
}
|
|
8817
|
+
if (callUuid && ["no-answer", "busy", "failed", "timeout", "cancel"].includes(callStatus)) {
|
|
8818
|
+
try {
|
|
8819
|
+
this.recordPrewarmWaste(callUuid);
|
|
8820
|
+
} catch (err) {
|
|
8821
|
+
getLogger().debug(`recordPrewarmWaste threw: ${String(err)}`);
|
|
8822
|
+
}
|
|
8823
|
+
const outcome = callStatus === "no-answer" || callStatus === "timeout" ? "no_answer" : callStatus === "busy" ? "busy" : "failed";
|
|
8824
|
+
this.resolveCompletion(callUuid, { outcome, status: callStatus });
|
|
8825
|
+
}
|
|
8826
|
+
res.status(200).send();
|
|
8827
|
+
});
|
|
8828
|
+
app.post("/webhooks/plivo/amd", async (req, res) => {
|
|
8829
|
+
if (!validatePlivoRequest(req, res)) return;
|
|
8830
|
+
const body = req.body ?? {};
|
|
8831
|
+
const callUuid = body["CallUUID"] ?? "";
|
|
8832
|
+
const amdRaw = body["Machine"] || body["MachineDetection"] || body["AnsweredBy"] || body["CallStatus"] || "";
|
|
8833
|
+
getLogger().info(`AMD result for ${sanitizeLogValue(callUuid)}: ${sanitizeLogValue(amdRaw)}`);
|
|
8834
|
+
const classification = classifyPlivoAmd(amdRaw);
|
|
8835
|
+
if (callUuid) this.amdClass.set(callUuid, classification);
|
|
8836
|
+
let cbKey = callUuid && this.onMachineDetectionByCallSid.has(callUuid) ? callUuid : void 0;
|
|
8837
|
+
if (cbKey === void 0 && this.onMachineDetectionByCallSid.size === 1) {
|
|
8838
|
+
cbKey = this.onMachineDetectionByCallSid.keys().next().value;
|
|
8839
|
+
}
|
|
8840
|
+
const cb = cbKey !== void 0 ? this.onMachineDetectionByCallSid.get(cbKey) : void 0;
|
|
8841
|
+
if (cb && callUuid) {
|
|
8842
|
+
if (cbKey !== void 0) this.onMachineDetectionByCallSid.delete(cbKey);
|
|
8843
|
+
try {
|
|
8844
|
+
await cb({
|
|
8845
|
+
call_id: callUuid,
|
|
8846
|
+
carrier: "plivo",
|
|
8847
|
+
classification,
|
|
8848
|
+
raw: amdRaw,
|
|
8849
|
+
detected_at: Date.now() / 1e3
|
|
8850
|
+
});
|
|
8851
|
+
} catch (err) {
|
|
8852
|
+
getLogger().warn(`onMachineDetection callback threw: ${sanitizeLogValue(String(err))}`);
|
|
8853
|
+
}
|
|
8854
|
+
}
|
|
8855
|
+
if (classification === "machine" && callUuid) {
|
|
8856
|
+
try {
|
|
8857
|
+
this.recordPrewarmWaste(callUuid);
|
|
8858
|
+
} catch (err) {
|
|
8859
|
+
getLogger().debug(`recordPrewarmWaste threw: ${String(err)}`);
|
|
8860
|
+
}
|
|
8861
|
+
if (this.voicemailMessage && this.config.plivoAuthId && this.config.plivoAuthToken) {
|
|
8862
|
+
await dropPlivoVoicemail(
|
|
8863
|
+
callUuid,
|
|
8864
|
+
this.voicemailMessage,
|
|
8865
|
+
this.config.plivoAuthId,
|
|
8866
|
+
this.config.plivoAuthToken
|
|
8867
|
+
);
|
|
8868
|
+
}
|
|
8869
|
+
}
|
|
8870
|
+
res.status(200).send();
|
|
8871
|
+
});
|
|
8872
|
+
app.all("/webhooks/plivo/transfer", (req, res) => {
|
|
8873
|
+
if (!validatePlivoRequest(req, res)) return;
|
|
8874
|
+
const to = String(req.query.to ?? "");
|
|
8875
|
+
if (!to || !/^\+[1-9]\d{6,14}$/.test(to)) {
|
|
8876
|
+
getLogger().warn(`Plivo transfer XML: invalid target ${JSON.stringify(to)}`);
|
|
8877
|
+
res.type("text/xml").send("<Response><Hangup/></Response>");
|
|
8878
|
+
return;
|
|
8879
|
+
}
|
|
8880
|
+
res.type("text/xml").send(`<Response><Dial><Number>${xmlEscape(to)}</Number></Dial></Response>`);
|
|
8881
|
+
});
|
|
7555
8882
|
this.server = createServer(app);
|
|
7556
8883
|
this.wss = new WebSocketServer({ noServer: true });
|
|
7557
8884
|
const MAX_WS_PER_IP = 10;
|
|
@@ -7584,34 +8911,43 @@ var EmbeddedServer = class {
|
|
|
7584
8911
|
ws.once("close", () => {
|
|
7585
8912
|
this.activeConnections.delete(ws);
|
|
7586
8913
|
});
|
|
7587
|
-
const
|
|
7588
|
-
if (
|
|
8914
|
+
const provider2 = this.config.telephonyProvider;
|
|
8915
|
+
if (provider2 === "telnyx") {
|
|
7589
8916
|
this.handleTelnyxStream(ws, url);
|
|
8917
|
+
} else if (provider2 === "plivo") {
|
|
8918
|
+
this.handlePlivoStream(ws, url);
|
|
7590
8919
|
} else {
|
|
7591
8920
|
this.handleTwilioStream(ws, url);
|
|
7592
8921
|
}
|
|
7593
8922
|
});
|
|
7594
|
-
await new Promise((resolve2) => {
|
|
8923
|
+
await new Promise((resolve2, reject) => {
|
|
7595
8924
|
const bindHost = process.env.PATTER_BIND_HOST ?? "127.0.0.1";
|
|
8925
|
+
this.server.once("error", reject);
|
|
7596
8926
|
this.server.listen(port, bindHost, () => {
|
|
8927
|
+
this.server.off("error", reject);
|
|
7597
8928
|
getLogger().info(`Server on port ${port}`);
|
|
7598
8929
|
getLogger().info(`Webhook: https://${this.config.webhookUrl}`);
|
|
7599
8930
|
getLogger().info(`Phone: ${this.config.phoneNumber}`);
|
|
7600
8931
|
const model = this.agent.model ?? "";
|
|
7601
|
-
|
|
8932
|
+
const calibrated = ["gpt-realtime-mini", "gpt-4o-mini-realtime-preview"];
|
|
8933
|
+
if (model && !calibrated.includes(model) && model.includes("realtime")) {
|
|
7602
8934
|
getLogger().warn(
|
|
7603
|
-
`Agent uses "${sanitizeLogValue(model)}" but DEFAULT_PRICING.openai_realtime is calibrated for
|
|
8935
|
+
`Agent uses "${sanitizeLogValue(model)}" but DEFAULT_PRICING.openai_realtime is calibrated for the default Realtime models (gpt-realtime-mini / gpt-4o-mini-realtime-preview). Pass Patter({ pricing: { openai_realtime: {...} } }) to set rates for this model, otherwise the dashboard cost display will under-report.`
|
|
7604
8936
|
);
|
|
7605
8937
|
}
|
|
7606
|
-
if (this.dashboard) {
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
8938
|
+
if (this.dashboard && this.dashboardMounted) {
|
|
8939
|
+
getLogger().info("\u2500\u2500\u2500\u2500 Dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
8940
|
+
if (this.effectiveDashboardToken) {
|
|
8941
|
+
getLogger().info(
|
|
8942
|
+
`URL: http://127.0.0.1:${port}/?token=${this.effectiveDashboardToken}`
|
|
8943
|
+
);
|
|
8944
|
+
} else {
|
|
8945
|
+
getLogger().info(`URL: http://127.0.0.1:${port}/`);
|
|
7610
8946
|
getLogger().warn(
|
|
7611
8947
|
"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."
|
|
7612
8948
|
);
|
|
7613
8949
|
}
|
|
7614
|
-
|
|
8950
|
+
getLogger().info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
7615
8951
|
}
|
|
7616
8952
|
resolve2();
|
|
7617
8953
|
});
|
|
@@ -7687,7 +9023,7 @@ var EmbeddedServer = class {
|
|
|
7687
9023
|
onMessage: this.onMessage,
|
|
7688
9024
|
onMetrics: wrappedMetrics,
|
|
7689
9025
|
recording: this.recording,
|
|
7690
|
-
buildAIAdapter: (resolvedPrompt) => buildAIAdapter(this.config, this.agent, resolvedPrompt),
|
|
9026
|
+
buildAIAdapter: (resolvedPrompt, toolsOverride) => buildAIAdapter(this.config, this.agent, resolvedPrompt, toolsOverride),
|
|
7691
9027
|
sanitizeVariables,
|
|
7692
9028
|
resolveVariables,
|
|
7693
9029
|
popPrewarmAudio: this.popPrewarmAudio,
|
|
@@ -7770,6 +9106,12 @@ var EmbeddedServer = class {
|
|
|
7770
9106
|
}).catch((err) => getLogger().error(`call_log end error: ${String(err)}`));
|
|
7771
9107
|
}
|
|
7772
9108
|
if (userEnd) await userEnd(data);
|
|
9109
|
+
const cid = typeof data.call_id === "string" ? data.call_id : "";
|
|
9110
|
+
if (cid) {
|
|
9111
|
+
const cls = this.amdClass.get(cid);
|
|
9112
|
+
const outcome = cls === "machine" ? "voicemail" : "answered";
|
|
9113
|
+
this.resolveCompletion(cid, { outcome, status: "completed", data });
|
|
9114
|
+
}
|
|
7773
9115
|
};
|
|
7774
9116
|
return [wrappedStart, wrappedMetrics, wrappedEnd];
|
|
7775
9117
|
}
|
|
@@ -7876,6 +9218,52 @@ var EmbeddedServer = class {
|
|
|
7876
9218
|
});
|
|
7877
9219
|
}
|
|
7878
9220
|
// ---------------------------------------------------------------------------
|
|
9221
|
+
// Plivo WebSocket message parser (thin layer)
|
|
9222
|
+
// ---------------------------------------------------------------------------
|
|
9223
|
+
handlePlivoStream(ws, url) {
|
|
9224
|
+
const caller = url.searchParams.get("caller") ?? "";
|
|
9225
|
+
const callee = url.searchParams.get("callee") ?? "";
|
|
9226
|
+
const bridge = new PlivoBridge(this.config);
|
|
9227
|
+
const handler = new StreamHandler(this.buildStreamHandlerDeps(bridge), ws, caller, callee);
|
|
9228
|
+
ws.on("message", async (raw) => {
|
|
9229
|
+
try {
|
|
9230
|
+
let data;
|
|
9231
|
+
try {
|
|
9232
|
+
data = JSON.parse(raw.toString());
|
|
9233
|
+
} catch (e) {
|
|
9234
|
+
getLogger().error("Failed to parse Plivo WS message:", e);
|
|
9235
|
+
return;
|
|
9236
|
+
}
|
|
9237
|
+
const event = data.event ?? "";
|
|
9238
|
+
if (event === "start") {
|
|
9239
|
+
handler.setStreamSid(data.start?.streamId ?? "");
|
|
9240
|
+
const callId = data.start?.callId ?? "";
|
|
9241
|
+
if (callId) this.activeCallIds.set(ws, callId);
|
|
9242
|
+
await handler.handleCallStart(callId);
|
|
9243
|
+
} else if (event === "media") {
|
|
9244
|
+
const payload = data.media?.payload ?? "";
|
|
9245
|
+
if (payload) handler.handleAudio(Buffer.from(payload, "base64"));
|
|
9246
|
+
} else if (event === "playedStream") {
|
|
9247
|
+
const markName = String(data.name ?? "");
|
|
9248
|
+
if (markName) await handler.onMark(markName);
|
|
9249
|
+
} else if (event === "dtmf") {
|
|
9250
|
+
const digit = String(data.dtmf?.digit ?? "").trim();
|
|
9251
|
+
if (digit) await handler.handleDtmf(digit);
|
|
9252
|
+
} else if (event === "playFailed" || event === "error") {
|
|
9253
|
+
getLogger().warn(`Plivo ${event}: ${data.reason ?? "unknown"}`);
|
|
9254
|
+
} else if (event === "stop") {
|
|
9255
|
+
await handler.handleStop();
|
|
9256
|
+
}
|
|
9257
|
+
} catch (err) {
|
|
9258
|
+
getLogger().error("Stream handler error (Plivo):", err);
|
|
9259
|
+
}
|
|
9260
|
+
});
|
|
9261
|
+
ws.on("close", async () => {
|
|
9262
|
+
this.activeCallIds.delete(ws);
|
|
9263
|
+
await handler.handleWsClose();
|
|
9264
|
+
});
|
|
9265
|
+
}
|
|
9266
|
+
// ---------------------------------------------------------------------------
|
|
7879
9267
|
// Graceful shutdown
|
|
7880
9268
|
// ---------------------------------------------------------------------------
|
|
7881
9269
|
/**
|
|
@@ -7892,10 +9280,10 @@ var EmbeddedServer = class {
|
|
|
7892
9280
|
const httpClosePromise = new Promise((resolve2) => {
|
|
7893
9281
|
this.server.close(() => resolve2());
|
|
7894
9282
|
});
|
|
7895
|
-
const
|
|
9283
|
+
const provider2 = this.config.telephonyProvider;
|
|
7896
9284
|
for (const [ws, callId] of this.activeCallIds) {
|
|
7897
9285
|
try {
|
|
7898
|
-
const bridge =
|
|
9286
|
+
const bridge = provider2 === "telnyx" ? new TelnyxBridge(this.config) : provider2 === "plivo" ? new PlivoBridge(this.config) : new TwilioBridge(this.config);
|
|
7899
9287
|
await bridge.endCall(callId, ws);
|
|
7900
9288
|
} catch {
|
|
7901
9289
|
}
|
|
@@ -7909,17 +9297,18 @@ var EmbeddedServer = class {
|
|
|
7909
9297
|
}
|
|
7910
9298
|
if (this.activeConnections.size > 0) {
|
|
7911
9299
|
getLogger().info(`Waiting for ${this.activeConnections.size} active connection(s) to close...`);
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
]);
|
|
9300
|
+
let checkInterval;
|
|
9301
|
+
const drainPromise = new Promise((resolve2) => {
|
|
9302
|
+
checkInterval = setInterval(() => {
|
|
9303
|
+
if (this.activeConnections.size === 0) {
|
|
9304
|
+
clearInterval(checkInterval);
|
|
9305
|
+
resolve2();
|
|
9306
|
+
}
|
|
9307
|
+
}, 100);
|
|
9308
|
+
});
|
|
9309
|
+
const timeoutPromise = new Promise((resolve2) => setTimeout(resolve2, GRACEFUL_SHUTDOWN_TIMEOUT_MS));
|
|
9310
|
+
await Promise.race([drainPromise, timeoutPromise]);
|
|
9311
|
+
clearInterval(checkInterval);
|
|
7923
9312
|
}
|
|
7924
9313
|
if (this.activeConnections.size > 0) {
|
|
7925
9314
|
getLogger().info(`Force-closing ${this.activeConnections.size} remaining connection(s)`);
|
|
@@ -7966,10 +9355,13 @@ var CircuitBreakerRegistry = class {
|
|
|
7966
9355
|
if (s.state === CircuitBreakerState.OPEN) {
|
|
7967
9356
|
if (this.clock() - s.openedAt >= this.cooldownMs) {
|
|
7968
9357
|
s.state = CircuitBreakerState.HALF_OPEN;
|
|
9358
|
+
s.probeInFlight = true;
|
|
7969
9359
|
return true;
|
|
7970
9360
|
}
|
|
7971
9361
|
return false;
|
|
7972
9362
|
}
|
|
9363
|
+
if (s.probeInFlight) return false;
|
|
9364
|
+
s.probeInFlight = true;
|
|
7973
9365
|
return true;
|
|
7974
9366
|
}
|
|
7975
9367
|
/** Mark a successful execution. Resets the breaker to CLOSED. */
|
|
@@ -7979,19 +9371,21 @@ var CircuitBreakerRegistry = class {
|
|
|
7979
9371
|
s.state = CircuitBreakerState.CLOSED;
|
|
7980
9372
|
s.consecutiveFailures = 0;
|
|
7981
9373
|
s.openedAt = 0;
|
|
9374
|
+
s.probeInFlight = false;
|
|
7982
9375
|
}
|
|
7983
9376
|
/** Mark a failed execution; trips OPEN once threshold is reached. */
|
|
7984
9377
|
recordFailure(toolName) {
|
|
7985
9378
|
if (this.threshold <= 0) return;
|
|
7986
9379
|
let s = this.state.get(toolName);
|
|
7987
9380
|
if (!s) {
|
|
7988
|
-
s = { state: CircuitBreakerState.CLOSED, consecutiveFailures: 0, openedAt: 0 };
|
|
9381
|
+
s = { state: CircuitBreakerState.CLOSED, consecutiveFailures: 0, openedAt: 0, probeInFlight: false };
|
|
7989
9382
|
this.state.set(toolName, s);
|
|
7990
9383
|
}
|
|
7991
9384
|
s.consecutiveFailures += 1;
|
|
7992
9385
|
if (s.consecutiveFailures >= this.threshold) {
|
|
7993
9386
|
s.state = CircuitBreakerState.OPEN;
|
|
7994
9387
|
s.openedAt = this.clock();
|
|
9388
|
+
s.probeInFlight = false;
|
|
7995
9389
|
}
|
|
7996
9390
|
}
|
|
7997
9391
|
/**
|
|
@@ -8016,7 +9410,18 @@ var CircuitBreakerRegistry = class {
|
|
|
8016
9410
|
var DEFAULT_TOOL_MAX_RETRIES = 2;
|
|
8017
9411
|
var DEFAULT_TOOL_RETRY_DELAY_MS = 500;
|
|
8018
9412
|
var DEFAULT_TOOL_TIMEOUT_MS = 1e4;
|
|
9413
|
+
var MAX_TOOL_TIMEOUT_MS = 3e5;
|
|
8019
9414
|
var TOOL_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
|
|
9415
|
+
var ToolTimeoutError = class extends Error {
|
|
9416
|
+
constructor(message) {
|
|
9417
|
+
super(message);
|
|
9418
|
+
this.name = "ToolTimeoutError";
|
|
9419
|
+
}
|
|
9420
|
+
};
|
|
9421
|
+
function resolveToolTimeoutMs(toolTimeoutMs, defaultMs) {
|
|
9422
|
+
if (toolTimeoutMs === void 0) return defaultMs;
|
|
9423
|
+
return Math.max(100, Math.min(toolTimeoutMs, MAX_TOOL_TIMEOUT_MS));
|
|
9424
|
+
}
|
|
8020
9425
|
async function invokeHandler(handler, args, callContext, onProgress) {
|
|
8021
9426
|
const invoked = handler(args, callContext);
|
|
8022
9427
|
if (invoked && typeof invoked === "object" && typeof invoked[Symbol.asyncIterator] === "function" && typeof invoked.next === "function") {
|
|
@@ -8076,15 +9481,41 @@ var DefaultToolExecutor = class {
|
|
|
8076
9481
|
retry_after_ms: cooldown
|
|
8077
9482
|
});
|
|
8078
9483
|
}
|
|
9484
|
+
const effectiveTimeoutMs = resolveToolTimeoutMs(
|
|
9485
|
+
toolDef.timeoutMs,
|
|
9486
|
+
this.requestTimeoutMs
|
|
9487
|
+
);
|
|
8079
9488
|
if (toolDef.handler) {
|
|
8080
9489
|
const totalAttempts = this.maxRetries + 1;
|
|
8081
9490
|
let lastErr = null;
|
|
8082
9491
|
for (let attempt = 0; attempt < totalAttempts; attempt++) {
|
|
9492
|
+
let timeoutTimer;
|
|
8083
9493
|
try {
|
|
8084
|
-
const
|
|
9494
|
+
const handlerPromise = invokeHandler(toolDef.handler, args, callContext, onProgress);
|
|
9495
|
+
const result = await Promise.race([
|
|
9496
|
+
handlerPromise,
|
|
9497
|
+
new Promise((_, reject) => {
|
|
9498
|
+
timeoutTimer = setTimeout(
|
|
9499
|
+
() => reject(
|
|
9500
|
+
new ToolTimeoutError(
|
|
9501
|
+
`Tool handler '${toolDef.name}' timed out after ${effectiveTimeoutMs}ms`
|
|
9502
|
+
)
|
|
9503
|
+
),
|
|
9504
|
+
effectiveTimeoutMs
|
|
9505
|
+
);
|
|
9506
|
+
})
|
|
9507
|
+
]);
|
|
8085
9508
|
this.breaker.recordSuccess(toolDef.name);
|
|
8086
9509
|
return result;
|
|
8087
9510
|
} catch (e) {
|
|
9511
|
+
if (e instanceof ToolTimeoutError) {
|
|
9512
|
+
getLogger().error(String(e));
|
|
9513
|
+
this.breaker.recordFailure(toolDef.name);
|
|
9514
|
+
return JSON.stringify({
|
|
9515
|
+
error: String(e),
|
|
9516
|
+
fallback: true
|
|
9517
|
+
});
|
|
9518
|
+
}
|
|
8088
9519
|
lastErr = e;
|
|
8089
9520
|
if (attempt < totalAttempts - 1) {
|
|
8090
9521
|
getLogger().warn(
|
|
@@ -8092,6 +9523,8 @@ var DefaultToolExecutor = class {
|
|
|
8092
9523
|
);
|
|
8093
9524
|
await new Promise((r) => setTimeout(r, backoffDelayMs(this.retryDelayMs, attempt)));
|
|
8094
9525
|
}
|
|
9526
|
+
} finally {
|
|
9527
|
+
if (timeoutTimer !== void 0) clearTimeout(timeoutTimer);
|
|
8095
9528
|
}
|
|
8096
9529
|
}
|
|
8097
9530
|
this.breaker.recordFailure(toolDef.name);
|
|
@@ -8128,7 +9561,10 @@ var DefaultToolExecutor = class {
|
|
|
8128
9561
|
...callContext,
|
|
8129
9562
|
attempt: attempt + 1
|
|
8130
9563
|
}),
|
|
8131
|
-
|
|
9564
|
+
// Use per-tool timeout when set, otherwise fall back to
|
|
9565
|
+
// the executor-level default. Mirrors Python's per-request
|
|
9566
|
+
// ``timeout=`` override on httpx.AsyncClient.post().
|
|
9567
|
+
signal: AbortSignal.timeout(effectiveTimeoutMs)
|
|
8132
9568
|
});
|
|
8133
9569
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
8134
9570
|
const result = JSON.stringify(await resp.json());
|
|
@@ -8278,7 +9714,7 @@ var OpenAILLMProvider = class {
|
|
|
8278
9714
|
body.tools = tools;
|
|
8279
9715
|
}
|
|
8280
9716
|
const signal = mergeAbortSignals(opts?.signal, AbortSignal.timeout(3e4));
|
|
8281
|
-
const response = await fetch(
|
|
9717
|
+
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
8282
9718
|
method: "POST",
|
|
8283
9719
|
headers: {
|
|
8284
9720
|
"Content-Type": "application/json",
|
|
@@ -8298,50 +9734,55 @@ var OpenAILLMProvider = class {
|
|
|
8298
9734
|
if (!reader) return;
|
|
8299
9735
|
const decoder = new TextDecoder();
|
|
8300
9736
|
let buffer = "";
|
|
8301
|
-
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
|
|
8307
|
-
|
|
8308
|
-
const
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
type: "usage",
|
|
8323
|
-
inputTokens: uncachedInput,
|
|
8324
|
-
outputTokens: chunk.usage.completion_tokens,
|
|
8325
|
-
cacheReadInputTokens: cached
|
|
8326
|
-
};
|
|
8327
|
-
}
|
|
8328
|
-
const delta = chunk.choices?.[0]?.delta;
|
|
8329
|
-
if (!delta) continue;
|
|
8330
|
-
if (delta.content) {
|
|
8331
|
-
yield { type: "text", content: delta.content };
|
|
8332
|
-
}
|
|
8333
|
-
if (delta.tool_calls) {
|
|
8334
|
-
for (const tc of delta.tool_calls) {
|
|
9737
|
+
try {
|
|
9738
|
+
while (true) {
|
|
9739
|
+
const { done, value } = await reader.read();
|
|
9740
|
+
if (done) break;
|
|
9741
|
+
buffer += decoder.decode(value, { stream: true });
|
|
9742
|
+
const lines = buffer.split("\n");
|
|
9743
|
+
buffer = lines.pop() || "";
|
|
9744
|
+
for (const line of lines) {
|
|
9745
|
+
const trimmed = line.trim();
|
|
9746
|
+
if (!trimmed || !trimmed.startsWith("data: ")) continue;
|
|
9747
|
+
const data = trimmed.slice(6);
|
|
9748
|
+
if (data === "[DONE]") continue;
|
|
9749
|
+
let chunk;
|
|
9750
|
+
try {
|
|
9751
|
+
chunk = JSON.parse(data);
|
|
9752
|
+
} catch {
|
|
9753
|
+
continue;
|
|
9754
|
+
}
|
|
9755
|
+
if (chunk.usage) {
|
|
9756
|
+
const cached = chunk.usage.prompt_tokens_details?.cached_tokens ?? 0;
|
|
9757
|
+
const uncachedInput = Math.max(0, (chunk.usage.prompt_tokens ?? 0) - cached);
|
|
8335
9758
|
yield {
|
|
8336
|
-
type: "
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
|
|
8340
|
-
arguments: tc.function?.arguments
|
|
9759
|
+
type: "usage",
|
|
9760
|
+
inputTokens: uncachedInput,
|
|
9761
|
+
outputTokens: chunk.usage.completion_tokens,
|
|
9762
|
+
cacheReadInputTokens: cached
|
|
8341
9763
|
};
|
|
8342
9764
|
}
|
|
9765
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
9766
|
+
if (!delta) continue;
|
|
9767
|
+
if (delta.content) {
|
|
9768
|
+
yield { type: "text", content: delta.content };
|
|
9769
|
+
}
|
|
9770
|
+
if (delta.tool_calls) {
|
|
9771
|
+
for (const tc of delta.tool_calls) {
|
|
9772
|
+
yield {
|
|
9773
|
+
type: "tool_call",
|
|
9774
|
+
index: tc.index,
|
|
9775
|
+
id: tc.id,
|
|
9776
|
+
name: tc.function?.name,
|
|
9777
|
+
arguments: tc.function?.arguments
|
|
9778
|
+
};
|
|
9779
|
+
}
|
|
9780
|
+
}
|
|
8343
9781
|
}
|
|
8344
9782
|
}
|
|
9783
|
+
} finally {
|
|
9784
|
+
reader.cancel().catch(() => {
|
|
9785
|
+
});
|
|
8345
9786
|
}
|
|
8346
9787
|
}
|
|
8347
9788
|
};
|
|
@@ -8475,7 +9916,7 @@ ${systemPrompt}` : DEFAULT_PHONE_PREAMBLE;
|
|
|
8475
9916
|
chunk.inputTokens ?? 0,
|
|
8476
9917
|
chunk.outputTokens ?? 0,
|
|
8477
9918
|
chunk.cacheReadInputTokens ?? 0,
|
|
8478
|
-
chunk.
|
|
9919
|
+
chunk.cacheWriteInputTokens ?? 0
|
|
8479
9920
|
);
|
|
8480
9921
|
} else if (chunk.type === "tool_call") {
|
|
8481
9922
|
hasToolCalls = true;
|
|
@@ -8704,12 +10145,12 @@ var TestSession = class {
|
|
|
8704
10145
|
}
|
|
8705
10146
|
continue;
|
|
8706
10147
|
}
|
|
8707
|
-
conversationHistory.push({
|
|
8708
|
-
role: "user",
|
|
8709
|
-
text: userInput,
|
|
8710
|
-
timestamp: Date.now()
|
|
8711
|
-
});
|
|
8712
10148
|
if (onMessage) {
|
|
10149
|
+
conversationHistory.push({
|
|
10150
|
+
role: "user",
|
|
10151
|
+
text: userInput,
|
|
10152
|
+
timestamp: Date.now()
|
|
10153
|
+
});
|
|
8713
10154
|
try {
|
|
8714
10155
|
const responseText = await onMessage({
|
|
8715
10156
|
text: userInput,
|
|
@@ -8739,6 +10180,11 @@ var TestSession = class {
|
|
|
8739
10180
|
}
|
|
8740
10181
|
log.info("");
|
|
8741
10182
|
const responseText = parts.join("");
|
|
10183
|
+
conversationHistory.push({
|
|
10184
|
+
role: "user",
|
|
10185
|
+
text: userInput,
|
|
10186
|
+
timestamp: Date.now()
|
|
10187
|
+
});
|
|
8742
10188
|
if (responseText) {
|
|
8743
10189
|
conversationHistory.push({
|
|
8744
10190
|
role: "assistant",
|
|
@@ -8770,11 +10216,14 @@ var TestSession = class {
|
|
|
8770
10216
|
export {
|
|
8771
10217
|
ErrorCode,
|
|
8772
10218
|
PatterError,
|
|
10219
|
+
PatterConfigError,
|
|
8773
10220
|
PatterConnectionError,
|
|
8774
10221
|
AuthenticationError,
|
|
8775
10222
|
ProvisionError,
|
|
8776
10223
|
RateLimitError,
|
|
8777
10224
|
ElevenLabsConvAIAdapter,
|
|
10225
|
+
PlivoAdapter,
|
|
10226
|
+
Carrier,
|
|
8778
10227
|
PRICING_VERSION,
|
|
8779
10228
|
PRICING_LAST_UPDATED,
|
|
8780
10229
|
PricingUnit,
|
|
@@ -8811,6 +10260,8 @@ export {
|
|
|
8811
10260
|
mergeAbortSignals,
|
|
8812
10261
|
OpenAILLMProvider,
|
|
8813
10262
|
LLMLoop,
|
|
10263
|
+
openclawConsult,
|
|
10264
|
+
openclawPostCallNotifier,
|
|
8814
10265
|
DEFAULT_MIN_SENTENCE_LEN,
|
|
8815
10266
|
SentenceChunker,
|
|
8816
10267
|
PipelineHookExecutor,
|