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.
@@ -5,7 +5,7 @@ import {
5
5
  createResampler8kTo16k,
6
6
  mulawToPcm16,
7
7
  pcm16ToMulaw
8
- } from "./chunk-CL2U3YET.mjs";
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 crypto4 from "crypto";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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 cachedAudio = Math.min(cached.audio_tokens ?? 0, input.audio_tokens ?? 0);
723
- const cachedText = Math.min(cached.text_tokens ?? 0, input.text_tokens ?? 0);
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 = provider2 === "twilio" ? Math.ceil(durationSeconds / 60) : durationSeconds / 60;
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 = 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
- if (userText.length > 0) {
993
- active.transcript.push({ role: "user", text: userText, timestamp: ts });
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.mkdirSync(dir, { recursive: true });
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.writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf8");
1138
- fs2.renameSync(tmp, this.deletedIdsPath);
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
- return this.activeCalls.get(callId);
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 * 1e3 : NaN;
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 crypto from "crypto";
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
- crypto.timingSafeEqual(aBuf, aBuf);
1810
+ crypto2.timingSafeEqual(aBuf, aBuf);
1431
1811
  return false;
1432
1812
  }
1433
- return crypto.timingSafeEqual(aBuf, bBuf);
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 crypto2 from "crypto";
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 crypto2.createHmac("sha256", this.webhookSecret).update(body).digest("hex");
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 text = await new Promise((resolve2) => {
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().info(
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().info(
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().info(
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().info("[DIAG] DeepgramSTT.finalize sent {type:Finalize}");
2756
+ getLogger().debug("[DIAG] DeepgramSTT.finalize sent {type:Finalize}");
2366
2757
  } catch (err) {
2367
- getLogger().info(`[DIAG] DeepgramSTT.finalize send failed: ${String(err)}`);
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
- turn_index: this._turns.length,
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. Stored for inclusion in the next ``emitEouMetrics``
2826
- * call (or an explicit re-emit if desired).
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, and turn-committed timestamps are all known. */
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: "0.6.0" });
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
- "you",
4060
- "thank you",
4061
- "thanks",
4062
- "yeah",
4063
- "yes",
4064
- "no",
4065
- "okay",
4066
- "ok",
4067
- "uh",
4068
- "um",
4069
- "mmm",
4070
- "hmm",
4071
- ".",
4072
- "bye",
4073
- "right",
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 grace = Number(process.env.PATTER_TTS_TAIL_GRACE_MS ?? 1500);
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. Currently Twilio-only — bridges may
4650
- * expose ``startRecording`` for parity when we add other carriers.
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}, ${sanitizeLogValue(this.caller || "?")} \u2192 ${sanitizeLogValue(this.callee || "?")})`
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(resolvedPrompt);
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
- const mutableAgent = this.deps.agent;
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
- const timeoutPromise = new Promise((resolve2) => setTimeout(() => resolve2(null), 25));
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.telephonyProvider === "twilio" && this.adapter.inputAudioFormat !== "ulaw_8000") {
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-LNDFGIY7.mjs");
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
- const carrier = this.deps.bridge.telephonyProvider;
5174
- if (carrier === "twilio" || carrier === "telnyx") {
5175
- getLogger().warn(
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
- this.deps.agent.tools,
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
- this.deps.agent.tools,
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
- const stripped = inputText.trim().toLowerCase();
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
- if (this.adapter instanceof OpenAIRealtimeAdapter) {
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(this.metricsAcc.recordTurnComplete(text));
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
- void this.flushAssistantTurn(buffered);
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
- if (this.adapter instanceof OpenAIRealtimeAdapter) {
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 (this.adapter instanceof OpenAIRealtimeAdapter) this.adapter.cancelResponse();
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 toolDef = this.deps.agent.tools?.find((t) => t.name === fc.name);
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.sendText(msg).catch((e) => {
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 crypto3 from "crypto";
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:" + crypto3.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 16);
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}.${crypto3.randomBytes(4).toString("hex")}.json`);
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 (crypto3.randomBytes(1)[0] < 5) {
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
- for (const yearName of fs3.readdirSync(callsRoot)) {
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 (!fs3.statSync(yearDir).isDirectory()) continue;
6650
- for (const monthName of fs3.readdirSync(yearDir)) {
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 (!fs3.statSync(monthDir).isDirectory()) continue;
6654
- for (const dayName of fs3.readdirSync(monthDir)) {
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
- rmTree(dayDir);
7587
+ await rmTreeAsync(dayDir);
6663
7588
  }
6664
7589
  }
6665
7590
  try {
6666
- if (fs3.readdirSync(monthDir).length === 0) fs3.rmdirSync(monthDir);
7591
+ if ((await fsp.readdir(monthDir)).length === 0) await fsp.rmdir(monthDir);
6667
7592
  } catch {
6668
7593
  }
6669
7594
  }
6670
7595
  try {
6671
- if (fs3.readdirSync(yearDir).length === 0) fs3.rmdirSync(yearDir);
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 rmTree(target) {
7605
+ async function rmTreeAsync(target) {
6681
7606
  try {
6682
- for (const child of fs3.readdirSync(target)) {
7607
+ for (const child of await fsp.readdir(target)) {
6683
7608
  const childPath = path3.join(target, child);
6684
- const stat = fs3.lstatSync(childPath);
7609
+ const stat = await fsp.lstat(childPath);
6685
7610
  if (stat.isDirectory()) {
6686
- rmTree(childPath);
7611
+ await rmTreeAsync(childPath);
6687
7612
  } else {
6688
7613
  try {
6689
- fs3.unlinkSync(childPath);
7614
+ await fsp.unlink(childPath);
6690
7615
  } catch {
6691
7616
  }
6692
7617
  }
6693
7618
  }
6694
- fs3.rmdirSync(target);
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 validateWebhookUrl(url) {
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 = crypto4.createPublicKey({
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 (crypto4.verify(null, Buffer.from(payload), keyObject, sigBuffer)) {
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 = crypto4.createHmac("sha1", authToken).update(data).digest("base64");
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 crypto4.timingSafeEqual(sigBuf, expBuf);
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 agentTools = agent.tools?.map((t) => ({
6869
- name: t.name,
6870
- description: t.description,
6871
- parameters: t.parameters,
6872
- strict: t.strict
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 = engine && engine.kind === "openai_realtime_2" ? OpenAIRealtime2Adapter : OpenAIRealtimeAdapter;
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 callback set by ``Patter.call()`` for the most
7187
- * recent outbound call. Public so ``client.ts`` can populate it after
7188
- * server start. Cleared after firing once per call to avoid leaking
7189
- * across calls.
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
- onMachineDetection;
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
- mountDashboard(app, this.metricsStore, this.dashboardToken);
7265
- mountApi(app, this.metricsStore, this.dashboardToken);
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 callSid = sanitizeLogValue(body["CallSid"] ?? "");
7283
- const callStatus = sanitizeLogValue(body["CallStatus"] ?? "");
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
- const cb = this.onMachineDetection;
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
- const cbTx = this.onMachineDetection;
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 isTelnyx = this.config.telephonyProvider === "telnyx";
7588
- if (isTelnyx) {
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
- if (model && model !== "gpt-4o-mini-realtime-preview" && model.includes("realtime")) {
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 "gpt-4o-mini-realtime-preview". Pass Patter({ pricing: { openai_realtime: {...} } }) to set rates for this model, otherwise the dashboard cost display will under-report.`
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
- console.log("\n\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");
7608
- getLogger().info(`URL: http://127.0.0.1:${port}/`);
7609
- if (!this.dashboardToken) {
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
- console.log("\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\n");
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 isTelnyx = this.config.telephonyProvider === "telnyx";
9283
+ const provider2 = this.config.telephonyProvider;
7896
9284
  for (const [ws, callId] of this.activeCallIds) {
7897
9285
  try {
7898
- const bridge = isTelnyx ? new TelnyxBridge(this.config) : new TwilioBridge(this.config);
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
- await Promise.race([
7913
- new Promise((resolve2) => {
7914
- const checkInterval = setInterval(() => {
7915
- if (this.activeConnections.size === 0) {
7916
- clearInterval(checkInterval);
7917
- resolve2();
7918
- }
7919
- }, 100);
7920
- }),
7921
- new Promise((resolve2) => setTimeout(resolve2, GRACEFUL_SHUTDOWN_TIMEOUT_MS))
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 result = await invokeHandler(toolDef.handler, args, callContext, onProgress);
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
- signal: AbortSignal.timeout(this.requestTimeoutMs)
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("https://api.openai.com/v1/chat/completions", {
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
- while (true) {
8302
- const { done, value } = await reader.read();
8303
- if (done) break;
8304
- buffer += decoder.decode(value, { stream: true });
8305
- const lines = buffer.split("\n");
8306
- buffer = lines.pop() || "";
8307
- for (const line of lines) {
8308
- const trimmed = line.trim();
8309
- if (!trimmed || !trimmed.startsWith("data: ")) continue;
8310
- const data = trimmed.slice(6);
8311
- if (data === "[DONE]") continue;
8312
- let chunk;
8313
- try {
8314
- chunk = JSON.parse(data);
8315
- } catch {
8316
- continue;
8317
- }
8318
- if (chunk.usage) {
8319
- const cached = chunk.usage.prompt_tokens_details?.cached_tokens ?? 0;
8320
- const uncachedInput = Math.max(0, (chunk.usage.prompt_tokens ?? 0) - cached);
8321
- yield {
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: "tool_call",
8337
- index: tc.index,
8338
- id: tc.id,
8339
- name: tc.function?.name,
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.cacheCreationInputTokens ?? 0
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,