getpatter 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -314,20 +314,36 @@ var init_deepgram_stt = __esm({
314
314
  init_logger();
315
315
  DEEPGRAM_WS_URL = "wss://api.deepgram.com/v1/listen";
316
316
  DeepgramSTT = class _DeepgramSTT {
317
- constructor(apiKey, language = "en", model = "nova-3", encoding = "linear16", sampleRate = 16e3) {
318
- this.apiKey = apiKey;
319
- this.language = language;
320
- this.model = model;
321
- this.encoding = encoding;
322
- this.sampleRate = sampleRate;
323
- }
324
317
  ws = null;
325
318
  callbacks = [];
326
319
  /** Request ID from Deepgram — used to query actual cost post-call. */
327
320
  requestId = "";
328
- /** Factory for Twilio calls — mulaw 8 kHz. */
329
- static forTwilio(apiKey, language = "en", model = "nova-3") {
330
- return new _DeepgramSTT(apiKey, language, model, "mulaw", 8e3);
321
+ apiKey;
322
+ language;
323
+ model;
324
+ encoding;
325
+ sampleRate;
326
+ endpointingMs;
327
+ utteranceEndMs;
328
+ smartFormat;
329
+ interimResults;
330
+ vadEvents;
331
+ constructor(apiKey, languageOrOptions, model, encoding, sampleRate, options) {
332
+ this.apiKey = apiKey;
333
+ const opts = typeof languageOrOptions === "object" && languageOrOptions !== null ? languageOrOptions : options ?? {};
334
+ this.language = (typeof languageOrOptions === "string" ? languageOrOptions : opts.language) ?? "en";
335
+ this.model = model ?? opts.model ?? "nova-3";
336
+ this.encoding = encoding ?? opts.encoding ?? "linear16";
337
+ this.sampleRate = sampleRate ?? opts.sampleRate ?? 16e3;
338
+ this.endpointingMs = opts.endpointingMs ?? 150;
339
+ this.utteranceEndMs = opts.utteranceEndMs === null ? null : opts.utteranceEndMs ?? 1e3;
340
+ this.smartFormat = opts.smartFormat ?? true;
341
+ this.interimResults = opts.interimResults ?? true;
342
+ this.vadEvents = opts.vadEvents ?? true;
343
+ }
344
+ /** Factory for Twilio calls — mulaw 8 kHz. Forwards tuning options through. */
345
+ static forTwilio(apiKey, language = "en", model = "nova-3", options = {}) {
346
+ return new _DeepgramSTT(apiKey, language, model, "mulaw", 8e3, options);
331
347
  }
332
348
  async connect() {
333
349
  const params = new URLSearchParams({
@@ -336,12 +352,15 @@ var init_deepgram_stt = __esm({
336
352
  encoding: this.encoding,
337
353
  sample_rate: String(this.sampleRate),
338
354
  channels: "1",
339
- interim_results: "true",
340
- endpointing: "300",
341
- smart_format: "true",
342
- vad_events: "true",
355
+ interim_results: this.interimResults ? "true" : "false",
356
+ endpointing: String(this.endpointingMs),
357
+ smart_format: this.smartFormat ? "true" : "false",
358
+ vad_events: this.vadEvents ? "true" : "false",
343
359
  no_delay: "true"
344
360
  });
361
+ if (this.utteranceEndMs !== null) {
362
+ params.set("utterance_end_ms", String(Math.max(this.utteranceEndMs, 1e3)));
363
+ }
345
364
  const url = `${DEEPGRAM_WS_URL}?${params.toString()}`;
346
365
  this.ws = new import_ws4.default(url, {
347
366
  headers: { Authorization: `Token ${this.apiKey}` }
@@ -376,7 +395,7 @@ var init_deepgram_stt = __esm({
376
395
  if (!text) return;
377
396
  const transcript = {
378
397
  text,
379
- isFinal: Boolean(data.is_final) && Boolean(data.speech_final),
398
+ isFinal: Boolean(data.is_final) || Boolean(data.speech_final),
380
399
  confidence: best.confidence ?? 0
381
400
  };
382
401
  for (const cb of this.callbacks) {
@@ -616,9 +635,15 @@ var init_store = __esm({
616
635
  maxCalls;
617
636
  calls = [];
618
637
  activeCalls = /* @__PURE__ */ new Map();
619
- constructor(maxCalls = 500) {
638
+ /**
639
+ * Accepts either a numeric ``maxCalls`` (legacy positional — matches the
640
+ * original TS API) or an options object ``{ maxCalls }`` to align with the
641
+ * Python SDK's keyword-argument style. Plain literals also work:
642
+ * ``new MetricsStore()`` / ``new MetricsStore(100)`` / ``new MetricsStore({ maxCalls: 100 })``.
643
+ */
644
+ constructor(maxCallsOrOpts = 500) {
620
645
  super();
621
- this.maxCalls = maxCalls;
646
+ this.maxCalls = typeof maxCallsOrOpts === "number" ? maxCallsOrOpts : maxCallsOrOpts.maxCalls ?? 500;
622
647
  }
623
648
  publish(eventType, data) {
624
649
  this.emit("sse", { type: eventType, data });
@@ -626,22 +651,100 @@ var init_store = __esm({
626
651
  recordCallStart(data) {
627
652
  const callId = data.call_id || "";
628
653
  if (!callId) return;
654
+ const existing = this.activeCalls.get(callId);
655
+ if (existing) {
656
+ existing.caller = data.caller || existing.caller;
657
+ existing.callee = data.callee || existing.callee;
658
+ existing.direction = data.direction || existing.direction;
659
+ existing.status = "in-progress";
660
+ existing.turns = existing.turns || [];
661
+ } else {
662
+ const record = {
663
+ call_id: callId,
664
+ caller: data.caller || "",
665
+ callee: data.callee || "",
666
+ direction: data.direction || "inbound",
667
+ started_at: Date.now() / 1e3,
668
+ status: "in-progress",
669
+ turns: []
670
+ };
671
+ this.activeCalls.set(callId, record);
672
+ }
673
+ this.publish("call_start", {
674
+ call_id: callId,
675
+ caller: data.caller || "",
676
+ callee: data.callee || "",
677
+ direction: data.direction || "inbound"
678
+ });
679
+ }
680
+ /**
681
+ * Pre-register an outbound call before any webhook fires. Lets the
682
+ * dashboard surface attempts that never reach media (no-answer, busy,
683
+ * carrier-rejected). Mirrors the Python ``record_call_initiated``.
684
+ */
685
+ recordCallInitiated(data) {
686
+ const callId = data.call_id || "";
687
+ if (!callId) return;
688
+ if (this.activeCalls.has(callId)) return;
629
689
  const record = {
630
690
  call_id: callId,
631
691
  caller: data.caller || "",
632
692
  callee: data.callee || "",
633
- direction: data.direction || "inbound",
693
+ direction: data.direction || "outbound",
634
694
  started_at: Date.now() / 1e3,
695
+ status: "initiated",
635
696
  turns: []
636
697
  };
637
698
  this.activeCalls.set(callId, record);
638
- this.publish("call_start", {
699
+ this.publish("call_initiated", {
639
700
  call_id: callId,
640
701
  caller: record.caller,
641
702
  callee: record.callee,
642
- direction: record.direction
703
+ direction: record.direction,
704
+ status: record.status
643
705
  });
644
706
  }
707
+ /**
708
+ * Update the status of an active or completed call. Terminal states
709
+ * (completed, no-answer, busy, failed, canceled, webhook_error) move the
710
+ * row from active to completed so the UI freezes the live duration timer.
711
+ */
712
+ updateCallStatus(callId, status, extra = {}) {
713
+ if (!callId || !status) return;
714
+ const TERMINAL = /* @__PURE__ */ new Set(["completed", "no-answer", "busy", "failed", "canceled", "webhook_error"]);
715
+ const active = this.activeCalls.get(callId);
716
+ if (active) {
717
+ active.status = status;
718
+ Object.assign(active, extra);
719
+ if (TERMINAL.has(status)) {
720
+ const entry = {
721
+ call_id: callId,
722
+ caller: active.caller || "",
723
+ callee: active.callee || "",
724
+ direction: active.direction || "outbound",
725
+ started_at: active.started_at || 0,
726
+ ended_at: Date.now() / 1e3,
727
+ status,
728
+ metrics: null,
729
+ ...extra
730
+ };
731
+ this.activeCalls.delete(callId);
732
+ this.calls.push(entry);
733
+ if (this.calls.length > this.maxCalls) {
734
+ this.calls = this.calls.slice(-this.maxCalls);
735
+ }
736
+ }
737
+ } else {
738
+ for (let i = this.calls.length - 1; i >= 0; i--) {
739
+ if (this.calls[i].call_id === callId) {
740
+ this.calls[i].status = status;
741
+ Object.assign(this.calls[i], extra);
742
+ break;
743
+ }
744
+ }
745
+ }
746
+ this.publish("call_status", { call_id: callId, status, ...extra });
747
+ }
645
748
  recordTurn(data) {
646
749
  const callId = data.call_id || "";
647
750
  const turn = data.turn;
@@ -658,6 +761,8 @@ var init_store = __esm({
658
761
  if (!callId) return;
659
762
  const active = this.activeCalls.get(callId);
660
763
  this.activeCalls.delete(callId);
764
+ const activeStatus = active?.status;
765
+ const resolvedStatus = activeStatus && activeStatus !== "in-progress" ? activeStatus : "completed";
661
766
  const entry = {
662
767
  call_id: callId,
663
768
  caller: data.caller || active?.caller || "",
@@ -666,6 +771,7 @@ var init_store = __esm({
666
771
  started_at: active?.started_at || 0,
667
772
  ended_at: Date.now() / 1e3,
668
773
  transcript: data.transcript || [],
774
+ status: resolvedStatus,
669
775
  metrics: metrics ?? null
670
776
  };
671
777
  this.calls.push(entry);
@@ -1867,18 +1973,73 @@ var init_remote_message = __esm({
1867
1973
  });
1868
1974
 
1869
1975
  // src/providers/elevenlabs-tts.ts
1870
- var ELEVENLABS_BASE_URL, ElevenLabsTTS;
1976
+ function resolveVoiceId(voice) {
1977
+ if (!voice) return voice;
1978
+ if (VOICE_ID_PATTERN.test(voice)) return voice;
1979
+ return ELEVENLABS_VOICE_ID_BY_NAME[voice.toLowerCase()] ?? voice;
1980
+ }
1981
+ var ELEVENLABS_BASE_URL, ELEVENLABS_VOICE_ID_BY_NAME, VOICE_ID_PATTERN, ElevenLabsTTS;
1871
1982
  var init_elevenlabs_tts = __esm({
1872
1983
  "src/providers/elevenlabs-tts.ts"() {
1873
1984
  "use strict";
1874
1985
  ELEVENLABS_BASE_URL = "https://api.elevenlabs.io/v1";
1986
+ ELEVENLABS_VOICE_ID_BY_NAME = {
1987
+ rachel: "21m00Tcm4TlvDq8ikWAM",
1988
+ drew: "29vD33N1CtxCmqQRPOHJ",
1989
+ clyde: "2EiwWnXFnvU5JabPnv8n",
1990
+ paul: "5Q0t7uMcjvnagumLfvZi",
1991
+ domi: "AZnzlk1XvdvUeBnXmlld",
1992
+ dave: "CYw3kZ02Hs0563khs1Fj",
1993
+ fin: "D38z5RcWu1voky8WS1ja",
1994
+ bella: "EXAVITQu4vr4xnSDxMaL",
1995
+ antoni: "ErXwobaYiN019PkySvjV",
1996
+ thomas: "GBv7mTt0atIp3Br8iCZE",
1997
+ charlie: "IKne3meq5aSn9XLyUdCD",
1998
+ george: "JBFqnCBsd6RMkjVDRZzb",
1999
+ emily: "LcfcDJNUP1GQjkzn1xUU",
2000
+ elli: "MF3mGyEYCl7XYWbV9V6O",
2001
+ callum: "N2lVS1w4EtoT3dr4eOWO",
2002
+ patrick: "ODq5zmih8GrVes37Dizd",
2003
+ harry: "SOYHLrjzK2X1ezoPC6cr",
2004
+ liam: "TX3LPaxmHKxFdv7VOQHJ",
2005
+ dorothy: "ThT5KcBeYPX3keUQqHPh",
2006
+ josh: "TxGEqnHWrfWFTfGW9XjX",
2007
+ arnold: "VR6AewLTigWG4xSOukaG",
2008
+ charlotte: "XB0fDUnXU5powFXDhCwa",
2009
+ matilda: "XrExE9yKIg1WjnnlVkGX",
2010
+ matthew: "Yko7PKHZNXotIFUBG7I9",
2011
+ james: "ZQe5CZNOzWyzPSCn5a3c",
2012
+ joseph: "Zlb1dXrM653N07WRdFW3",
2013
+ jeremy: "bVMeCyTHy58xNoL34h3p",
2014
+ michael: "flq6f7yk4E4fJM5XTYuZ",
2015
+ ethan: "g5CIjZEefAph4nQFvHAz",
2016
+ gigi: "jBpfuIE2acCO8z3wKNLl",
2017
+ freya: "jsCqWAovK2LkecY7zXl4",
2018
+ brian: "nPczCjzI2devNBz1zQrb",
2019
+ grace: "oWAxZDx7w5VEj9dCyTzz",
2020
+ daniel: "onwK4e9ZLuTAKqWW03F9",
2021
+ lily: "pFZP5JQG7iQjIQuC4Bku",
2022
+ serena: "pMsXgVXv3BLzUgSXRplE",
2023
+ adam: "pNInz6obpgDQGcFmaJgB",
2024
+ nicole: "piTKgcLEGmPE4e6mEKli",
2025
+ bill: "pqHfZKP75CvOlQylNhV4",
2026
+ jessie: "t0jbNlBVZ17f02VDIeMI",
2027
+ ryan: "wViXBPUzp2ZZixB1xQuM",
2028
+ sam: "yoZ06aMxZJJ28mfd3POQ",
2029
+ glinda: "z9fAnlkpzviPz146aGWa",
2030
+ giovanni: "zcAOhNBS3c14rBihAFp1",
2031
+ mimi: "zrHiDhphv9ZnVXBqCLjz",
2032
+ alloy: "21m00Tcm4TlvDq8ikWAM"
2033
+ };
2034
+ VOICE_ID_PATTERN = /^[A-Za-z0-9]{20}$/;
1875
2035
  ElevenLabsTTS = class {
1876
2036
  constructor(apiKey, voiceId = "21m00Tcm4TlvDq8ikWAM", modelId = "eleven_turbo_v2_5", outputFormat = "pcm_16000") {
1877
2037
  this.apiKey = apiKey;
1878
- this.voiceId = voiceId;
1879
2038
  this.modelId = modelId;
1880
2039
  this.outputFormat = outputFormat;
2040
+ this.voiceId = resolveVoiceId(voiceId);
1881
2041
  }
2042
+ voiceId;
1882
2043
  /**
1883
2044
  * Synthesise text to speech and return the full audio as a single Buffer.
1884
2045
  *
@@ -1963,6 +2124,11 @@ var init_openai_tts = __esm({
1963
2124
  *
1964
2125
  * OpenAI returns 24 kHz PCM16; each chunk is resampled to 16 kHz before
1965
2126
  * yielding so the output is ready for telephony pipelines.
2127
+ *
2128
+ * The resampler carries state (buffered samples + odd trailing byte)
2129
+ * between chunks — without that state cross-chunk sample alignment drifts
2130
+ * and the caller hears pops / dropped audio (BUG #23, mirror of the
2131
+ * Python `audioop.ratecv` fix).
1966
2132
  */
1967
2133
  async *synthesizeStream(text) {
1968
2134
  const response = await fetch(OPENAI_TTS_URL, {
@@ -1986,14 +2152,23 @@ var init_openai_tts = __esm({
1986
2152
  if (!response.body) {
1987
2153
  throw new Error("OpenAI TTS: no response body");
1988
2154
  }
2155
+ const ctx = { carryByte: null, leftover: [] };
1989
2156
  const reader = response.body.getReader();
1990
2157
  try {
1991
2158
  while (true) {
1992
2159
  const { done, value } = await reader.read();
1993
2160
  if (done) break;
1994
2161
  if (value && value.length > 0) {
1995
- yield _OpenAITTS.resample24kTo16k(Buffer.from(value));
2162
+ const out = _OpenAITTS.resampleStreaming(Buffer.from(value), ctx);
2163
+ if (out.length > 0) yield out;
2164
+ }
2165
+ }
2166
+ if (ctx.leftover.length > 0) {
2167
+ const tail = Buffer.alloc(ctx.leftover.length * 2);
2168
+ for (let i = 0; i < ctx.leftover.length; i++) {
2169
+ tail.writeInt16LE(ctx.leftover[i], i * 2);
1996
2170
  }
2171
+ yield tail;
1997
2172
  }
1998
2173
  } finally {
1999
2174
  if (typeof reader.cancel === "function") await reader.cancel().catch(() => {
@@ -2002,35 +2177,53 @@ var init_openai_tts = __esm({
2002
2177
  }
2003
2178
  }
2004
2179
  /**
2005
- * Resample 24 kHz PCM16-LE to 16 kHz by taking 2 out of every 3 samples.
2006
- *
2007
- * For each group of 3 input samples the first is kept as-is and the second
2008
- * output sample is the average of input samples 2 and 3. This matches the
2009
- * Python SDK implementation.
2180
+ * Streaming 24 kHz 16 kHz resampler (PCM16-LE). Maintains cross-chunk
2181
+ * state so the 3:2 pattern doesn't reset at every network read.
2010
2182
  */
2011
- static resample24kTo16k(audio) {
2012
- if (audio.length < 2) return audio;
2013
- const sampleCount = Math.floor(audio.length / 2);
2014
- const samples = new Int16Array(sampleCount);
2015
- for (let i = 0; i < sampleCount; i++) {
2016
- samples[i] = audio.readInt16LE(i * 2);
2017
- }
2018
- const resampled = [];
2019
- for (let i = 0; i < samples.length; i += 3) {
2020
- resampled.push(samples[i]);
2021
- if (i + 1 < samples.length) {
2022
- if (i + 2 < samples.length) {
2023
- resampled.push(Math.trunc((samples[i + 1] + samples[i + 2]) / 2));
2024
- } else {
2025
- resampled.push(samples[i + 1]);
2026
- }
2027
- }
2183
+ static resampleStreaming(audio, ctx) {
2184
+ let buf;
2185
+ if (ctx.carryByte !== null) {
2186
+ buf = Buffer.concat([Buffer.from([ctx.carryByte]), audio]);
2187
+ ctx.carryByte = null;
2188
+ } else {
2189
+ buf = audio;
2190
+ }
2191
+ if (buf.length % 2 === 1) {
2192
+ ctx.carryByte = buf[buf.length - 1];
2193
+ buf = buf.subarray(0, buf.length - 1);
2194
+ }
2195
+ if (buf.length === 0 && ctx.leftover.length === 0) {
2196
+ return Buffer.alloc(0);
2197
+ }
2198
+ const sampleCount = buf.length / 2;
2199
+ const samples = ctx.leftover.slice();
2200
+ for (let i2 = 0; i2 < sampleCount; i2++) {
2201
+ samples.push(buf.readInt16LE(i2 * 2));
2028
2202
  }
2029
- const out = Buffer.alloc(resampled.length * 2);
2030
- for (let i = 0; i < resampled.length; i++) {
2031
- out.writeInt16LE(resampled[i], i * 2);
2203
+ const out = [];
2204
+ let i = 0;
2205
+ while (i + 2 < samples.length) {
2206
+ out.push(samples[i]);
2207
+ out.push(Math.trunc((samples[i + 1] + samples[i + 2]) / 2));
2208
+ i += 3;
2032
2209
  }
2033
- return out;
2210
+ ctx.leftover = samples.slice(i);
2211
+ const buffer = Buffer.alloc(out.length * 2);
2212
+ for (let j = 0; j < out.length; j++) {
2213
+ buffer.writeInt16LE(out[j], j * 2);
2214
+ }
2215
+ return buffer;
2216
+ }
2217
+ /** @deprecated use {@link resampleStreaming} with persistent state. */
2218
+ static resample24kTo16k(audio) {
2219
+ const ctx = { carryByte: null, leftover: [] };
2220
+ const out = _OpenAITTS.resampleStreaming(audio, ctx);
2221
+ if (ctx.leftover.length === 0) return out;
2222
+ const tail = Buffer.alloc(ctx.leftover.length * 2);
2223
+ for (let i = 0; i < ctx.leftover.length; i++) {
2224
+ tail.writeInt16LE(ctx.leftover[i], i * 2);
2225
+ }
2226
+ return Buffer.concat([out, tail]);
2034
2227
  }
2035
2228
  };
2036
2229
  }
@@ -2972,6 +3165,9 @@ var init_stream_handler = __esm({
2972
3165
  maxDurationTimer = null;
2973
3166
  transcriptProcessing = false;
2974
3167
  transcriptQueue = [];
3168
+ // BUG #22 throttle state — mirror Python impl.
3169
+ lastCommitText = "";
3170
+ lastCommitAt = 0;
2975
3171
  history;
2976
3172
  metricsAcc;
2977
3173
  constructor(deps, ws, caller, callee) {
@@ -3082,15 +3278,23 @@ var init_stream_handler = __esm({
3082
3278
  this.streamSid = sid;
3083
3279
  }
3084
3280
  /** Handle an incoming audio chunk (already decoded from base64). */
3085
- handleAudio(audioBuffer) {
3281
+ async handleAudio(audioBuffer) {
3086
3282
  const provider = this.deps.agent.provider ?? "openai_realtime";
3087
- if (provider === "pipeline" && this.stt && !this.isSpeaking) {
3088
- if (this.deps.bridge.telephonyProvider === "twilio") {
3089
- const pcm8k = mulawToPcm16(audioBuffer);
3090
- const pcm16k = resample8kTo16k(pcm8k);
3091
- this.stt.sendAudio(pcm16k);
3283
+ if (provider === "pipeline" && this.stt) {
3284
+ if (this.isSpeaking && (this.deps.agent.bargeInThresholdMs ?? 300) === 0) {
3285
+ return;
3286
+ }
3287
+ const pcm8k = mulawToPcm16(audioBuffer);
3288
+ const pcm16k = resample8kTo16k(pcm8k);
3289
+ const hooks = this.deps.agent.hooks;
3290
+ if (hooks) {
3291
+ const hookExecutor = new PipelineHookExecutor(hooks);
3292
+ const hookCtx = this.buildHookContext();
3293
+ const processed = await hookExecutor.runBeforeSendToStt(pcm16k, hookCtx);
3294
+ if (processed === null) return;
3295
+ this.stt.sendAudio(processed);
3092
3296
  } else {
3093
- this.stt.sendAudio(audioBuffer);
3297
+ this.stt.sendAudio(pcm16k);
3094
3298
  }
3095
3299
  } else if (this.adapter) {
3096
3300
  if (this.adapter instanceof ElevenLabsConvAIAdapter && this.deps.bridge.telephonyProvider === "twilio") {
@@ -3172,8 +3376,7 @@ var init_stream_handler = __esm({
3172
3376
  this.tts = new OpenAITTS(this.deps.agent.tts.apiKey, this.deps.agent.tts.voice ?? "alloy");
3173
3377
  }
3174
3378
  } else if (this.deps.agent.elevenlabsKey) {
3175
- const voiceId = this.deps.agent.voice && this.deps.agent.voice !== "alloy" ? this.deps.agent.voice : "21m00Tcm4TlvDq8ikWAM";
3176
- this.tts = new ElevenLabsTTS(this.deps.agent.elevenlabsKey, voiceId);
3379
+ this.tts = new ElevenLabsTTS(this.deps.agent.elevenlabsKey, this.deps.agent.voice || "rachel");
3177
3380
  }
3178
3381
  if (!this.stt) {
3179
3382
  getLogger().info(`Pipeline mode (${label}): no STT configured`);
@@ -3285,7 +3488,59 @@ var init_stream_handler = __esm({
3285
3488
  }
3286
3489
  }
3287
3490
  async processTranscript(transcript) {
3491
+ if (transcript.text && this.isSpeaking) {
3492
+ getLogger().info(
3493
+ `Barge-in: caller spoke over agent (${sanitizeLogValue(transcript.text.slice(0, 40))})`
3494
+ );
3495
+ this.isSpeaking = false;
3496
+ try {
3497
+ this.deps.bridge.sendClear(this.ws, this.streamSid);
3498
+ } catch (err) {
3499
+ getLogger().debug(`sendClear during barge-in failed: ${String(err)}`);
3500
+ }
3501
+ this.metricsAcc.recordTurnInterrupted();
3502
+ }
3288
3503
  if (!transcript.isFinal || !transcript.text) return;
3504
+ const now = Date.now();
3505
+ const normalised = transcript.text.trim().toLowerCase();
3506
+ const stripped = normalised.replace(/[.,!?;: ]+$/, "").trim();
3507
+ const sinceLastMs = now - this.lastCommitAt;
3508
+ const HALLUCINATIONS = /* @__PURE__ */ new Set([
3509
+ "you",
3510
+ "thank you",
3511
+ "thanks",
3512
+ "yeah",
3513
+ "yes",
3514
+ "no",
3515
+ "okay",
3516
+ "ok",
3517
+ "uh",
3518
+ "um",
3519
+ "mmm",
3520
+ "hmm",
3521
+ ".",
3522
+ "bye",
3523
+ "right",
3524
+ "cool"
3525
+ ]);
3526
+ if (HALLUCINATIONS.has(stripped) || stripped === "") {
3527
+ getLogger().info(`Dropped likely STT hallucination: ${sanitizeLogValue(normalised.slice(0, 40))}`);
3528
+ return;
3529
+ }
3530
+ if (sinceLastMs < 2e3 && normalised === this.lastCommitText) {
3531
+ getLogger().info(
3532
+ `Dropped duplicate final transcript (${(sinceLastMs / 1e3).toFixed(1)}s since last): ${sanitizeLogValue(normalised.slice(0, 40))}`
3533
+ );
3534
+ return;
3535
+ }
3536
+ if (sinceLastMs < 500) {
3537
+ getLogger().info(
3538
+ `Dropped back-to-back final transcript (${(sinceLastMs / 1e3).toFixed(2)}s since last): ${sanitizeLogValue(normalised.slice(0, 40))}`
3539
+ );
3540
+ return;
3541
+ }
3542
+ this.lastCommitText = normalised;
3543
+ this.lastCommitAt = now;
3289
3544
  const label = this.deps.bridge.label;
3290
3545
  getLogger().info(`User (${label} pipeline): ${sanitizeLogValue(transcript.text)}`);
3291
3546
  this.metricsAcc.startTurn();
@@ -3770,6 +4025,25 @@ function buildAIAdapter(config, agent, resolvedPrompt) {
3770
4025
  tools
3771
4026
  );
3772
4027
  }
4028
+ function extractDeepgramOptions(options) {
4029
+ if (!options) return {};
4030
+ const get = (snake, camel) => options[snake] ?? options[camel];
4031
+ const out = {};
4032
+ const model = get("model", "model");
4033
+ if (typeof model === "string") out.model = model;
4034
+ const endpointing = get("endpointing_ms", "endpointingMs");
4035
+ if (typeof endpointing === "number") out.endpointingMs = endpointing;
4036
+ const utteranceEnd = get("utterance_end_ms", "utteranceEndMs");
4037
+ if (utteranceEnd === null) out.utteranceEndMs = null;
4038
+ else if (typeof utteranceEnd === "number") out.utteranceEndMs = utteranceEnd;
4039
+ const smart = get("smart_format", "smartFormat");
4040
+ if (typeof smart === "boolean") out.smartFormat = smart;
4041
+ const interim = get("interim_results", "interimResults");
4042
+ if (typeof interim === "boolean") out.interimResults = interim;
4043
+ const vad = get("vad_events", "vadEvents");
4044
+ if (typeof vad === "boolean") out.vadEvents = vad;
4045
+ return out;
4046
+ }
3773
4047
  function isValidTelnyxTransferTarget(target) {
3774
4048
  if (typeof target !== "string" || !target) return false;
3775
4049
  if (/^\+[1-9]\d{6,14}$/.test(target)) return true;
@@ -3875,13 +4149,21 @@ var init_server = __esm({
3875
4149
  }
3876
4150
  }
3877
4151
  createStt(agent) {
4152
+ const isPipeline = agent.provider === "pipeline";
3878
4153
  if (agent.stt) {
3879
4154
  if (agent.stt.provider === "deepgram") {
3880
- return DeepgramSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en");
4155
+ const dgOptions = extractDeepgramOptions(agent.stt.options);
4156
+ if (isPipeline) {
4157
+ return new DeepgramSTT(agent.stt.apiKey, agent.stt.language ?? "en", dgOptions.model, "linear16", 16e3, dgOptions);
4158
+ }
4159
+ return DeepgramSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en", dgOptions.model, dgOptions);
3881
4160
  } else if (agent.stt.provider === "whisper") {
3882
- return WhisperSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en");
4161
+ return isPipeline ? new WhisperSTT(agent.stt.apiKey, "whisper-1", agent.stt.language ?? "en") : WhisperSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en");
3883
4162
  }
3884
4163
  } else if (agent.deepgramKey) {
4164
+ if (isPipeline) {
4165
+ return new DeepgramSTT(agent.deepgramKey, agent.language ?? "en", "nova-3", "linear16", 16e3);
4166
+ }
3885
4167
  return DeepgramSTT.forTwilio(agent.deepgramKey, agent.language ?? "en");
3886
4168
  }
3887
4169
  return null;
@@ -3923,12 +4205,12 @@ var init_server = __esm({
3923
4205
  label = "Telnyx";
3924
4206
  telephonyProvider = "telnyx";
3925
4207
  sendAudio(ws, audioBase64, _streamSid) {
3926
- ws.send(JSON.stringify({ event_type: "media", payload: { audio: { chunk: audioBase64 } } }));
4208
+ ws.send(JSON.stringify({ event: "media", media: { payload: audioBase64 } }));
3927
4209
  }
3928
4210
  sendMark(_ws, _markName, _streamSid) {
3929
4211
  }
3930
4212
  sendClear(ws, _streamSid) {
3931
- ws.send(JSON.stringify({ event_type: "media_stop" }));
4213
+ ws.send(JSON.stringify({ event: "clear" }));
3932
4214
  }
3933
4215
  async transferCall(callId, toNumber) {
3934
4216
  if (!isValidTelnyxTransferTarget(toNumber)) {
@@ -4024,7 +4306,15 @@ var init_server = __esm({
4024
4306
  createStt(agent) {
4025
4307
  if (agent.stt) {
4026
4308
  if (agent.stt.provider === "deepgram") {
4027
- return new DeepgramSTT(agent.stt.apiKey, agent.stt.language ?? "en", "nova-3", "linear16", 16e3);
4309
+ const dgOptions = extractDeepgramOptions(agent.stt.options);
4310
+ return new DeepgramSTT(
4311
+ agent.stt.apiKey,
4312
+ agent.stt.language ?? "en",
4313
+ dgOptions.model ?? "nova-3",
4314
+ "linear16",
4315
+ 16e3,
4316
+ dgOptions
4317
+ );
4028
4318
  } else if (agent.stt.provider === "whisper") {
4029
4319
  return new WhisperSTT(agent.stt.apiKey, "whisper-1", agent.stt.language ?? "en");
4030
4320
  }
@@ -4076,6 +4366,7 @@ var init_server = __esm({
4076
4366
  server = null;
4077
4367
  wss = null;
4078
4368
  twilioTokenWarningLogged = false;
4369
+ telnyxSigWarningLogged = false;
4079
4370
  metricsStore;
4080
4371
  pricing;
4081
4372
  remoteHandler = new RemoteMessageHandler();
@@ -4123,6 +4414,31 @@ var init_server = __esm({
4123
4414
  mountApi(app, this.metricsStore, this.dashboardToken);
4124
4415
  getLogger().info("Dashboard: http://127.0.0.1:" + port + "/");
4125
4416
  }
4417
+ app.post("/webhooks/twilio/status", (req, res) => {
4418
+ if (this.config.twilioToken) {
4419
+ const signature = req.headers["x-twilio-signature"] || "";
4420
+ const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
4421
+ const params = req.body ?? {};
4422
+ if (!validateTwilioSignature(url, params, signature, this.config.twilioToken)) {
4423
+ res.status(403).send("Invalid signature");
4424
+ return;
4425
+ }
4426
+ }
4427
+ const body = req.body;
4428
+ const callSid = sanitizeLogValue(body["CallSid"] ?? "");
4429
+ const callStatus = sanitizeLogValue(body["CallStatus"] ?? "");
4430
+ const duration = body["CallDuration"] ?? body["Duration"] ?? "";
4431
+ getLogger().info(
4432
+ `Twilio status ${callStatus} for call ${callSid} (duration=${duration})`
4433
+ );
4434
+ if (callSid && callStatus) {
4435
+ const extra = {};
4436
+ const parsed = parseFloat(duration);
4437
+ if (!Number.isNaN(parsed)) extra.duration_seconds = parsed;
4438
+ this.metricsStore.updateCallStatus(callSid, callStatus, extra);
4439
+ }
4440
+ res.status(204).send();
4441
+ });
4126
4442
  app.post("/webhooks/twilio/recording", (req, res) => {
4127
4443
  if (this.config.twilioToken) {
4128
4444
  const signature = req.headers["x-twilio-signature"] || "";
@@ -4208,7 +4524,7 @@ var init_server = __esm({
4208
4524
  const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${xmlStreamUrl}"><Parameter name="caller" value="${xmlEscape(caller)}"/><Parameter name="callee" value="${xmlEscape(callee)}"/></Stream></Connect></Response>`;
4209
4525
  res.type("text/xml").send(twiml);
4210
4526
  });
4211
- app.post("/webhooks/telnyx/voice", (req, res) => {
4527
+ app.post("/webhooks/telnyx/voice", async (req, res) => {
4212
4528
  if (this.config.telnyxPublicKey) {
4213
4529
  const rawBody = req.rawBody ?? "";
4214
4530
  const signature = req.headers["telnyx-signature-ed25519"] ?? "";
@@ -4217,7 +4533,8 @@ var init_server = __esm({
4217
4533
  getLogger().warn("Telnyx webhook rejected: invalid or missing Ed25519 signature");
4218
4534
  return res.status(403).send("Invalid signature");
4219
4535
  }
4220
- } else {
4536
+ } else if (!this.telnyxSigWarningLogged) {
4537
+ this.telnyxSigWarningLogged = true;
4221
4538
  getLogger().warn("Telnyx webhook signature verification is disabled. Set telnyxPublicKey in LocalOptions for production use.");
4222
4539
  }
4223
4540
  const body = req.body;
@@ -4227,41 +4544,77 @@ var init_server = __esm({
4227
4544
  if (typeof body.data.event_type !== "string" || typeof body.data.payload !== "object" || body.data.payload === null) {
4228
4545
  return res.status(400).send("Invalid body");
4229
4546
  }
4230
- const eventType = body?.data?.event_type ?? "";
4547
+ const eventType = body.data.event_type ?? "";
4548
+ const payload = body.data.payload ?? {};
4231
4549
  if (eventType === "call.dtmf.received") {
4232
- const digit = String(body.data?.payload?.digit ?? "").trim();
4550
+ const digit = String(payload.digit ?? "").trim();
4233
4551
  if (digit) {
4234
4552
  getLogger().info(`Telnyx DTMF received (webhook): ${sanitizeLogValue(digit)}`);
4235
4553
  }
4236
- return res.json({ received: true });
4554
+ return res.status(200).send();
4237
4555
  }
4238
4556
  if (eventType === "call.recording.saved") {
4239
- const recordingUrl = body.data?.payload?.recording_urls?.mp3 ?? body.data?.payload?.recording_urls?.wav ?? body.data?.payload?.public_recording_urls?.mp3 ?? "";
4557
+ const recordingUrl = payload.recording_urls?.mp3 ?? payload.recording_urls?.wav ?? payload.public_recording_urls?.mp3 ?? "";
4240
4558
  if (recordingUrl) {
4241
4559
  getLogger().info(`Telnyx recording saved (webhook): ${sanitizeLogValue(recordingUrl)}`);
4242
4560
  }
4243
- return res.json({ received: true });
4244
- }
4245
- if (eventType === "call.initiated") {
4246
- const payload = body?.data?.payload ?? {};
4247
- const callControlId = payload.call_control_id ?? "";
4248
- const caller = payload.from ?? "";
4249
- const callee = payload.to ?? "";
4250
- const streamUrl = `wss://${this.config.webhookUrl}/ws/stream/${encodeURIComponent(callControlId)}?caller=${encodeURIComponent(caller)}&callee=${encodeURIComponent(callee)}`;
4251
- const commands = [
4252
- { command: "answer" },
4253
- {
4254
- command: "stream_start",
4255
- params: {
4561
+ return res.status(200).send();
4562
+ }
4563
+ const callControlId = payload.call_control_id ?? "";
4564
+ if (!callControlId) {
4565
+ getLogger().warn("Telnyx webhook rejected: missing call_control_id");
4566
+ return res.status(400).send("Invalid webhook payload");
4567
+ }
4568
+ const apiKey = this.config.telnyxKey;
4569
+ if (!apiKey) {
4570
+ getLogger().warn("Telnyx webhook: missing telnyxKey in LocalOptions");
4571
+ return res.status(500).send("Missing Telnyx API key");
4572
+ }
4573
+ const apiBase = "https://api.telnyx.com/v2";
4574
+ const authHeaders = {
4575
+ "Content-Type": "application/json",
4576
+ Authorization: `Bearer ${apiKey}`
4577
+ };
4578
+ try {
4579
+ if (eventType === "call.initiated") {
4580
+ getLogger().info(`Telnyx call.initiated ${callControlId} \u2014 answering`);
4581
+ const resp = await fetch(`${apiBase}/calls/${encodeURIComponent(callControlId)}/actions/answer`, {
4582
+ method: "POST",
4583
+ headers: authHeaders,
4584
+ body: JSON.stringify({}),
4585
+ signal: AbortSignal.timeout(1e4)
4586
+ });
4587
+ if (!resp.ok) {
4588
+ getLogger().warn(`Telnyx answer failed: ${resp.status} ${(await resp.text()).slice(0, 200)}`);
4589
+ }
4590
+ } else if (eventType === "call.answered") {
4591
+ const caller = payload.from ?? "";
4592
+ const callee = payload.to ?? "";
4593
+ const streamUrl = `wss://${this.config.webhookUrl}/ws/stream/${encodeURIComponent(callControlId)}?caller=${encodeURIComponent(caller)}&callee=${encodeURIComponent(callee)}`;
4594
+ getLogger().info(`Telnyx call.answered ${callControlId} \u2014 starting stream`);
4595
+ const resp = await fetch(`${apiBase}/calls/${encodeURIComponent(callControlId)}/actions/streaming_start`, {
4596
+ method: "POST",
4597
+ headers: authHeaders,
4598
+ body: JSON.stringify({
4256
4599
  stream_url: streamUrl,
4257
- stream_track: "both_tracks"
4258
- }
4600
+ stream_track: "both_tracks",
4601
+ stream_bidirectional_mode: "rtp",
4602
+ stream_bidirectional_codec: "PCMU",
4603
+ stream_bidirectional_sampling_rate: 8e3,
4604
+ stream_bidirectional_target_legs: "self"
4605
+ }),
4606
+ signal: AbortSignal.timeout(1e4)
4607
+ });
4608
+ if (!resp.ok) {
4609
+ getLogger().warn(`Telnyx streaming_start failed: ${resp.status} ${(await resp.text()).slice(0, 200)}`);
4259
4610
  }
4260
- ];
4261
- res.json({ commands });
4262
- } else {
4263
- res.json({ received: true });
4611
+ } else {
4612
+ getLogger().debug(`Telnyx event ignored: ${eventType}`);
4613
+ }
4614
+ } catch (e) {
4615
+ getLogger().error(`Telnyx webhook handler error: ${String(e)}`);
4264
4616
  }
4617
+ return res.status(200).send();
4265
4618
  });
4266
4619
  this.server = (0, import_http.createServer)(app);
4267
4620
  this.wss = new import_ws5.WebSocketServer({ noServer: true });
@@ -4408,11 +4761,12 @@ Connect AI agents to phone numbers in 4 lines of code
4408
4761
  getLogger().error("Failed to parse Telnyx WS message:", e);
4409
4762
  return;
4410
4763
  }
4411
- const eventType = data.event_type ?? "";
4412
- getLogger().info(`Telnyx event: ${eventType}`);
4413
- if (eventType === "stream_started" && !streamStarted) {
4764
+ const event = data.event ?? "";
4765
+ if (event === "connected") return;
4766
+ getLogger().info(`Telnyx event: ${event}`);
4767
+ if (event === "start" && !streamStarted) {
4414
4768
  streamStarted = true;
4415
- const callControlId = data.payload?.call_control_id ?? "";
4769
+ const callControlId = data.start?.call_control_id ?? "";
4416
4770
  if (callControlId) this.activeCallIds.set(ws, callControlId);
4417
4771
  await handler.handleCallStart(callControlId);
4418
4772
  if (this.recording) {
@@ -4422,22 +4776,21 @@ Connect AI agents to phone numbers in 4 lines of code
4422
4776
  getLogger().warn(`Could not start recording: ${String(e)}`);
4423
4777
  }
4424
4778
  }
4425
- } else if (eventType === "media") {
4426
- const audioChunk = data.payload?.audio?.chunk ?? "";
4779
+ } else if (event === "media") {
4780
+ const track = data.media?.track ?? "inbound";
4781
+ if (track !== "inbound") return;
4782
+ const audioChunk = data.media?.payload ?? "";
4427
4783
  if (!audioChunk) return;
4428
4784
  handler.handleAudio(Buffer.from(audioChunk, "base64"));
4429
- } else if (eventType === "call.dtmf.received") {
4430
- const digit = String(data.payload?.digit ?? "").trim();
4785
+ } else if (event === "dtmf") {
4786
+ const digit = String(data.dtmf?.digit ?? "").trim();
4431
4787
  if (digit) {
4432
4788
  getLogger().info(`Telnyx DTMF received: ${digit}`);
4433
4789
  await handler.handleDtmf(digit);
4434
4790
  }
4435
- } else if (eventType === "call.recording.saved") {
4436
- const recordingUrl = data.payload?.recording_urls?.mp3 ?? data.payload?.recording_urls?.wav ?? data.payload?.public_recording_urls?.mp3 ?? "";
4437
- if (recordingUrl) {
4438
- getLogger().info(`Telnyx recording saved: ${recordingUrl}`);
4439
- }
4440
- } else if (eventType === "stream_stopped") {
4791
+ } else if (event === "error") {
4792
+ getLogger().warn(`Telnyx stream error: ${JSON.stringify(data)}`);
4793
+ } else if (event === "stop") {
4441
4794
  await handler.handleStop();
4442
4795
  }
4443
4796
  } catch (err) {
@@ -6748,13 +7101,21 @@ var STTConfigImpl = class {
6748
7101
  provider;
6749
7102
  apiKey;
6750
7103
  language;
6751
- constructor(provider, apiKey, language = "en") {
7104
+ options;
7105
+ constructor(provider, apiKey, language = "en", options) {
6752
7106
  this.provider = provider;
6753
7107
  this.apiKey = apiKey;
6754
7108
  this.language = language;
7109
+ if (options) this.options = options;
6755
7110
  }
6756
7111
  toDict() {
6757
- return { provider: this.provider, api_key: this.apiKey, language: this.language };
7112
+ const out = {
7113
+ provider: this.provider,
7114
+ api_key: this.apiKey,
7115
+ language: this.language
7116
+ };
7117
+ if (this.options) out.options = { ...this.options };
7118
+ return out;
6758
7119
  }
6759
7120
  };
6760
7121
  var TTSConfigImpl = class {
@@ -6771,7 +7132,15 @@ var TTSConfigImpl = class {
6771
7132
  }
6772
7133
  };
6773
7134
  function deepgram(opts) {
6774
- return new STTConfigImpl("deepgram", opts.apiKey, opts.language ?? "en");
7135
+ const options = {
7136
+ model: opts.model ?? "nova-3",
7137
+ endpointing_ms: opts.endpointingMs ?? 150,
7138
+ utterance_end_ms: opts.utteranceEndMs === null ? null : opts.utteranceEndMs ?? 1e3,
7139
+ smart_format: opts.smartFormat ?? true,
7140
+ interim_results: opts.interimResults ?? true
7141
+ };
7142
+ if (opts.vadEvents !== void 0) options.vad_events = opts.vadEvents;
7143
+ return new STTConfigImpl("deepgram", opts.apiKey, opts.language ?? "en", options);
6775
7144
  }
6776
7145
  function whisper(opts) {
6777
7146
  return new STTConfigImpl("whisper", opts.apiKey, opts.language ?? "en");
@@ -6782,11 +7151,42 @@ function elevenlabs(opts) {
6782
7151
  function openaiTts(opts) {
6783
7152
  return new TTSConfigImpl("openai", opts.apiKey, opts.voice ?? "alloy");
6784
7153
  }
7154
+ function cartesia(opts) {
7155
+ return new TTSConfigImpl(
7156
+ "cartesia",
7157
+ opts.apiKey,
7158
+ opts.voice ?? "f786b574-daa5-4673-aa0c-cbe3e8534c02"
7159
+ );
7160
+ }
7161
+ function rime(opts) {
7162
+ return new TTSConfigImpl("rime", opts.apiKey, opts.voice ?? "astra");
7163
+ }
7164
+ function lmnt(opts) {
7165
+ return new TTSConfigImpl("lmnt", opts.apiKey, opts.voice ?? "leah");
7166
+ }
6785
7167
 
6786
7168
  // src/client.ts
6787
7169
  init_server();
6788
7170
  var DEFAULT_BACKEND_URL2 = "wss://api.getpatter.com";
6789
7171
  var DEFAULT_REST_URL = "https://api.getpatter.com";
7172
+ function sttConfigToDict(cfg) {
7173
+ const out = {
7174
+ provider: cfg.provider,
7175
+ api_key: cfg.apiKey,
7176
+ language: cfg.language
7177
+ };
7178
+ if (cfg.options) out.options = { ...cfg.options };
7179
+ return out;
7180
+ }
7181
+ function ttsConfigToDict(cfg) {
7182
+ const out = {
7183
+ provider: cfg.provider,
7184
+ api_key: cfg.apiKey,
7185
+ voice: cfg.voice
7186
+ };
7187
+ if (cfg.options) out.options = { ...cfg.options };
7188
+ return out;
7189
+ }
6790
7190
  var Patter = class {
6791
7191
  apiKey;
6792
7192
  backendUrl;
@@ -6797,7 +7197,8 @@ var Patter = class {
6797
7197
  embeddedServer = null;
6798
7198
  tunnelHandle = null;
6799
7199
  constructor(options) {
6800
- if ("mode" in options && options.mode === "local") {
7200
+ const isLocal = "mode" in options && options.mode === "local" || !("apiKey" in options) && ("twilioSid" in options && options.twilioSid || "telnyxKey" in options && options.telnyxKey);
7201
+ if (isLocal) {
6801
7202
  const local = options;
6802
7203
  if (!local.phoneNumber) {
6803
7204
  throw new Error("Local mode requires phoneNumber");
@@ -6957,23 +7358,42 @@ var Patter = class {
6957
7358
  const telnyxKey = this.localConfig.telnyxKey ?? "";
6958
7359
  const connectionId = this.localConfig.telnyxConnectionId ?? "";
6959
7360
  const streamUrl = `wss://${webhookUrl}/ws/stream/${encodeURIComponent(localOpts.to)}?caller=${encodeURIComponent(phoneNumber)}&callee=${encodeURIComponent(localOpts.to)}`;
7361
+ const telnyxPayload = {
7362
+ connection_id: connectionId,
7363
+ from: phoneNumber,
7364
+ to: localOpts.to,
7365
+ stream_url: streamUrl,
7366
+ stream_track: "both_tracks"
7367
+ };
7368
+ if (localOpts.ringTimeout !== void 0) {
7369
+ telnyxPayload.timeout_secs = Math.max(1, Math.floor(localOpts.ringTimeout));
7370
+ }
6960
7371
  const response2 = await fetch("https://api.telnyx.com/v2/calls", {
6961
7372
  method: "POST",
6962
7373
  headers: {
6963
7374
  "Content-Type": "application/json",
6964
7375
  Authorization: `Bearer ${telnyxKey}`
6965
7376
  },
6966
- body: JSON.stringify({
6967
- connection_id: connectionId,
6968
- from: phoneNumber,
6969
- to: localOpts.to,
6970
- stream_url: streamUrl,
6971
- stream_track: "both_tracks"
6972
- })
7377
+ body: JSON.stringify(telnyxPayload)
6973
7378
  });
6974
7379
  if (!response2.ok) {
6975
7380
  throw new ProvisionError(`Failed to initiate Telnyx call: ${await response2.text()}`);
6976
7381
  }
7382
+ if (this.embeddedServer) {
7383
+ try {
7384
+ const body = await response2.clone().json();
7385
+ const callId = body.data?.call_control_id;
7386
+ if (callId) {
7387
+ this.embeddedServer.metricsStore.recordCallInitiated({
7388
+ call_id: callId,
7389
+ caller: phoneNumber,
7390
+ callee: localOpts.to,
7391
+ direction: "outbound"
7392
+ });
7393
+ }
7394
+ } catch {
7395
+ }
7396
+ }
6977
7397
  return;
6978
7398
  }
6979
7399
  const twilioSid = this.localConfig.twilioSid ?? "";
@@ -6985,13 +7405,19 @@ var Patter = class {
6985
7405
  From: phoneNumber,
6986
7406
  Url: `https://${webhookUrl}/webhooks/twilio/voice`,
6987
7407
  StatusCallback: statusCallbackUrl,
6988
- StatusCallbackMethod: "POST"
7408
+ StatusCallbackMethod: "POST",
7409
+ // Full lifecycle so the dashboard sees ringing/no-answer/busy/failed
7410
+ // transitions even when media never arrives.
7411
+ StatusCallbackEvent: "initiated ringing answered completed"
6989
7412
  });
6990
7413
  if (localOpts.machineDetection) {
6991
7414
  params.append("MachineDetection", "DetectMessageEnd");
6992
7415
  params.append("AsyncAmd", "true");
6993
7416
  params.append("AsyncAmdStatusCallback", `https://${webhookUrl}/webhooks/twilio/amd`);
6994
7417
  }
7418
+ if (localOpts.ringTimeout !== void 0) {
7419
+ params.append("Timeout", String(Math.max(1, Math.floor(localOpts.ringTimeout))));
7420
+ }
6995
7421
  if (localOpts.voicemailMessage && this.embeddedServer) {
6996
7422
  this.embeddedServer.voicemailMessage = localOpts.voicemailMessage;
6997
7423
  }
@@ -7006,6 +7432,21 @@ var Patter = class {
7006
7432
  if (!response.ok) {
7007
7433
  throw new ProvisionError(`Failed to initiate call: ${await response.text()}`);
7008
7434
  }
7435
+ if (this.embeddedServer) {
7436
+ try {
7437
+ const body = await response.clone().json();
7438
+ const callSid = body.sid;
7439
+ if (callSid) {
7440
+ this.embeddedServer.metricsStore.recordCallInitiated({
7441
+ call_id: callSid,
7442
+ caller: phoneNumber,
7443
+ callee: localOpts.to,
7444
+ direction: "outbound"
7445
+ });
7446
+ }
7447
+ } catch {
7448
+ }
7449
+ }
7009
7450
  return;
7010
7451
  }
7011
7452
  const cloudOpts = options;
@@ -7088,11 +7529,15 @@ var Patter = class {
7088
7529
  const data = await response.json();
7089
7530
  return data.map((c) => ({ id: c.id, direction: c.direction, caller: c.caller, callee: c.callee, startedAt: c.started_at, endedAt: c.ended_at, durationSeconds: c.duration_seconds, status: c.status, transcript: c.transcript }));
7090
7531
  }
7091
- // Provider helpers
7532
+ // Provider helpers — mirror the Python classmethod factories so callers can
7533
+ // write ``Patter.deepgram({ apiKey })`` without importing the top-level.
7092
7534
  static deepgram = deepgram;
7093
7535
  static whisper = whisper;
7094
7536
  static elevenlabs = elevenlabs;
7095
7537
  static openaiTts = openaiTts;
7538
+ static cartesia = cartesia;
7539
+ static rime = rime;
7540
+ static lmnt = lmnt;
7096
7541
  static guardrail(opts) {
7097
7542
  return {
7098
7543
  name: opts.name,
@@ -7158,8 +7603,8 @@ var Patter = class {
7158
7603
  provider,
7159
7604
  provider_credentials: credentials,
7160
7605
  country,
7161
- stt_config: stt?.toDict() ?? null,
7162
- tts_config: tts?.toDict() ?? null
7606
+ stt_config: stt ? stt.toDict?.() ?? sttConfigToDict(stt) : null,
7607
+ tts_config: tts ? tts.toDict?.() ?? ttsConfigToDict(tts) : null
7163
7608
  })
7164
7609
  });
7165
7610
  if (response.status === 409) return;
@@ -7293,6 +7738,37 @@ var FallbackLLMProvider = class {
7293
7738
  }
7294
7739
  }
7295
7740
  }
7741
+ /**
7742
+ * Async-friendly disposer. Parity with Python's ``FallbackLLMProvider.aclose()``
7743
+ * — safe to call multiple times, returns a resolved Promise once all probe
7744
+ * timers are cleared. Prefer this in async contexts so awaiting the
7745
+ * shutdown integrates naturally with the owning lifecycle.
7746
+ */
7747
+ async aclose() {
7748
+ this.destroy();
7749
+ }
7750
+ /**
7751
+ * Explicit-resource-management hook so callers can write
7752
+ * ``await using fallback = new FallbackLLMProvider([...])`` and have
7753
+ * background probe timers cleared automatically when the block exits.
7754
+ * Mirrors Python's ``async with FallbackLLMProvider(...)``.
7755
+ */
7756
+ async [Symbol.asyncDispose]() {
7757
+ await this.aclose();
7758
+ }
7759
+ /**
7760
+ * Stream only the text deltas, flattening the chunk envelope. Parity with
7761
+ * Python's ``FallbackLLMProvider.complete_stream``. Tool-call and done
7762
+ * markers are filtered out so callers can concatenate the yielded strings
7763
+ * directly.
7764
+ */
7765
+ async *completeStream(messages, tools) {
7766
+ for await (const chunk of this.stream(messages, tools)) {
7767
+ if (chunk.type === "text") {
7768
+ yield chunk.content ?? "";
7769
+ }
7770
+ }
7771
+ }
7296
7772
  // -----------------------------------------------------------------------
7297
7773
  // LLMProvider implementation
7298
7774
  // -----------------------------------------------------------------------
@@ -7815,13 +8291,37 @@ function wrapCallback(cb) {
7815
8291
  }
7816
8292
  };
7817
8293
  }
7818
- async function scheduleCron(cron, callback) {
7819
- const cm = await loadCron();
7820
- if (!cm.validate(cron)) {
7821
- throw new Error(`Invalid cron expression: ${cron}`);
7822
- }
7823
- const task = cm.schedule(cron, wrapCallback(callback));
7824
- return makeHandle(`cron-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, task);
8294
+ function scheduleCron(cron, callback) {
8295
+ let cancelled = false;
8296
+ let task = null;
8297
+ const jobId = `cron-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
8298
+ loadCron().then((cm) => {
8299
+ if (cancelled) return;
8300
+ if (!cm.validate(cron)) {
8301
+ throw new Error(`Invalid cron expression: ${cron}`);
8302
+ }
8303
+ task = cm.schedule(cron, wrapCallback(callback));
8304
+ }).catch((err) => getLogger().error(`scheduleCron failed: ${String(err)}`));
8305
+ return {
8306
+ jobId,
8307
+ cancel() {
8308
+ if (cancelled) return;
8309
+ cancelled = true;
8310
+ if (task) {
8311
+ try {
8312
+ task.stop();
8313
+ } catch {
8314
+ }
8315
+ try {
8316
+ task.destroy?.();
8317
+ } catch {
8318
+ }
8319
+ }
8320
+ },
8321
+ get pending() {
8322
+ return !cancelled;
8323
+ }
8324
+ };
7825
8325
  }
7826
8326
  function scheduleOnce(at, callback) {
7827
8327
  const delayMs = at.getTime() - Date.now();
@@ -7843,8 +8343,18 @@ function scheduleOnce(at, callback) {
7843
8343
  }
7844
8344
  };
7845
8345
  }
7846
- function scheduleInterval(intervalMs, callback) {
7847
- if (intervalMs <= 0) throw new Error("intervalMs must be positive");
8346
+ function scheduleInterval(intervalOrOpts, callback) {
8347
+ let intervalMs;
8348
+ if (typeof intervalOrOpts === "number") {
8349
+ intervalMs = intervalOrOpts;
8350
+ } else if (intervalOrOpts.intervalMs !== void 0) {
8351
+ intervalMs = intervalOrOpts.intervalMs;
8352
+ } else if (intervalOrOpts.seconds !== void 0) {
8353
+ intervalMs = intervalOrOpts.seconds * 1e3;
8354
+ } else {
8355
+ throw new Error("scheduleInterval requires seconds or intervalMs");
8356
+ }
8357
+ if (intervalMs <= 0) throw new Error("interval must be positive");
7848
8358
  let cancelled = false;
7849
8359
  const wrapped = wrapCallback(callback);
7850
8360
  const timer = setInterval(() => {
@@ -7861,27 +8371,6 @@ function scheduleInterval(intervalMs, callback) {
7861
8371
  }
7862
8372
  };
7863
8373
  }
7864
- function makeHandle(jobId, task) {
7865
- let cancelled = false;
7866
- return {
7867
- jobId,
7868
- cancel() {
7869
- if (cancelled) return;
7870
- cancelled = true;
7871
- try {
7872
- task.stop();
7873
- } catch {
7874
- }
7875
- try {
7876
- task.destroy?.();
7877
- } catch {
7878
- }
7879
- },
7880
- get pending() {
7881
- return !cancelled;
7882
- }
7883
- };
7884
- }
7885
8374
 
7886
8375
  // src/index.ts
7887
8376
  init_deepgram_stt();