getpatter 0.6.5 → 0.6.6

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
@@ -5,10 +5,10 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __glob = (map) => (path6) => {
9
- var fn = map[path6];
8
+ var __glob = (map) => (path7) => {
9
+ var fn = map[path7];
10
10
  if (fn) return fn();
11
- throw new Error("Module not found in bundle: " + path6);
11
+ throw new Error("Module not found in bundle: " + path7);
12
12
  };
13
13
  var __esm = (fn, res) => function __init() {
14
14
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
@@ -118,6 +118,110 @@ var init_errors = __esm({
118
118
  }
119
119
  });
120
120
 
121
+ // src/telemetry/call-metrics.ts
122
+ function engineFromMode(mode) {
123
+ if (mode === "openai_realtime" || mode === "openai_realtime_2") return "realtime";
124
+ if (mode === "elevenlabs_convai") return "convai";
125
+ if (mode === "pipeline") return "pipeline";
126
+ return "other";
127
+ }
128
+ function providerFromMetrics(m) {
129
+ const mode = m.provider_mode;
130
+ if (mode === "openai_realtime" || mode === "openai_realtime_2") return "openai";
131
+ if (mode === "elevenlabs_convai") return "elevenlabs";
132
+ for (const key of ["llm_provider", "stt_provider", "tts_provider"]) {
133
+ const v = m[key];
134
+ if (typeof v === "string" && v) return v.toLowerCase();
135
+ }
136
+ return "other";
137
+ }
138
+ function providerFromMode(mode) {
139
+ if (mode === "openai_realtime" || mode === "openai_realtime_2") return "openai";
140
+ if (mode === "elevenlabs_convai") return "elevenlabs";
141
+ return "other";
142
+ }
143
+ function carrierFamily(tp) {
144
+ return typeof tp === "string" && tp ? tp.toLowerCase() : "none";
145
+ }
146
+ function direction(value) {
147
+ const v = typeof value === "string" ? value.toLowerCase() : "";
148
+ return v === "inbound" || v === "outbound" ? v : void 0;
149
+ }
150
+ function turnCountBucket(n) {
151
+ if (n <= 0) return "0";
152
+ if (n === 1) return "1";
153
+ if (n <= 3) return "2_3";
154
+ if (n <= 6) return "4_6";
155
+ if (n <= 12) return "7_12";
156
+ return "13_plus";
157
+ }
158
+ function latencyMs(m) {
159
+ const p95 = m.latency_p95;
160
+ if (p95 && typeof p95 === "object") {
161
+ return p95.agent_response_ms;
162
+ }
163
+ return void 0;
164
+ }
165
+ function recordCallStarted(telemetry, opts) {
166
+ if (!telemetry) return;
167
+ try {
168
+ const dims = {
169
+ engine: engineFromMode(opts.providerMode),
170
+ provider: providerFromMode(opts.providerMode),
171
+ carrier: carrierFamily(opts.telephonyProvider)
172
+ };
173
+ const d = direction(opts.direction);
174
+ if (d !== void 0) dims.direction = d;
175
+ telemetry.record("call_started", dims);
176
+ } catch {
177
+ }
178
+ }
179
+ function recordCallCompleted(telemetry, opts) {
180
+ if (!telemetry) return;
181
+ try {
182
+ const dims = { outcome: opts.outcome };
183
+ const d = direction(opts.direction);
184
+ if (d !== void 0) dims.direction = d;
185
+ const metrics = opts.metrics;
186
+ if (metrics && typeof metrics === "object") {
187
+ const m = metrics;
188
+ dims.engine = engineFromMode(m.provider_mode);
189
+ dims.provider = providerFromMetrics(m);
190
+ dims.carrier = carrierFamily(m.telephony_provider);
191
+ if (typeof m.duration_seconds === "number") {
192
+ dims.duration_seconds = Math.max(0, Math.round(m.duration_seconds));
193
+ }
194
+ const lat = latencyMs(m);
195
+ if (typeof lat === "number") dims.latency_ms = Math.max(0, Math.round(lat));
196
+ const cost = m.cost;
197
+ if (cost && typeof cost === "object") {
198
+ const total = cost.total;
199
+ if (typeof total === "number" && Number.isFinite(total)) {
200
+ dims.cost_usd = Math.max(0, Math.round(total * 1e4) / 1e4);
201
+ }
202
+ }
203
+ if (Array.isArray(m.turns)) {
204
+ dims.turn_count_bucket = turnCountBucket(m.turns.length);
205
+ }
206
+ const errorCode = m.error_code;
207
+ if (typeof errorCode === "string" && errorCode) {
208
+ dims.error_code = errorCode;
209
+ dims.outcome = "error";
210
+ }
211
+ } else if (opts.carrier !== void 0) {
212
+ dims.carrier = carrierFamily(opts.carrier);
213
+ }
214
+ telemetry.record("call_completed", dims);
215
+ } catch {
216
+ }
217
+ }
218
+ var init_call_metrics = __esm({
219
+ "src/telemetry/call-metrics.ts"() {
220
+ "use strict";
221
+ init_cjs_shims();
222
+ }
223
+ });
224
+
121
225
  // src/logger.ts
122
226
  function getLogger() {
123
227
  return currentLogger;
@@ -2171,10 +2275,10 @@ var init_plivo_adapter = __esm({
2171
2275
  this.baseUrl = `${PLIVO_API_BASE}/Account/${encodeURIComponent(authId)}`;
2172
2276
  this.authHeader = `Basic ${Buffer.from(`${authId}:${authToken}`).toString("base64")}`;
2173
2277
  }
2174
- async request(method, path6, jsonBody) {
2278
+ async request(method, path7, jsonBody) {
2175
2279
  const headers = { Authorization: this.authHeader };
2176
2280
  if (jsonBody !== void 0) headers["Content-Type"] = "application/json";
2177
- const response = await fetch(`${this.baseUrl}${path6}`, {
2281
+ const response = await fetch(`${this.baseUrl}${path7}`, {
2178
2282
  method,
2179
2283
  headers,
2180
2284
  body: jsonBody !== void 0 ? JSON.stringify(jsonBody) : void 0,
@@ -2182,7 +2286,7 @@ var init_plivo_adapter = __esm({
2182
2286
  });
2183
2287
  const text = await response.text();
2184
2288
  if (!response.ok && response.status !== 404) {
2185
- throw new Error(`Plivo ${method} ${path6} failed: ${response.status} ${text}`);
2289
+ throw new Error(`Plivo ${method} ${path7} failed: ${response.status} ${text}`);
2186
2290
  }
2187
2291
  let data = {};
2188
2292
  if (text) {
@@ -3705,9 +3809,9 @@ function loadDashboardHtml() {
3705
3809
  (0, import_node_path.join)(here, "dashboard", "ui.html"),
3706
3810
  (0, import_node_path.join)(here, "..", "dashboard", "ui.html")
3707
3811
  ];
3708
- for (const path6 of candidates) {
3812
+ for (const path7 of candidates) {
3709
3813
  try {
3710
- return (0, import_node_fs.readFileSync)(path6, "utf8");
3814
+ return (0, import_node_fs.readFileSync)(path7, "utf8");
3711
3815
  } catch {
3712
3816
  }
3713
3817
  }
@@ -4599,6 +4703,9 @@ var init_metrics = __esm({
4599
4703
  ttsModel;
4600
4704
  realtimeModel;
4601
4705
  _pricing;
4706
+ // Terminal error code (lowercased ErrorCode value or "other"); set by
4707
+ // recordError when the call ends abnormally. Empty for a clean call.
4708
+ _errorCode = "";
4602
4709
  _callStart;
4603
4710
  _turns = [];
4604
4711
  // mutable internal array; immutable when exposed via TurnMetrics[] → readonly TurnMetrics[]
@@ -5169,11 +5276,35 @@ var init_metrics = __esm({
5169
5276
  telephony_provider: this.telephonyProvider,
5170
5277
  stt_model: this.sttModel,
5171
5278
  tts_model: this.ttsModel,
5172
- llm_model: this._llmModel
5279
+ llm_model: this._llmModel,
5280
+ error_code: this._errorCode
5173
5281
  };
5174
5282
  this._eventBus?.emit("call_ended", { callId: this.callId, metrics });
5175
5283
  return metrics;
5176
5284
  }
5285
+ /**
5286
+ * Record the call's terminal error as a coarse, anonymous code. Stores the
5287
+ * PatterError `.code` lowercased; maps common timeout/connection errors; falls
5288
+ * back to "other". Never stores the message. Last write wins.
5289
+ */
5290
+ recordError(err) {
5291
+ const code = err?.code;
5292
+ const name = err?.name;
5293
+ const sys = typeof code === "string" ? code : "";
5294
+ if (sys.startsWith("ECONN") || sys === "EHOSTUNREACH" || sys === "ENETUNREACH" || sys === "EPIPE") {
5295
+ this._errorCode = "connection";
5296
+ return;
5297
+ }
5298
+ if (typeof code === "string" && code) {
5299
+ this._errorCode = code.toLowerCase();
5300
+ return;
5301
+ }
5302
+ if (name === "TimeoutError" || name === "AbortError") {
5303
+ this._errorCode = "timeout";
5304
+ } else {
5305
+ this._errorCode = "other";
5306
+ }
5307
+ }
5177
5308
  /** Return the cost breakdown for the call so far without ending it. */
5178
5309
  getCostSoFar() {
5179
5310
  const duration3 = (hrTimeMs() - this._callStart) / 1e3;
@@ -5963,7 +6094,7 @@ var init_llm_loop = __esm({
5963
6094
  });
5964
6095
  if (!response.ok) {
5965
6096
  const errText = await response.text();
5966
- getLogger().error(`LLM API error: ${response.status} ${errText}`);
6097
+ getLogger().error(`LLM API error: ${response.status} ${errText.slice(0, 200)}`);
5967
6098
  throw new PatterConnectionError(
5968
6099
  `LLM API returned ${response.status}: ${errText.slice(0, 200)}`
5969
6100
  );
@@ -6132,7 +6263,15 @@ ${systemPrompt}` : DEFAULT_PHONE_PREAMBLE;
6132
6263
  const hasAfterLlmChunk = Boolean(hookExecutor?.hasAfterLlmChunk());
6133
6264
  const allEmittedText = [];
6134
6265
  const callId = callContext.call_id;
6135
- const streamOpts = typeof callId === "string" && callId.length > 0 ? { ...opts, callId } : opts;
6266
+ const caller = callContext.caller;
6267
+ const callee = callContext.callee;
6268
+ const hasContext = typeof callId === "string" && callId.length > 0 || typeof caller === "string" && caller.length > 0 || typeof callee === "string" && callee.length > 0;
6269
+ const streamOpts = hasContext ? {
6270
+ ...opts,
6271
+ ...typeof callId === "string" && callId.length > 0 ? { callId } : {},
6272
+ ...typeof caller === "string" && caller.length > 0 ? { caller } : {},
6273
+ ...typeof callee === "string" && callee.length > 0 ? { callee } : {}
6274
+ } : opts;
6136
6275
  for (let iter = 0; iter < maxIterations; iter++) {
6137
6276
  const toolCallsAccumulated = /* @__PURE__ */ new Map();
6138
6277
  const textParts = [];
@@ -6266,6 +6405,7 @@ ${systemPrompt}` : DEFAULT_PHONE_PREAMBLE;
6266
6405
  { role: "system", content: this.systemPrompt }
6267
6406
  ];
6268
6407
  for (const entry of history) {
6408
+ if (entry.role === "tool") continue;
6269
6409
  messages.push({
6270
6410
  role: entry.role === "assistant" ? "assistant" : "user",
6271
6411
  content: entry.text
@@ -6543,10 +6683,10 @@ function mergeDefs(...defs) {
6543
6683
  function cloneDef(schema) {
6544
6684
  return mergeDefs(schema._zod.def);
6545
6685
  }
6546
- function getElementAtPath(obj, path6) {
6547
- if (!path6)
6686
+ function getElementAtPath(obj, path7) {
6687
+ if (!path7)
6548
6688
  return obj;
6549
- return path6.reduce((acc, key) => acc?.[key], obj);
6689
+ return path7.reduce((acc, key) => acc?.[key], obj);
6550
6690
  }
6551
6691
  function promiseAllObject(promisesObj) {
6552
6692
  const keys = Object.keys(promisesObj);
@@ -6874,11 +7014,11 @@ function explicitlyAborted(x, startIndex = 0) {
6874
7014
  }
6875
7015
  return false;
6876
7016
  }
6877
- function prefixIssues(path6, issues) {
7017
+ function prefixIssues(path7, issues) {
6878
7018
  return issues.map((iss) => {
6879
7019
  var _a3;
6880
7020
  (_a3 = iss).path ?? (_a3.path = []);
6881
- iss.path.unshift(path6);
7021
+ iss.path.unshift(path7);
6882
7022
  return iss;
6883
7023
  });
6884
7024
  }
@@ -7097,16 +7237,16 @@ function flattenError(error2, mapper = (issue2) => issue2.message) {
7097
7237
  }
7098
7238
  function formatError(error2, mapper = (issue2) => issue2.message) {
7099
7239
  const fieldErrors = { _errors: [] };
7100
- const processError = (error3, path6 = []) => {
7240
+ const processError = (error3, path7 = []) => {
7101
7241
  for (const issue2 of error3.issues) {
7102
7242
  if (issue2.code === "invalid_union" && issue2.errors.length) {
7103
- issue2.errors.map((issues) => processError({ issues }, [...path6, ...issue2.path]));
7243
+ issue2.errors.map((issues) => processError({ issues }, [...path7, ...issue2.path]));
7104
7244
  } else if (issue2.code === "invalid_key") {
7105
- processError({ issues: issue2.issues }, [...path6, ...issue2.path]);
7245
+ processError({ issues: issue2.issues }, [...path7, ...issue2.path]);
7106
7246
  } else if (issue2.code === "invalid_element") {
7107
- processError({ issues: issue2.issues }, [...path6, ...issue2.path]);
7247
+ processError({ issues: issue2.issues }, [...path7, ...issue2.path]);
7108
7248
  } else {
7109
- const fullpath = [...path6, ...issue2.path];
7249
+ const fullpath = [...path7, ...issue2.path];
7110
7250
  if (fullpath.length === 0) {
7111
7251
  fieldErrors._errors.push(mapper(issue2));
7112
7252
  } else {
@@ -17910,20 +18050,20 @@ var require_compile = __commonJS({
17910
18050
  var util_1 = require_util();
17911
18051
  var validate_1 = require_validate();
17912
18052
  var SchemaEnv = class {
17913
- constructor(env) {
18053
+ constructor(env2) {
17914
18054
  var _a3;
17915
18055
  this.refs = {};
17916
18056
  this.dynamicAnchors = {};
17917
18057
  let schema;
17918
- if (typeof env.schema == "object")
17919
- schema = env.schema;
17920
- this.schema = env.schema;
17921
- this.schemaId = env.schemaId;
17922
- this.root = env.root || this;
17923
- this.baseId = (_a3 = env.baseId) !== null && _a3 !== void 0 ? _a3 : (0, resolve_1.normalizeId)(schema === null || schema === void 0 ? void 0 : schema[env.schemaId || "$id"]);
17924
- this.schemaPath = env.schemaPath;
17925
- this.localRefs = env.localRefs;
17926
- this.meta = env.meta;
18058
+ if (typeof env2.schema == "object")
18059
+ schema = env2.schema;
18060
+ this.schema = env2.schema;
18061
+ this.schemaId = env2.schemaId;
18062
+ this.root = env2.root || this;
18063
+ this.baseId = (_a3 = env2.baseId) !== null && _a3 !== void 0 ? _a3 : (0, resolve_1.normalizeId)(schema === null || schema === void 0 ? void 0 : schema[env2.schemaId || "$id"]);
18064
+ this.schemaPath = env2.schemaPath;
18065
+ this.localRefs = env2.localRefs;
18066
+ this.meta = env2.meta;
17927
18067
  this.$async = schema === null || schema === void 0 ? void 0 : schema.$async;
17928
18068
  this.refs = {};
17929
18069
  }
@@ -18107,15 +18247,15 @@ var require_compile = __commonJS({
18107
18247
  baseId = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, schId);
18108
18248
  }
18109
18249
  }
18110
- let env;
18250
+ let env2;
18111
18251
  if (typeof schema != "boolean" && schema.$ref && !(0, util_1.schemaHasRulesButRef)(schema, this.RULES)) {
18112
18252
  const $ref = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, schema.$ref);
18113
- env = resolveSchema.call(this, root, $ref);
18253
+ env2 = resolveSchema.call(this, root, $ref);
18114
18254
  }
18115
18255
  const { schemaId } = this.opts;
18116
- env = env || new SchemaEnv({ schema, schemaId, root, baseId });
18117
- if (env.schema !== env.root.schema)
18118
- return env;
18256
+ env2 = env2 || new SchemaEnv({ schema, schemaId, root, baseId });
18257
+ if (env2.schema !== env2.root.schema)
18258
+ return env2;
18119
18259
  return void 0;
18120
18260
  }
18121
18261
  }
@@ -18267,8 +18407,8 @@ var require_utils = __commonJS({
18267
18407
  }
18268
18408
  return ind;
18269
18409
  }
18270
- function removeDotSegments(path6) {
18271
- let input = path6;
18410
+ function removeDotSegments(path7) {
18411
+ let input = path7;
18272
18412
  const output = [];
18273
18413
  let nextSlash = -1;
18274
18414
  let len = 0;
@@ -18521,8 +18661,8 @@ var require_schemes = __commonJS({
18521
18661
  wsComponent.secure = void 0;
18522
18662
  }
18523
18663
  if (wsComponent.resourceName) {
18524
- const [path6, query] = wsComponent.resourceName.split("?");
18525
- wsComponent.path = path6 && path6 !== "/" ? path6 : void 0;
18664
+ const [path7, query] = wsComponent.resourceName.split("?");
18665
+ wsComponent.path = path7 && path7 !== "/" ? path7 : void 0;
18526
18666
  wsComponent.query = query;
18527
18667
  wsComponent.resourceName = void 0;
18528
18668
  }
@@ -19610,8 +19750,8 @@ var require_ref = __commonJS({
19610
19750
  schemaType: "string",
19611
19751
  code(cxt) {
19612
19752
  const { gen, schema: $ref, it } = cxt;
19613
- const { baseId, schemaEnv: env, validateName, opts, self } = it;
19614
- const { root } = env;
19753
+ const { baseId, schemaEnv: env2, validateName, opts, self } = it;
19754
+ const { root } = env2;
19615
19755
  if (($ref === "#" || $ref === "#/") && baseId === root.baseId)
19616
19756
  return callRootRef();
19617
19757
  const schOrEnv = compile_1.resolveRef.call(self, root, baseId, $ref);
@@ -19621,8 +19761,8 @@ var require_ref = __commonJS({
19621
19761
  return callValidate(schOrEnv);
19622
19762
  return inlineRefSchema(schOrEnv);
19623
19763
  function callRootRef() {
19624
- if (env === root)
19625
- return callRef(cxt, validateName, env, env.$async);
19764
+ if (env2 === root)
19765
+ return callRef(cxt, validateName, env2, env2.$async);
19626
19766
  const rootName = gen.scopeValue("root", { ref: root });
19627
19767
  return callRef(cxt, (0, codegen_1._)`${rootName}.validate`, root, root.$async);
19628
19768
  }
@@ -19652,14 +19792,14 @@ var require_ref = __commonJS({
19652
19792
  exports2.getValidate = getValidate;
19653
19793
  function callRef(cxt, v, sch, $async) {
19654
19794
  const { gen, it } = cxt;
19655
- const { allErrors, schemaEnv: env, opts } = it;
19795
+ const { allErrors, schemaEnv: env2, opts } = it;
19656
19796
  const passCxt = opts.passContext ? names_1.default.this : codegen_1.nil;
19657
19797
  if ($async)
19658
19798
  callAsyncRef();
19659
19799
  else
19660
19800
  callSyncRef();
19661
19801
  function callAsyncRef() {
19662
- if (!env.$async)
19802
+ if (!env2.$async)
19663
19803
  throw new Error("async schema referenced by sync schema");
19664
19804
  const valid = gen.let("valid");
19665
19805
  gen.try(() => {
@@ -21961,12 +22101,12 @@ var require_dist = __commonJS({
21961
22101
  throw new Error(`Unknown format "${name}"`);
21962
22102
  return f;
21963
22103
  };
21964
- function addFormats(ajv, list, fs6, exportName) {
22104
+ function addFormats(ajv, list, fs8, exportName) {
21965
22105
  var _a3;
21966
22106
  var _b;
21967
22107
  (_a3 = (_b = ajv.opts.code).formats) !== null && _a3 !== void 0 ? _a3 : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
21968
22108
  for (const f of list)
21969
- ajv.addFormat(f, fs6[f]);
22109
+ ajv.addFormat(f, fs8[f]);
21970
22110
  }
21971
22111
  module2.exports = exports2 = formatsPlugin;
21972
22112
  Object.defineProperty(exports2, "__esModule", { value: true });
@@ -27783,6 +27923,26 @@ function isSttHallucination(text) {
27783
27923
  const pieces = stripped.split(/[.!?…。!?]+/u).map((p) => p.trim()).filter((p) => p.length > 0);
27784
27924
  return pieces.length > 1 && pieces.every((p) => HALLUCINATIONS.has(p));
27785
27925
  }
27926
+ function normalizeForEcho(text) {
27927
+ return text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/u, " ").trim().replace(/\s+/gu, " ");
27928
+ }
27929
+ function looksLikeEcho(candidate, agentText) {
27930
+ const a = normalizeForEcho(agentText);
27931
+ const c = normalizeForEcho(candidate);
27932
+ if (!a || !c) return false;
27933
+ const words = c.split(" ").filter(Boolean);
27934
+ if (words.length < ECHO_MIN_CANDIDATE_WORDS) return false;
27935
+ if (a.includes(c)) return true;
27936
+ const agentWords = new Set(a.split(" "));
27937
+ const overlap = words.filter((w) => agentWords.has(w)).length / words.length;
27938
+ return overlap >= ECHO_WORD_OVERLAP_THRESHOLD;
27939
+ }
27940
+ function isNearDuplicate(a, b) {
27941
+ if (!a || !b) return false;
27942
+ if (a === b) return true;
27943
+ const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
27944
+ return longer.startsWith(shorter + " ");
27945
+ }
27786
27946
  async function queryDeepgramCost(metricsAcc, deepgramKey, deepgramRequestId) {
27787
27947
  try {
27788
27948
  const projResp = await fetch("https://api.deepgram.com/v1/projects", {
@@ -27813,7 +27973,7 @@ async function queryDeepgramCost(metricsAcc, deepgramKey, deepgramRequestId) {
27813
27973
  } catch {
27814
27974
  }
27815
27975
  }
27816
- var DEFAULT_TOOL_CALL_PREAMBLE_BLOCK, HALLUCINATIONS, StreamHandler;
27976
+ var DEFAULT_TOOL_CALL_PREAMBLE_BLOCK, HALLUCINATIONS, ECHO_WORD_OVERLAP_THRESHOLD, ECHO_MIN_CANDIDATE_WORDS, StreamHandler;
27817
27977
  var init_stream_handler = __esm({
27818
27978
  "src/stream-handler.ts"() {
27819
27979
  "use strict";
@@ -27926,6 +28086,8 @@ Avoid:
27926
28086
  "[blank_audio]",
27927
28087
  "(silence)"
27928
28088
  ]);
28089
+ ECHO_WORD_OVERLAP_THRESHOLD = 0.6;
28090
+ ECHO_MIN_CANDIDATE_WORDS = 4;
27929
28091
  StreamHandler = class _StreamHandler {
27930
28092
  deps;
27931
28093
  ws;
@@ -27938,6 +28100,17 @@ Avoid:
27938
28100
  stt = null;
27939
28101
  tts = null;
27940
28102
  isSpeaking = false;
28103
+ /**
28104
+ * True only while the post-TTS tail-grace window is pending: the agent has
28105
+ * finished its turn but ``isSpeaking`` is still held for
28106
+ * ``PATTER_TTS_TAIL_GRACE_MS`` to swallow the fading echo tail. A VAD
28107
+ * ``speech_start`` (or a transcript) during this window is the user's NEXT
28108
+ * turn, not a barge-in — there is nothing left to interrupt. Set by
28109
+ * ``endSpeakingWithGrace``; cleared by ``beginSpeaking``, the grace flip,
28110
+ * ``cancelSpeaking``, and ``endTailGraceForNewTurn``. Parity with Python
28111
+ * ``_tail_grace_active``.
28112
+ */
28113
+ tailGraceActive = false;
27941
28114
  /**
27942
28115
  * Ring buffer of inbound PCM16 16 kHz frames captured while the agent
27943
28116
  * is speaking and the self-hearing guard is dropping audio. On
@@ -28013,6 +28186,35 @@ Avoid:
28013
28186
  * ``isSpeaking=false``, and silently cut the agent's first turn.
28014
28187
  */
28015
28188
  firstAudioSentAt = null;
28189
+ /**
28190
+ * Estimated wall-clock (ms) when the LAST audio byte pushed to the carrier
28191
+ * finishes PLAYING on the phone. The pipeline pushes TTS audio as fast as
28192
+ * the provider synthesizes it (no pacing) and the carrier buffers + plays
28193
+ * at realtime, so "we finished pushing" and "the caller finished hearing"
28194
+ * can diverge by tens of seconds — especially with agent-runtime LLMs
28195
+ * (Hermes/OpenClaw) that deliver a long reply all at once after a thinking
28196
+ * pause. ``endSpeakingWithGrace`` holds ``isSpeaking=true`` (with
28197
+ * ``tailGraceActive=false``) until this cursor passes, so a barge-in during
28198
+ * the audible backlog still takes the cancel path (``sendClear`` drops the
28199
+ * carrier buffer) instead of being treated as a calm next turn. Advanced by
28200
+ * ``trackOutboundPlayback``; reset by ``cancelSpeaking`` (the buffer is
28201
+ * cleared) and ``endTailGraceForNewTurn``.
28202
+ */
28203
+ playbackBufferedUntil = 0;
28204
+ /**
28205
+ * Per-turn playback timeline used to estimate the response prefix the
28206
+ * caller actually HEARD when a barge-in lands. ``turnPlaybackTotalMs``
28207
+ * accumulates the playout duration of every chunk pushed this turn
28208
+ * (including filler audio, which keeps the timeline aligned);
28209
+ * ``turnSpokenSegments`` records ``{text, startMs}`` for each RESPONSE
28210
+ * sentence at its first audible chunk (filler / error-fallback audio
28211
+ * advances the clock but adds no segment). ``heard = total - backlog``
28212
+ * then maps to a sentence-granular prefix — see ``heardResponsePrefix``.
28213
+ * Both reset at ``beginSpeaking``. Mirrors Python
28214
+ * ``_turn_playback_total_s`` / ``_turn_spoken_segments``.
28215
+ */
28216
+ turnPlaybackTotalMs = 0;
28217
+ turnSpokenSegments = [];
28016
28218
  /**
28017
28219
  * Optional barge-in confirmation strategies. With an empty array the
28018
28220
  * SDK falls back to the legacy "cancel on first VAD speech_start"
@@ -28130,11 +28332,15 @@ Avoid:
28130
28332
  }
28131
28333
  this.speakingGeneration++;
28132
28334
  this.isSpeaking = true;
28335
+ this.tailGraceActive = false;
28133
28336
  this.speakingStartedAt = Date.now();
28134
28337
  this.suppressedSpeechPending = false;
28135
28338
  void isFirstMessage;
28136
28339
  this.firstAudioSentAt = Date.now();
28137
28340
  this.inboundAudioRing = [];
28341
+ this.currentAgentSpokenText = "";
28342
+ this.turnPlaybackTotalMs = 0;
28343
+ this.turnSpokenSegments = [];
28138
28344
  this.resetVad();
28139
28345
  }
28140
28346
  /**
@@ -28149,6 +28355,87 @@ Avoid:
28149
28355
  this.firstAudioSentAt = Date.now();
28150
28356
  }
28151
28357
  }
28358
+ /**
28359
+ * Advance ``playbackBufferedUntil`` by the playout duration of an outbound
28360
+ * TTS chunk. ``numBytes`` is the size of the chunk BEFORE carrier encoding
28361
+ * (the same buffer handed to ``encodePipelineAudio``): PCM16 @ 16 kHz in
28362
+ * the default path (32 bytes/ms), or the carrier's native μ-law @ 8 kHz
28363
+ * (8 bytes/ms) when the TTS adapter emits wire format directly
28364
+ * (``ttsOutputFormatNativeForCarrier`` — Twilio/Plivo ``ulaw_8000``;
28365
+ * Telnyx native is ``pcm_16000`` so it stays at 32 bytes/ms).
28366
+ */
28367
+ trackOutboundPlayback(numBytes) {
28368
+ if (numBytes <= 0) return;
28369
+ const bytesPerMs = this.ttsOutputFormatNativeForCarrier && this.deps.bridge.telephonyProvider !== "telnyx" ? 8 : 32;
28370
+ const now = Date.now();
28371
+ const chunkMs = numBytes / bytesPerMs;
28372
+ const base = this.playbackBufferedUntil > now ? this.playbackBufferedUntil : now;
28373
+ this.playbackBufferedUntil = base + chunkMs;
28374
+ this.turnPlaybackTotalMs += chunkMs;
28375
+ }
28376
+ /**
28377
+ * Estimate the response prefix the caller actually HEARD this turn.
28378
+ *
28379
+ * The pipeline pushes audio faster than realtime, so at barge-in time
28380
+ * ``heard = totalPushed - carrierBacklog`` ms of audio have actually
28381
+ * played. Mapped at sentence granularity against ``turnSpokenSegments``:
28382
+ * a sentence counts as heard once its playback has STARTED
28383
+ * (``startMs <= heardMs``), so the sentence playing at the moment of
28384
+ * interruption is included.
28385
+ *
28386
+ * Returns ``null`` when no segments were tracked this turn (nothing
28387
+ * synthesized through the tracked path — callers fall back to the legacy
28388
+ * full-text behaviour). Mirrors Python ``_heard_response_prefix``.
28389
+ */
28390
+ heardResponsePrefix() {
28391
+ if (this.turnSpokenSegments.length === 0) return null;
28392
+ const remainingMs = Math.max(0, this.playbackBufferedUntil - Date.now());
28393
+ const heardMs = Math.max(0, this.turnPlaybackTotalMs - remainingMs);
28394
+ const heard = this.turnSpokenSegments.filter((s) => s.startMs <= heardMs);
28395
+ return {
28396
+ text: heard.map((s) => s.text).join(" "),
28397
+ heardEverything: heard.length === this.turnSpokenSegments.length
28398
+ };
28399
+ }
28400
+ /**
28401
+ * Replace the text of the most recent assistant entry in the conversation
28402
+ * history. No-op when the last entry is not an assistant turn (e.g. the
28403
+ * caller's next turn was already committed).
28404
+ */
28405
+ rewriteLastAssistantEntry(text) {
28406
+ const entries = this.history.entries;
28407
+ const last = entries[entries.length - 1];
28408
+ if (last && last.role === "assistant") {
28409
+ entries[entries.length - 1] = { ...last, text };
28410
+ }
28411
+ }
28412
+ /**
28413
+ * LiveKit-style "heard prefix" semantics for a barge-in that lands AFTER
28414
+ * the turn completed, while the carrier is still playing the buffered
28415
+ * tail.
28416
+ *
28417
+ * The completed turn already recorded its FULL reply in history, but the
28418
+ * caller only heard part of it before interrupting — a stateful agent
28419
+ * runtime (Hermes / OpenClaw) would otherwise "remember saying" things
28420
+ * the caller never heard. Rewrites the last assistant entry to the heard
28421
+ * prefix + ``[interrupted by caller]``.
28422
+ *
28423
+ * MUST run BEFORE ``cancelSpeaking`` resets ``playbackBufferedUntil``
28424
+ * (the backlog is the heard-prefix input). No-op when a turn is still in
28425
+ * flight (the streaming path applies its own marker), when there is no
28426
+ * backlog, or when everything was already heard. Mirrors Python
28427
+ * ``_maybe_truncate_completed_turn_history``.
28428
+ */
28429
+ maybeTruncateCompletedTurnHistory() {
28430
+ if (this.dispatchTask !== null) return;
28431
+ const remainingMs = this.playbackBufferedUntil - Date.now();
28432
+ if (remainingMs <= 0) return;
28433
+ const heard = this.heardResponsePrefix();
28434
+ if (heard === null || heard.heardEverything) return;
28435
+ this.rewriteLastAssistantEntry(
28436
+ heard.text ? `${heard.text} [interrupted by caller]` : "[interrupted by caller]"
28437
+ );
28438
+ }
28152
28439
  /**
28153
28440
  * Atomically end speaking AND invalidate any pending grace timer.
28154
28441
  * Use instead of ``this.isSpeaking = false`` at barge-in sites.
@@ -28159,10 +28446,12 @@ Avoid:
28159
28446
  cancelSpeaking() {
28160
28447
  this.speakingGeneration++;
28161
28448
  this.isSpeaking = false;
28449
+ this.tailGraceActive = false;
28162
28450
  this.speakingStartedAt = null;
28163
28451
  this.firstAudioSentAt = null;
28164
28452
  this.lastCancelAt = Date.now();
28165
28453
  this.suppressedSpeechPending = false;
28454
+ this.playbackBufferedUntil = 0;
28166
28455
  this.drainPendingMarks();
28167
28456
  if (this.llmAbort !== null) {
28168
28457
  try {
@@ -28235,23 +28524,37 @@ Avoid:
28235
28524
  if (grace > 0) {
28236
28525
  const gen = this.speakingGeneration;
28237
28526
  this.clearGraceTimer();
28238
- this.graceTimer = setTimeout(() => {
28239
- this.graceTimer = null;
28240
- if (this.speakingGeneration === gen) {
28241
- this.isSpeaking = false;
28242
- this.speakingStartedAt = null;
28243
- this.firstAudioSentAt = null;
28244
- this.clearPendingBargeIn();
28245
- void this.resetBargeInStrategies();
28246
- if (this.suppressedSpeechPending) {
28247
- this.suppressedSpeechPending = false;
28248
- this.flushInboundAudioRing();
28527
+ const startTailGrace = () => {
28528
+ this.tailGraceActive = true;
28529
+ this.graceTimer = setTimeout(() => {
28530
+ this.graceTimer = null;
28531
+ if (this.speakingGeneration === gen) {
28532
+ this.isSpeaking = false;
28533
+ this.tailGraceActive = false;
28534
+ this.speakingStartedAt = null;
28535
+ this.firstAudioSentAt = null;
28536
+ this.clearPendingBargeIn();
28537
+ void this.resetBargeInStrategies();
28538
+ if (this.suppressedSpeechPending) {
28539
+ this.suppressedSpeechPending = false;
28540
+ this.flushInboundAudioRing();
28541
+ }
28542
+ this.resetVad();
28249
28543
  }
28250
- this.resetVad();
28251
- }
28252
- }, grace);
28544
+ }, grace);
28545
+ };
28546
+ const bufferedMs = Math.max(0, this.playbackBufferedUntil - Date.now());
28547
+ if (bufferedMs <= 0) {
28548
+ startTailGrace();
28549
+ } else {
28550
+ this.graceTimer = setTimeout(() => {
28551
+ this.graceTimer = null;
28552
+ if (this.speakingGeneration === gen) startTailGrace();
28553
+ }, bufferedMs);
28554
+ }
28253
28555
  } else {
28254
28556
  this.isSpeaking = false;
28557
+ this.tailGraceActive = false;
28255
28558
  this.speakingStartedAt = null;
28256
28559
  this.firstAudioSentAt = null;
28257
28560
  this.clearPendingBargeIn();
@@ -28263,6 +28566,35 @@ Avoid:
28263
28566
  this.resetVad();
28264
28567
  }
28265
28568
  }
28569
+ /**
28570
+ * End the post-TTS tail-grace window because the user has begun their next
28571
+ * turn. Unlike a barge-in, the agent's response already played out in full
28572
+ * — there is nothing to cancel and no turn was interrupted. We flip the
28573
+ * speaking flag off (bumping ``speakingGeneration`` so the scheduled grace
28574
+ * timer no-ops), recover any leading audio the self-hearing guard captured
28575
+ * into the ring (the user's first ~250 ms, which VAD needed before it could
28576
+ * emit ``speech_start``), and let the live STT stream take over. We do NOT
28577
+ * call ``sendClear``, ``recordBargeinDetected`` or ``recordTurnInterrupted``
28578
+ * — none apply to a turn that completed normally.
28579
+ *
28580
+ * Without this, fast next-turn speech (humans reply in 200-700 ms, well
28581
+ * inside the 1500 ms default grace) is withheld from STT and recorded as an
28582
+ * empty ``[interrupted]`` turn, after which the agent goes silent for the
28583
+ * rest of the call. Parity with Python ``_end_tail_grace_for_new_turn``.
28584
+ */
28585
+ endTailGraceForNewTurn() {
28586
+ this.isSpeaking = false;
28587
+ this.tailGraceActive = false;
28588
+ this.speakingStartedAt = null;
28589
+ this.firstAudioSentAt = null;
28590
+ this.playbackBufferedUntil = 0;
28591
+ this.speakingGeneration++;
28592
+ this.clearGraceTimer();
28593
+ this.clearPendingBargeIn();
28594
+ void this.resetBargeInStrategies();
28595
+ this.suppressedSpeechPending = false;
28596
+ this.flushInboundAudioRing();
28597
+ }
28266
28598
  async resetBargeInStrategies() {
28267
28599
  if (this.bargeInStrategies.length === 0) return;
28268
28600
  const { resetStrategies: resetStrategies2 } = await Promise.resolve().then(() => (init_barge_in_strategies(), barge_in_strategies_exports));
@@ -28398,9 +28730,43 @@ Avoid:
28398
28730
  maxDurationTimer = null;
28399
28731
  transcriptProcessing = false;
28400
28732
  transcriptQueue = [];
28733
+ /**
28734
+ * The in-flight turn dispatch (LLM + TTS) runs as a SINGLE tracked promise
28735
+ * so the transcript drain loop keeps running ``handleBargeIn`` against the
28736
+ * LIVE turn during a long (30-90 s) agent-runtime response, instead of
28737
+ * head-of-line-blocking on it. Exactly one is in flight: the launcher awaits
28738
+ * the previous one to settle (fast — a barge-in already aborted it) before
28739
+ * starting the next, preserving history/metrics ordering. Parity with
28740
+ * Python ``_dispatch_task``.
28741
+ */
28742
+ dispatchTask = null;
28743
+ /**
28744
+ * Cap (ms) on how long teardown waits for the backgrounded dispatch to
28745
+ * settle. JS promises are not cancellable, so a user-supplied ``onMessage``
28746
+ * (which receives no AbortSignal) parked on a hung external call could block
28747
+ * call cleanup indefinitely — `llmAbort.abort()` only unblocks the built-in
28748
+ * LLM/TTS paths. We bound the WAIT (Python hard-cancels the task instead).
28749
+ * 30 s matches the webhook ceiling.
28750
+ */
28751
+ static DISPATCH_SETTLE_TIMEOUT_MS = 3e4;
28752
+ /**
28753
+ * Opt-in (default OFF): forward inbound audio to STT even while the agent is
28754
+ * speaking, so the transcript barge-in path can receive a transcript on
28755
+ * echo-masked PSTN links where the VAD never fires. ECHO RISK without AEC.
28756
+ * Parity with Python ``_forward_stt_while_speaking``.
28757
+ */
28758
+ forwardSttWhileSpeaking = ["1", "true", "yes"].includes(
28759
+ (process.env.PATTER_FORWARD_STT_WHILE_SPEAKING ?? "").trim().toLowerCase()
28760
+ );
28401
28761
  // Throttle state for back-to-back STT finals — see ``commitTranscript``.
28402
28762
  lastCommitText = "";
28403
28763
  lastCommitAt = 0;
28764
+ /** The agent's spoken text for the CURRENT turn, accumulated as tokens stream.
28765
+ * The echo guard rejects transcripts matching it (the agent's own TTS bleeding
28766
+ * back into STT when audio is forwarded during TTS without effective AEC).
28767
+ * Reset in ``beginSpeaking``; only consulted while ``forwardSttWhileSpeaking``.
28768
+ * Parity with Python ``_current_agent_spoken_text``. */
28769
+ currentAgentSpokenText = "";
28404
28770
  // PCM16 byte-alignment carry for TTS streaming (pipeline mode).
28405
28771
  // HTTP streams from ElevenLabs / OpenAI / Cartesia can yield chunks of any
28406
28772
  // size, including odd byte counts. Silently dropping the trailing odd byte
@@ -28420,6 +28786,11 @@ Avoid:
28420
28786
  this.ws = ws;
28421
28787
  this.caller = caller;
28422
28788
  this.callee = callee;
28789
+ if (this.forwardSttWhileSpeaking) {
28790
+ getLogger().warn(
28791
+ "PATTER_FORWARD_STT_WHILE_SPEAKING=on: inbound audio is sent to STT during TTS so transcript barge-in works on echo-masked links. Without AEC the agent's own voice may be transcribed as a phantom interruption \u2014 pair with agent.bargeInStrategies."
28792
+ );
28793
+ }
28423
28794
  this.bargeInStrategies = (deps.agent.bargeInStrategies ?? []).slice();
28424
28795
  const confirmMs = deps.agent.bargeInConfirmMs;
28425
28796
  this.bargeInConfirmMs = typeof confirmMs === "number" && Number.isFinite(confirmMs) && confirmMs > 0 ? confirmMs : 1500;
@@ -28619,12 +28990,12 @@ Avoid:
28619
28990
  } catch {
28620
28991
  }
28621
28992
  if (this.deps.onCallStart) {
28622
- const direction = this.deps.metricsStore.getActive(callId)?.direction ?? "inbound";
28993
+ const direction2 = this.deps.metricsStore.getActive(callId)?.direction ?? "inbound";
28623
28994
  await this.deps.onCallStart({
28624
28995
  call_id: callId,
28625
28996
  caller: this.caller,
28626
28997
  callee: this.callee,
28627
- direction,
28998
+ direction: direction2,
28628
28999
  telephony_provider: this.deps.bridge.telephonyProvider,
28629
29000
  ...Object.keys(customParams).length > 0 ? { custom_params: customParams } : {}
28630
29001
  });
@@ -28691,6 +29062,17 @@ Avoid:
28691
29062
  setStreamSid(sid) {
28692
29063
  this.streamSid = sid;
28693
29064
  }
29065
+ /**
29066
+ * Record a terminal/processing error as a coarse, anonymous code on the call
29067
+ * metrics (code only, never the message). Surfaced via `call_completed`
29068
+ * telemetry. Safe to call with any value; last write wins.
29069
+ */
29070
+ recordError(err) {
29071
+ try {
29072
+ this.metricsAcc.recordError(err);
29073
+ } catch {
29074
+ }
29075
+ }
28694
29076
  /** Handle an incoming audio chunk (already decoded from base64). */
28695
29077
  /** Forward inbound audio bytes to the AI adapter and (in pipeline mode) the STT provider. */
28696
29078
  async handleAudio(audioBuffer) {
@@ -28717,6 +29099,9 @@ Avoid:
28717
29099
  );
28718
29100
  }
28719
29101
  if (evt?.type === "speech_start") {
29102
+ if (this.isSpeaking && this.tailGraceActive) {
29103
+ this.endTailGraceForNewTurn();
29104
+ }
28720
29105
  const phantomSuppressed = this.isSpeaking && !this.canBargeIn();
28721
29106
  if (phantomSuppressed) {
28722
29107
  getLogger().info(
@@ -28724,7 +29109,8 @@ Avoid:
28724
29109
  );
28725
29110
  this.suppressedSpeechPending = true;
28726
29111
  } else if (this.isSpeaking) {
28727
- if (this.bargeInStrategies.length > 0) {
29112
+ const deferCancel = this.bargeInStrategies.length > 0 || this.forwardSttWhileSpeaking && !this.aec;
29113
+ if (deferCancel) {
28728
29114
  this.startPendingBargeIn();
28729
29115
  this.metricsAcc.anchorUserSpeechStart();
28730
29116
  return;
@@ -28734,6 +29120,7 @@ Avoid:
28734
29120
  this.metricsAcc.recordBargeinDetected();
28735
29121
  const bargeinSpan = startSpan(SPAN_BARGEIN, { "patter.call.id": this.callId });
28736
29122
  try {
29123
+ this.maybeTruncateCompletedTurnHistory();
28737
29124
  this.cancelSpeaking();
28738
29125
  try {
28739
29126
  this.deps.bridge.sendClear(this.ws, this.streamSid);
@@ -28778,9 +29165,10 @@ Avoid:
28778
29165
  if (this.inboundAudioRing.length > _StreamHandler.INBOUND_AUDIO_RING_FRAMES) {
28779
29166
  this.inboundAudioRing.shift();
28780
29167
  }
29168
+ if (!this.forwardSttWhileSpeaking) return;
29169
+ } else if ((this.deps.agent.bargeInThresholdMs ?? 300) === 0) {
28781
29170
  return;
28782
29171
  }
28783
- if ((this.deps.agent.bargeInThresholdMs ?? 300) === 0) return;
28784
29172
  }
28785
29173
  const hooks = this.deps.agent.hooks;
28786
29174
  if (hooks?.beforeSendToStt) {
@@ -28842,6 +29230,27 @@ Avoid:
28842
29230
  }
28843
29231
  }
28844
29232
  }
29233
+ /**
29234
+ * Await the backgrounded turn dispatch during teardown, but never block
29235
+ * longer than ``DISPATCH_SETTLE_TIMEOUT_MS``. The earlier ``llmAbort.abort()``
29236
+ * settles the built-in LLM/TTS paths immediately; the cap only bites a
29237
+ * misbehaving user ``onMessage`` parked on a hung external call (JS promises
29238
+ * can't be cancelled). No-op when nothing is in flight.
29239
+ */
29240
+ async settleDispatchForTeardown() {
29241
+ if (!this.dispatchTask) return;
29242
+ const settle = this.dispatchTask.catch(() => {
29243
+ });
29244
+ let timer;
29245
+ const cap = new Promise((resolve2) => {
29246
+ timer = setTimeout(resolve2, _StreamHandler.DISPATCH_SETTLE_TIMEOUT_MS);
29247
+ });
29248
+ try {
29249
+ await Promise.race([settle, cap]);
29250
+ } finally {
29251
+ if (timer) clearTimeout(timer);
29252
+ }
29253
+ }
28845
29254
  /** Handle call stop / stream end. */
28846
29255
  /** Handle a carrier-emitted `stop` event signalling the call has ended. */
28847
29256
  async handleStop() {
@@ -28858,6 +29267,7 @@ Avoid:
28858
29267
  } catch {
28859
29268
  }
28860
29269
  }
29270
+ await this.settleDispatchForTeardown();
28861
29271
  this.clearPendingBargeIn();
28862
29272
  this.drainPendingMarks();
28863
29273
  this.clearGraceTimer();
@@ -28885,6 +29295,7 @@ Avoid:
28885
29295
  } catch {
28886
29296
  }
28887
29297
  }
29298
+ await this.settleDispatchForTeardown();
28888
29299
  this.clearPendingBargeIn();
28889
29300
  this.drainPendingMarks();
28890
29301
  this.clearGraceTimer();
@@ -29279,7 +29690,7 @@ Avoid:
29279
29690
  };
29280
29691
  }
29281
29692
  /** Synthesize a single sentence through TTS with hooks, sending audio to telephony. */
29282
- async synthesizeSentence(sentence, hookExecutor, hookCtx, ttsFirstByteSent) {
29693
+ async synthesizeSentence(sentence, hookExecutor, hookCtx, ttsFirstByteSent, recordSegment = true) {
29283
29694
  if (!this.tts || !this.isSpeaking) return;
29284
29695
  let transformed = sentence;
29285
29696
  const transforms = this.deps.agent.textTransforms;
@@ -29305,8 +29716,16 @@ Avoid:
29305
29716
  if (this.aec) {
29306
29717
  this.aec.pushFarEnd(processedAudio);
29307
29718
  }
29719
+ if (recordSegment) {
29720
+ this.turnSpokenSegments.push({
29721
+ text: processedText,
29722
+ startMs: this.turnPlaybackTotalMs
29723
+ });
29724
+ recordSegment = false;
29725
+ }
29308
29726
  const encoded = this.encodePipelineAudio(processedAudio);
29309
29727
  this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid);
29728
+ this.trackOutboundPlayback(processedAudio.length);
29310
29729
  this.markFirstAudioSent();
29311
29730
  }
29312
29731
  } catch (e) {
@@ -29381,64 +29800,101 @@ Avoid:
29381
29800
  return;
29382
29801
  }
29383
29802
  this.history.push({ role: "user", text: filteredTranscript, timestamp: Date.now() });
29384
- let responseText = "";
29385
29803
  this.metricsAcc.recordOnUserTurnCompletedDelay(0);
29386
29804
  this.metricsAcc.recordTurnCommitted();
29387
29805
  closeEndpointSpan();
29388
- if (this.deps.onMessage && typeof this.deps.onMessage === "function") {
29389
- try {
29390
- responseText = await this.deps.onMessage({
29806
+ await this.dispatchTask?.catch(() => {
29807
+ });
29808
+ const historySnapshot = [...this.history.entries];
29809
+ this.dispatchTask = this.dispatchTurn(
29810
+ filteredTranscript,
29811
+ hookExecutor,
29812
+ hookCtx,
29813
+ interrupted,
29814
+ historySnapshot
29815
+ );
29816
+ }
29817
+ /**
29818
+ * Post-commit turn body (LLM dispatch → TTS → turn-complete) run as a
29819
+ * tracked background task so the transcript drain loop is not blocked for
29820
+ * the whole (possibly 30-90 s) agent-runtime turn. A barge-in — transcript
29821
+ * (now reachable mid-turn) or VAD — aborts the in-flight ``llmAbort`` and
29822
+ * flips ``isSpeaking``, which the LLM/TTS loops here observe and break on.
29823
+ * Parity with Python ``_dispatch_turn``.
29824
+ */
29825
+ async dispatchTurn(filteredTranscript, hookExecutor, hookCtx, interrupted, historySnapshot) {
29826
+ const label = this.deps.bridge.label;
29827
+ let responseText = "";
29828
+ try {
29829
+ if (this.deps.onMessage && typeof this.deps.onMessage === "function") {
29830
+ try {
29831
+ responseText = await this.deps.onMessage({
29832
+ text: filteredTranscript,
29833
+ call_id: this.callId,
29834
+ caller: this.caller,
29835
+ callee: this.callee,
29836
+ history: historySnapshot
29837
+ });
29838
+ } catch (e) {
29839
+ getLogger().error(`onMessage error (${label}):`, e);
29840
+ return;
29841
+ }
29842
+ if (!responseText) {
29843
+ getLogger().warn(
29844
+ `onMessage returned empty/void (${label}) \u2014 no TTS will play. If you intended to observe transcripts, use onTranscript instead; if you meant to answer via the built-in LLM, remove onMessage and pass openaiKey.`
29845
+ );
29846
+ }
29847
+ } else if (this.deps.onMessage && isRemoteUrl(this.deps.onMessage)) {
29848
+ const msgData = {
29391
29849
  text: filteredTranscript,
29392
29850
  call_id: this.callId,
29393
29851
  caller: this.caller,
29394
29852
  callee: this.callee,
29395
- history: [...this.history.entries]
29396
- });
29397
- } catch (e) {
29398
- getLogger().error(`onMessage error (${label}):`, e);
29399
- return;
29400
- }
29401
- if (!responseText) {
29853
+ history: historySnapshot
29854
+ };
29855
+ if (isWebSocketUrl(this.deps.onMessage)) {
29856
+ await this.handleWebSocketResponse(msgData);
29857
+ return;
29858
+ }
29859
+ try {
29860
+ responseText = await this.deps.remoteHandler.callWebhook(this.deps.onMessage, msgData);
29861
+ } catch (e) {
29862
+ getLogger().error(`Webhook remote error (${label}):`, e);
29863
+ return;
29864
+ }
29865
+ } else if (this.llmLoop) {
29866
+ const llmResult = await this.runPipelineLlm(
29867
+ filteredTranscript,
29868
+ hookExecutor,
29869
+ hookCtx,
29870
+ historySnapshot
29871
+ );
29872
+ responseText = llmResult.text;
29873
+ interrupted = interrupted || llmResult.interrupted;
29874
+ } else {
29402
29875
  getLogger().warn(
29403
- `onMessage returned empty/void (${label}) \u2014 no TTS will play. If you intended to observe transcripts, use onTranscript instead; if you meant to answer via the built-in LLM, remove onMessage and pass openaiKey.`
29876
+ `Pipeline (${label}) has no llm/onMessage handler \u2014 transcript "${sanitizeLogValue(filteredTranscript.slice(0, 60))}" dropped. Check that agent.llm or onMessage is configured.`
29404
29877
  );
29405
- }
29406
- } else if (this.deps.onMessage && isRemoteUrl(this.deps.onMessage)) {
29407
- const msgData = {
29408
- text: filteredTranscript,
29409
- call_id: this.callId,
29410
- caller: this.caller,
29411
- callee: this.callee,
29412
- history: [...this.history.entries]
29413
- };
29414
- if (isWebSocketUrl(this.deps.onMessage)) {
29415
- await this.handleWebSocketResponse(msgData);
29416
29878
  return;
29417
29879
  }
29418
- try {
29419
- responseText = await this.deps.remoteHandler.callWebhook(this.deps.onMessage, msgData);
29420
- } catch (e) {
29421
- getLogger().error(`Webhook remote error (${label}):`, e);
29422
- return;
29880
+ if (!responseText) return;
29881
+ if (this.llmLoop) {
29882
+ let spokenText = responseText;
29883
+ if (interrupted) {
29884
+ const heard = this.heardResponsePrefix();
29885
+ spokenText = heard === null ? `${responseText} [interrupted by caller]` : heard.text ? `${heard.text} [interrupted by caller]` : "[interrupted by caller]";
29886
+ }
29887
+ await this.emitAssistantTranscript(spokenText);
29888
+ if (!interrupted) this.metricsAcc.recordTtsComplete(responseText);
29889
+ } else {
29890
+ interrupted = await this.runRegularLlm(responseText, hookExecutor, hookCtx) || interrupted;
29891
+ responseText = this.history.entries[this.history.entries.length - 1]?.text ?? responseText;
29423
29892
  }
29424
- } else if (this.llmLoop) {
29425
- responseText = await this.runPipelineLlm(filteredTranscript, hookExecutor, hookCtx);
29426
- } else {
29427
- getLogger().warn(
29428
- `Pipeline (${label}) has no llm/onMessage handler \u2014 transcript "${sanitizeLogValue(filteredTranscript.slice(0, 60))}" dropped. Check that agent.llm or onMessage is configured.`
29429
- );
29430
- return;
29431
- }
29432
- if (!responseText) return;
29433
- if (this.llmLoop) {
29434
- await this.emitAssistantTranscript(responseText);
29435
- this.metricsAcc.recordTtsComplete(responseText);
29436
- } else {
29437
- interrupted = await this.runRegularLlm(responseText, hookExecutor, hookCtx) || interrupted;
29438
- responseText = this.history.entries[this.history.entries.length - 1]?.text ?? responseText;
29439
- }
29440
- if (!interrupted) {
29441
- await this.emitTurnMetrics(this.metricsAcc.recordTurnComplete(responseText));
29893
+ if (!interrupted) {
29894
+ await this.emitTurnMetrics(this.metricsAcc.recordTurnComplete(responseText));
29895
+ }
29896
+ } finally {
29897
+ this.dispatchTask = null;
29442
29898
  }
29443
29899
  }
29444
29900
  /**
@@ -29449,6 +29905,18 @@ Avoid:
29449
29905
  */
29450
29906
  async handleBargeInAsync(transcript) {
29451
29907
  if (!transcript.text || !this.isSpeaking) return false;
29908
+ if (this.tailGraceActive) {
29909
+ this.endTailGraceForNewTurn();
29910
+ return false;
29911
+ }
29912
+ if (this.forwardSttWhileSpeaking && looksLikeEcho(transcript.text, this.currentAgentSpokenText)) {
29913
+ getLogger().info(
29914
+ `Barge-in suppressed: transcript matches agent's own speech (echo) \u2014 ${sanitizeLogValue(
29915
+ transcript.text.slice(0, 40)
29916
+ )}`
29917
+ );
29918
+ return false;
29919
+ }
29452
29920
  if (!this.canBargeIn()) {
29453
29921
  getLogger().info(
29454
29922
  `Barge-in transcript suppressed (agent speaking < gate, aec=${this.aec ? "on" : "off"})`
@@ -29488,6 +29956,18 @@ Avoid:
29488
29956
  */
29489
29957
  handleBargeIn(transcript) {
29490
29958
  if (!transcript.text || !this.isSpeaking) return false;
29959
+ if (this.tailGraceActive) {
29960
+ this.endTailGraceForNewTurn();
29961
+ return false;
29962
+ }
29963
+ if (this.forwardSttWhileSpeaking && looksLikeEcho(transcript.text, this.currentAgentSpokenText)) {
29964
+ getLogger().info(
29965
+ `Barge-in suppressed: transcript matches agent's own speech (echo) \u2014 ${sanitizeLogValue(
29966
+ transcript.text.slice(0, 40)
29967
+ )}`
29968
+ );
29969
+ return false;
29970
+ }
29491
29971
  if (this.bargeInStrategies.length === 0) {
29492
29972
  if (!this.canBargeIn()) {
29493
29973
  getLogger().info(
@@ -29519,6 +29999,7 @@ Avoid:
29519
29999
  this.metricsAcc.recordBargeinDetected();
29520
30000
  const bargeinSpan = startSpan(SPAN_BARGEIN, { "patter.call.id": this.callId });
29521
30001
  try {
30002
+ this.maybeTruncateCompletedTurnHistory();
29522
30003
  this.cancelSpeaking();
29523
30004
  try {
29524
30005
  this.deps.bridge.sendClear(this.ws, this.streamSid);
@@ -29582,15 +30063,21 @@ Avoid:
29582
30063
  getLogger().debug(`Dropped likely STT hallucination: ${sanitizeLogValue(normalised.slice(0, 40))}`);
29583
30064
  return false;
29584
30065
  }
30066
+ if (this.forwardSttWhileSpeaking && this.isSpeaking && looksLikeEcho(text, this.currentAgentSpokenText)) {
30067
+ getLogger().debug(
30068
+ `Dropped agent-echo transcript (not a user turn): ${sanitizeLogValue(normalised.slice(0, 40))}`
30069
+ );
30070
+ return false;
30071
+ }
29585
30072
  if (sinceLastMs < 2e3 && normalised === this.lastCommitText) {
29586
30073
  getLogger().debug(
29587
30074
  `Dropped duplicate final transcript (${(sinceLastMs / 1e3).toFixed(1)}s since last): ${sanitizeLogValue(normalised.slice(0, 40))}`
29588
30075
  );
29589
30076
  return false;
29590
30077
  }
29591
- if (sinceLastMs < 500) {
30078
+ if (sinceLastMs < 500 && isNearDuplicate(normalised, this.lastCommitText)) {
29592
30079
  getLogger().debug(
29593
- `Dropped back-to-back final transcript (${(sinceLastMs / 1e3).toFixed(2)}s since last): ${sanitizeLogValue(normalised.slice(0, 40))}`
30080
+ `Dropped back-to-back near-duplicate final (${(sinceLastMs / 1e3).toFixed(2)}s since last): ${sanitizeLogValue(normalised.slice(0, 40))}`
29594
30081
  );
29595
30082
  return false;
29596
30083
  }
@@ -29598,11 +30085,63 @@ Avoid:
29598
30085
  this.lastCommitAt = now;
29599
30086
  return true;
29600
30087
  }
30088
+ /**
30089
+ * Schedule the opt-in long-turn filler and return its async ``clear()``.
30090
+ *
30091
+ * When ``agent.longTurnMessage`` is unset / empty the returned clear is a
30092
+ * no-op (byte-identical to today's behaviour). Otherwise a one-shot timer
30093
+ * fires after ``agent.longTurnMessageAfterS`` seconds and, IFF no audio has
30094
+ * reached the carrier this turn (``!ttsFirstByteSent.value``) AND we still own
30095
+ * the floor (``this.isSpeaking``), synthesizes the filler ONCE via the same
30096
+ * per-sentence TTS primitive every sentence uses.
30097
+ *
30098
+ * The returned ``clear()`` is **async**: it stops the timer AND, if the filler
30099
+ * already started synthesizing (its ``setTimeout`` callback runs in a separate
30100
+ * macro-task, so it can fire just before the first real sentence), AWAITS the
30101
+ * in-flight synthesis so the filler audio can never interleave with the real
30102
+ * sentence that follows. Idempotent; self-synthesis failure degrades to
30103
+ * silence (never crashes the turn). The caller must clear on first real audio,
30104
+ * on the error branch, and in the finally.
30105
+ */
30106
+ scheduleLongTurnFiller(ttsFirstByteSent, hookExecutor, hookCtx, label) {
30107
+ const message = this.deps.agent.longTurnMessage;
30108
+ if (!message) return async () => {
30109
+ };
30110
+ const afterS = this.deps.agent.longTurnMessageAfterS ?? 4;
30111
+ let cancelled = false;
30112
+ let inFlight = null;
30113
+ const timer = setTimeout(() => {
30114
+ if (cancelled || ttsFirstByteSent.value || !this.isSpeaking) return;
30115
+ inFlight = this.synthesizeSentence(
30116
+ message,
30117
+ hookExecutor,
30118
+ hookCtx,
30119
+ ttsFirstByteSent,
30120
+ false
30121
+ ).catch((err) => {
30122
+ getLogger().error(
30123
+ `longTurnMessage filler synthesis failed (${label}):`,
30124
+ err
30125
+ );
30126
+ });
30127
+ }, Math.max(0, afterS * 1e3));
30128
+ return async () => {
30129
+ cancelled = true;
30130
+ clearTimeout(timer);
30131
+ if (inFlight !== null) {
30132
+ const pending = inFlight;
30133
+ inFlight = null;
30134
+ await pending;
30135
+ }
30136
+ };
30137
+ }
29601
30138
  /**
29602
30139
  * Streaming built-in LLM path with sentence chunking and per-sentence
29603
- * guardrails/TTS. Returns the concatenated response text.
30140
+ * guardrails/TTS. Returns the concatenated (plain) response text plus whether
30141
+ * the turn was cut short by a barge-in — the caller applies the interrupted
30142
+ * marker to history only, keeping metrics on the plain text.
29604
30143
  */
29605
- async runPipelineLlm(filteredTranscript, hookExecutor, hookCtx) {
30144
+ async runPipelineLlm(filteredTranscript, hookExecutor, hookCtx, historySnapshot) {
29606
30145
  const label = this.deps.bridge.label;
29607
30146
  const callCtx = { call_id: this.callId, caller: this.caller, callee: this.callee };
29608
30147
  const chunker = new SentenceChunker({
@@ -29615,6 +30154,12 @@ Avoid:
29615
30154
  this.llmAbort = new AbortController();
29616
30155
  const llmSignal = this.llmAbort.signal;
29617
30156
  let llmError = false;
30157
+ const clearLongTurnFiller = this.scheduleLongTurnFiller(
30158
+ ttsFirstByteSent,
30159
+ hookExecutor,
30160
+ hookCtx,
30161
+ label
30162
+ );
29618
30163
  const llmSpan = startSpan(SPAN_LLM, { "patter.call.id": this.callId });
29619
30164
  const guardAndSpeak = async (sentence, isFirst) => {
29620
30165
  if (isFirst) this.metricsAcc.recordLlmFirstSentenceComplete();
@@ -29625,6 +30170,7 @@ Avoid:
29625
30170
  if (transformed === null) return;
29626
30171
  sentenceText = transformed;
29627
30172
  }
30173
+ await clearLongTurnFiller();
29628
30174
  await this.synthesizeSentence(sentenceText, hookExecutor, hookCtx, ttsFirstByteSent);
29629
30175
  };
29630
30176
  let firstSentenceEmitted = false;
@@ -29632,7 +30178,7 @@ Avoid:
29632
30178
  try {
29633
30179
  for await (const token of this.llmLoop.run(
29634
30180
  filteredTranscript,
29635
- this.history.entries,
30181
+ historySnapshot,
29636
30182
  callCtx,
29637
30183
  this.metricsAcc,
29638
30184
  hookExecutor,
@@ -29643,6 +30189,7 @@ Avoid:
29643
30189
  this.metricsAcc.recordLlmFirstToken();
29644
30190
  await this.emitLlmFirstToken();
29645
30191
  allParts.push(token);
30192
+ this.currentAgentSpokenText = allParts.join("");
29646
30193
  for (const sentence of chunker.push(token)) {
29647
30194
  if (!this.isSpeaking) break;
29648
30195
  await guardAndSpeak(sentence, !firstSentenceEmitted);
@@ -29652,6 +30199,7 @@ Avoid:
29652
30199
  }
29653
30200
  } catch (e) {
29654
30201
  const isAbort = e?.name === "AbortError" || llmSignal.aborted;
30202
+ await clearLongTurnFiller();
29655
30203
  if (!isAbort) {
29656
30204
  llmError = true;
29657
30205
  chunker.reset();
@@ -29660,7 +30208,7 @@ Avoid:
29660
30208
  const fallback = this.deps.agent.llmErrorMessage;
29661
30209
  if (fallback && !ttsFirstByteSent.value && this.isSpeaking) {
29662
30210
  try {
29663
- await this.synthesizeSentence(fallback, hookExecutor, hookCtx, ttsFirstByteSent);
30211
+ await this.synthesizeSentence(fallback, hookExecutor, hookCtx, ttsFirstByteSent, false);
29664
30212
  } catch (err) {
29665
30213
  getLogger().error(`llmErrorMessage fallback synthesis failed (${label}):`, err);
29666
30214
  }
@@ -29676,6 +30224,7 @@ Avoid:
29676
30224
  }
29677
30225
  }
29678
30226
  } finally {
30227
+ await clearLongTurnFiller();
29679
30228
  this.endSpeakingWithGrace();
29680
30229
  this.llmAbort = null;
29681
30230
  try {
@@ -29683,7 +30232,7 @@ Avoid:
29683
30232
  } catch {
29684
30233
  }
29685
30234
  }
29686
- return allParts.join("");
30235
+ return { text: allParts.join(""), interrupted: llmSignal.aborted };
29687
30236
  }
29688
30237
  /**
29689
30238
  * Non-streaming path (onMessage function / webhook): apply output guardrails,
@@ -30770,7 +31319,7 @@ function validateTelnyxSignature(rawBody, signature, timestamp, publicKey, toler
30770
31319
  if (!Number.isFinite(ts)) return false;
30771
31320
  const tsMs = ts < 1e12 ? ts * 1e3 : ts;
30772
31321
  const ageMs = Date.now() - tsMs;
30773
- if (ageMs < 0 || ageMs > toleranceSec * 1e3) return false;
31322
+ if (ageMs > toleranceSec * 1e3 || ageMs < -TELNYX_FUTURE_SKEW_MS) return false;
30774
31323
  const payload = `${timestamp}|${rawBody}`;
30775
31324
  const keyBuffer = Buffer.from(publicKey, "base64");
30776
31325
  const keyObject = import_node_crypto4.default.createPublicKey({
@@ -30816,7 +31365,7 @@ function sanitizeVariables(raw) {
30816
31365
  for (const key of Object.keys(raw)) {
30817
31366
  if (BLOCKED_KEYS.has(key)) continue;
30818
31367
  const val = raw[key];
30819
- safe[key] = typeof val === "string" ? val : String(val ?? "");
31368
+ safe[key] = (typeof val === "string" ? val : String(val ?? "")).replace(/[\x00-\x1f\x7f]/g, "").slice(0, 500);
30820
31369
  }
30821
31370
  return safe;
30822
31371
  }
@@ -30911,7 +31460,7 @@ async function sleep(ms) {
30911
31460
  if (ms <= 0) return;
30912
31461
  await new Promise((resolve2) => setTimeout(resolve2, ms));
30913
31462
  }
30914
- var import_node_crypto4, import_express, import_http, import_ws5, TRANSFER_CALL_TOOL, END_CALL_TOOL, TwilioBridge, TELNYX_DTMF_ALLOWED, TELNYX_DTMF_DURATION_MS, TelnyxBridge, GRACEFUL_SHUTDOWN_TIMEOUT_MS, EmbeddedServer;
31463
+ var import_node_crypto4, import_express, import_http, import_ws5, TRANSFER_CALL_TOOL, END_CALL_TOOL, TELNYX_FUTURE_SKEW_MS, TwilioBridge, TELNYX_DTMF_ALLOWED, TELNYX_DTMF_DURATION_MS, TelnyxBridge, GRACEFUL_SHUTDOWN_TIMEOUT_MS, EmbeddedServer;
30915
31464
  var init_server = __esm({
30916
31465
  "src/server.ts"() {
30917
31466
  "use strict";
@@ -30920,6 +31469,7 @@ var init_server = __esm({
30920
31469
  import_express = __toESM(require("express"));
30921
31470
  import_http = require("http");
30922
31471
  import_ws5 = require("ws");
31472
+ init_call_metrics();
30923
31473
  init_openai_realtime_2();
30924
31474
  init_elevenlabs_convai();
30925
31475
  init_plivo_adapter();
@@ -30959,6 +31509,7 @@ var init_server = __esm({
30959
31509
  }
30960
31510
  }
30961
31511
  };
31512
+ TELNYX_FUTURE_SKEW_MS = 3e4;
30962
31513
  TwilioBridge = class {
30963
31514
  constructor(config2) {
30964
31515
  this.config = config2;
@@ -31260,6 +31811,9 @@ var init_server = __esm({
31260
31811
  twilioTokenWarningLogged = false;
31261
31812
  telnyxSigWarningLogged = false;
31262
31813
  metricsStore;
31814
+ /** Anonymous telemetry client, set by ``client.ts`` ``serve()``; emits the
31815
+ * per-call ``call_completed`` event from the call-end path. */
31816
+ telemetry;
31263
31817
  pricing;
31264
31818
  remoteHandler = new RemoteMessageHandler();
31265
31819
  /**
@@ -31363,6 +31917,12 @@ var init_server = __esm({
31363
31917
  * Mirrors Python's ``_resolve_completion``.
31364
31918
  */
31365
31919
  resolveCompletion(callId, args) {
31920
+ if (args.outcome === "no_answer" || args.outcome === "busy" || args.outcome === "failed") {
31921
+ recordCallCompleted(this.telemetry, {
31922
+ outcome: args.outcome,
31923
+ carrier: this.config.telephonyProvider
31924
+ });
31925
+ }
31366
31926
  const entry = this.completions.get(callId);
31367
31927
  if (!entry || entry.done) return;
31368
31928
  const data = args.data;
@@ -32111,7 +32671,13 @@ var init_server = __esm({
32111
32671
  return Object.fromEntries(Object.entries(snap).filter(([, v]) => v !== void 0));
32112
32672
  };
32113
32673
  const store = this.metricsStore;
32674
+ const telemetry = this.telemetry;
32114
32675
  const wrappedStart = async (data) => {
32676
+ recordCallStarted(telemetry, {
32677
+ providerMode: agent.provider ?? void 0,
32678
+ telephonyProvider: bridge.telephonyProvider,
32679
+ direction: data.direction
32680
+ });
32115
32681
  if (logger2.enabled) {
32116
32682
  const callId = typeof data.call_id === "string" ? data.call_id : "";
32117
32683
  const dataCaller = typeof data.caller === "string" ? data.caller : "";
@@ -32142,6 +32708,11 @@ var init_server = __esm({
32142
32708
  if (userMetrics) await userMetrics(data);
32143
32709
  };
32144
32710
  const wrappedEnd = async (data) => {
32711
+ recordCallCompleted(this.telemetry, {
32712
+ outcome: "completed",
32713
+ metrics: data.metrics,
32714
+ direction: data.direction
32715
+ });
32145
32716
  if (logger2.enabled) {
32146
32717
  const callId = typeof data.call_id === "string" ? data.call_id : "";
32147
32718
  const metricsObj = data.metrics ?? null;
@@ -32197,7 +32768,7 @@ var init_server = __esm({
32197
32768
  await handler.handleCallStart(callSid, customParameters);
32198
32769
  } else if (event === "media") {
32199
32770
  const payload = data.media?.payload ?? "";
32200
- handler.handleAudio(Buffer.from(payload, "base64"));
32771
+ await handler.handleAudio(Buffer.from(payload, "base64"));
32201
32772
  } else if (event === "mark") {
32202
32773
  const markName = String(data.mark?.name ?? "");
32203
32774
  if (markName) await handler.onMark(markName);
@@ -32209,6 +32780,7 @@ var init_server = __esm({
32209
32780
  }
32210
32781
  } catch (err) {
32211
32782
  getLogger().error("Stream handler error:", err);
32783
+ handler.recordError(err);
32212
32784
  }
32213
32785
  });
32214
32786
  ws.on("close", async () => {
@@ -32253,7 +32825,7 @@ var init_server = __esm({
32253
32825
  if (track !== "inbound") return;
32254
32826
  const audioChunk = data.media?.payload ?? "";
32255
32827
  if (!audioChunk) return;
32256
- handler.handleAudio(Buffer.from(audioChunk, "base64"));
32828
+ await handler.handleAudio(Buffer.from(audioChunk, "base64"));
32257
32829
  } else if (event === "dtmf") {
32258
32830
  const digit = String(data.dtmf?.digit ?? "").trim();
32259
32831
  if (digit) {
@@ -32267,9 +32839,11 @@ var init_server = __esm({
32267
32839
  }
32268
32840
  } catch (err) {
32269
32841
  getLogger().error("Stream handler error (Telnyx):", err);
32842
+ handler.recordError(err);
32270
32843
  }
32271
32844
  });
32272
32845
  ws.on("close", async () => {
32846
+ this.activeCallIds.delete(ws);
32273
32847
  await handler.handleWsClose();
32274
32848
  });
32275
32849
  }
@@ -32298,7 +32872,7 @@ var init_server = __esm({
32298
32872
  await handler.handleCallStart(callId);
32299
32873
  } else if (event === "media") {
32300
32874
  const payload = data.media?.payload ?? "";
32301
- if (payload) handler.handleAudio(Buffer.from(payload, "base64"));
32875
+ if (payload) await handler.handleAudio(Buffer.from(payload, "base64"));
32302
32876
  } else if (event === "playedStream") {
32303
32877
  const markName = String(data.name ?? "");
32304
32878
  if (markName) await handler.onMark(markName);
@@ -32312,6 +32886,7 @@ var init_server = __esm({
32312
32886
  }
32313
32887
  } catch (err) {
32314
32888
  getLogger().error("Stream handler error (Plivo):", err);
32889
+ handler.recordError(err);
32315
32890
  }
32316
32891
  });
32317
32892
  ws.on("close", async () => {
@@ -34192,6 +34767,7 @@ __export(index_exports, {
34192
34767
  CerebrasLLM: () => LLM4,
34193
34768
  ChatContext: () => ChatContext,
34194
34769
  CloudflareTunnel: () => CloudflareTunnel,
34770
+ CustomLLM: () => LLM7,
34195
34771
  DEFAULT_MIN_SENTENCE_LEN: () => DEFAULT_MIN_SENTENCE_LEN,
34196
34772
  DEFAULT_PRICING: () => DEFAULT_PRICING,
34197
34773
  DTMF_EVENTS: () => DTMF_EVENTS,
@@ -34215,7 +34791,7 @@ __export(index_exports, {
34215
34791
  GoogleLLM: () => LLM5,
34216
34792
  GroqLLM: () => LLM3,
34217
34793
  Guardrail: () => Guardrail,
34218
- HermesLLM: () => LLM7,
34794
+ HermesLLM: () => LLM8,
34219
34795
  IVRActivity: () => IVRActivity,
34220
34796
  InworldTTS: () => TTS7,
34221
34797
  KrispFrameDuration: () => KrispFrameDuration,
@@ -34241,7 +34817,7 @@ __export(index_exports, {
34241
34817
  OpenAITranscribeSTT: () => STT3,
34242
34818
  OpenAITranscriptionModel: () => OpenAITranscriptionModel,
34243
34819
  OpenAIVoice: () => OpenAIVoice,
34244
- OpenClawLLM: () => LLM8,
34820
+ OpenClawLLM: () => LLM9,
34245
34821
  PRICING_LAST_UPDATED: () => PRICING_LAST_UPDATED,
34246
34822
  PRICING_VERSION: () => PRICING_VERSION,
34247
34823
  PartialStreamError: () => PartialStreamError,
@@ -34310,6 +34886,7 @@ __export(index_exports, {
34310
34886
  createResampler24kTo16k: () => createResampler24kTo16k,
34311
34887
  createResampler24kTo8k: () => createResampler24kTo8k,
34312
34888
  createResampler8kTo16k: () => createResampler8kTo16k,
34889
+ custom: () => custom2,
34313
34890
  deepgram: () => deepgram,
34314
34891
  defineTool: () => defineTool,
34315
34892
  elevenlabs: () => elevenlabs,
@@ -34321,6 +34898,8 @@ __export(index_exports, {
34321
34898
  geminiLive: () => geminiLive,
34322
34899
  getLogger: () => getLogger,
34323
34900
  guardrail: () => guardrail,
34901
+ hashCaller: () => hashCaller,
34902
+ hermes: () => hermes,
34324
34903
  initTracing: () => initTracing,
34325
34904
  isRemoteUrl: () => isRemoteUrl,
34326
34905
  isTracingEnabled: () => isTracingEnabled,
@@ -34333,7 +34912,9 @@ __export(index_exports, {
34333
34912
  mountDashboard: () => mountDashboard,
34334
34913
  mulawToPcm16: () => mulawToPcm16,
34335
34914
  notifyDashboard: () => notifyDashboard,
34915
+ openaiCompatible: () => openaiCompatible,
34336
34916
  openaiTts: () => openaiTts,
34917
+ openclaw: () => openclaw,
34337
34918
  openclawConsult: () => openclawConsult,
34338
34919
  openclawPostCallNotifier: () => openclawPostCallNotifier,
34339
34920
  pcm16ToMulaw: () => pcm16ToMulaw,
@@ -34364,6 +34945,60 @@ init_cjs_shims();
34364
34945
  init_errors();
34365
34946
  init_server();
34366
34947
 
34948
+ // src/telephony/twilio.ts
34949
+ init_cjs_shims();
34950
+ var Carrier2 = class {
34951
+ kind = "twilio";
34952
+ accountSid;
34953
+ authToken;
34954
+ constructor(opts = {}) {
34955
+ const sid = opts.accountSid ?? process.env.TWILIO_ACCOUNT_SID;
34956
+ const tok = opts.authToken ?? process.env.TWILIO_AUTH_TOKEN;
34957
+ if (!sid) {
34958
+ throw new Error(
34959
+ "Twilio carrier requires accountSid. Pass { accountSid: 'AC...' } or set TWILIO_ACCOUNT_SID in the environment."
34960
+ );
34961
+ }
34962
+ if (!tok) {
34963
+ throw new Error(
34964
+ "Twilio carrier requires authToken. Pass { authToken: '...' } or set TWILIO_AUTH_TOKEN in the environment."
34965
+ );
34966
+ }
34967
+ this.accountSid = sid;
34968
+ this.authToken = tok;
34969
+ }
34970
+ };
34971
+
34972
+ // src/telephony/telnyx.ts
34973
+ init_cjs_shims();
34974
+ var Carrier3 = class {
34975
+ kind = "telnyx";
34976
+ apiKey;
34977
+ connectionId;
34978
+ publicKey;
34979
+ constructor(opts = {}) {
34980
+ const key = opts.apiKey ?? process.env.TELNYX_API_KEY;
34981
+ const conn = opts.connectionId ?? process.env.TELNYX_CONNECTION_ID;
34982
+ const pub = opts.publicKey ?? process.env.TELNYX_PUBLIC_KEY;
34983
+ if (!key) {
34984
+ throw new Error(
34985
+ "Telnyx carrier requires apiKey. Pass { apiKey: '...' } or set TELNYX_API_KEY in the environment."
34986
+ );
34987
+ }
34988
+ if (!conn) {
34989
+ throw new Error(
34990
+ "Telnyx carrier requires connectionId. Pass { connectionId: '...' } or set TELNYX_CONNECTION_ID in the environment."
34991
+ );
34992
+ }
34993
+ this.apiKey = key;
34994
+ this.connectionId = conn;
34995
+ this.publicKey = pub;
34996
+ }
34997
+ };
34998
+
34999
+ // src/client.ts
35000
+ init_plivo();
35001
+
34367
35002
  // src/engines/openai.ts
34368
35003
  init_cjs_shims();
34369
35004
  init_openai_realtime();
@@ -34590,6 +35225,570 @@ function validateAllToolSchemas(tools) {
34590
35225
  // src/client.ts
34591
35226
  init_logger();
34592
35227
 
35228
+ // src/telemetry/index.ts
35229
+ init_cjs_shims();
35230
+
35231
+ // src/telemetry/client.ts
35232
+ init_cjs_shims();
35233
+ init_logger();
35234
+
35235
+ // src/telemetry/consent.ts
35236
+ init_cjs_shims();
35237
+
35238
+ // src/telemetry/env.ts
35239
+ init_cjs_shims();
35240
+ var CI_ENV_VARS = [
35241
+ "CI",
35242
+ "CONTINUOUS_INTEGRATION",
35243
+ "GITHUB_ACTIONS",
35244
+ "GITLAB_CI",
35245
+ "TRAVIS",
35246
+ "CIRCLECI",
35247
+ "APPVEYOR",
35248
+ "TF_BUILD",
35249
+ "TEAMCITY_VERSION",
35250
+ "BUILDKITE",
35251
+ "DRONE",
35252
+ "JENKINS_URL",
35253
+ "HUDSON_URL",
35254
+ "BAMBOO_BUILDKEY",
35255
+ "CODEBUILD_BUILD_ID"
35256
+ ];
35257
+ var TEST_ENV_VARS = ["VITEST", "JEST_WORKER_ID"];
35258
+ function isTruthy(value) {
35259
+ if (value === void 0) return false;
35260
+ const v = value.trim().toLowerCase();
35261
+ return v !== "" && v !== "0" && v !== "false" && v !== "no" && v !== "off";
35262
+ }
35263
+ function isCi() {
35264
+ return CI_ENV_VARS.some((name) => isTruthy(process.env[name]));
35265
+ }
35266
+ function isTest() {
35267
+ if (TEST_ENV_VARS.some((name) => process.env[name] !== void 0)) return true;
35268
+ const node = (process.env.NODE_ENV ?? "").trim().toLowerCase();
35269
+ const patter = (process.env.PATTER_ENV ?? "").trim().toLowerCase();
35270
+ return node === "test" || patter === "test";
35271
+ }
35272
+
35273
+ // src/telemetry/install-id.ts
35274
+ init_cjs_shims();
35275
+ var import_node_crypto5 = require("crypto");
35276
+ var fs5 = __toESM(require("fs"));
35277
+ var os2 = __toESM(require("os"));
35278
+ var path5 = __toESM(require("path"));
35279
+ var RUN_ID = (0, import_node_crypto5.randomUUID)().replace(/-/g, "");
35280
+ var HEX32 = /^[0-9a-f]{32}$/;
35281
+ var VERSION_RE = /^[0-9][0-9a-z.+-]{0,31}$/;
35282
+ var cachedInstallId = null;
35283
+ function runId() {
35284
+ return RUN_ID;
35285
+ }
35286
+ function statePath() {
35287
+ const base = process.env.PATTER_TELEMETRY_STATE_DIR || process.env.XDG_STATE_HOME;
35288
+ const root = base && base.length > 0 ? base : path5.join(os2.homedir(), ".getpatter");
35289
+ return path5.join(root, "install-id");
35290
+ }
35291
+ function installId() {
35292
+ if (cachedInstallId !== null) return cachedInstallId;
35293
+ const p = statePath();
35294
+ try {
35295
+ const existing = fs5.readFileSync(p, "utf8").trim();
35296
+ if (HEX32.test(existing)) {
35297
+ cachedInstallId = existing;
35298
+ return cachedInstallId;
35299
+ }
35300
+ } catch {
35301
+ }
35302
+ const newId = (0, import_node_crypto5.randomUUID)().replace(/-/g, "");
35303
+ try {
35304
+ fs5.mkdirSync(path5.dirname(p), { recursive: true });
35305
+ fs5.writeFileSync(p, newId, "utf8");
35306
+ cachedInstallId = newId;
35307
+ } catch {
35308
+ cachedInstallId = RUN_ID;
35309
+ }
35310
+ return cachedInstallId;
35311
+ }
35312
+ function versionPath() {
35313
+ return path5.join(path5.dirname(statePath()), "version");
35314
+ }
35315
+ function previousVersion(current) {
35316
+ const p = versionPath();
35317
+ let prev = "";
35318
+ try {
35319
+ prev = fs5.readFileSync(p, "utf8").trim();
35320
+ } catch {
35321
+ prev = "";
35322
+ }
35323
+ try {
35324
+ fs5.mkdirSync(path5.dirname(p), { recursive: true });
35325
+ fs5.writeFileSync(p, current, "utf8");
35326
+ } catch {
35327
+ }
35328
+ return VERSION_RE.test(prev) ? prev : "";
35329
+ }
35330
+ function daysSinceInstallBucket() {
35331
+ let mtimeMs;
35332
+ try {
35333
+ mtimeMs = fs5.statSync(statePath()).mtimeMs;
35334
+ } catch {
35335
+ return "0";
35336
+ }
35337
+ const days = Math.max(0, Math.floor((Date.now() - mtimeMs) / 864e5));
35338
+ if (days === 0) return "0";
35339
+ if (days <= 7) return "1_7";
35340
+ if (days <= 30) return "8_30";
35341
+ return "30_plus";
35342
+ }
35343
+ function firstRunPath() {
35344
+ return path5.join(path5.dirname(statePath()), "first-run");
35345
+ }
35346
+ function isFirstRun() {
35347
+ const p = firstRunPath();
35348
+ try {
35349
+ if (fs5.existsSync(p)) return false;
35350
+ } catch {
35351
+ return false;
35352
+ }
35353
+ try {
35354
+ fs5.mkdirSync(path5.dirname(p), { recursive: true });
35355
+ fs5.writeFileSync(p, "1", "utf8");
35356
+ return true;
35357
+ } catch {
35358
+ return false;
35359
+ }
35360
+ }
35361
+ function optOutPath() {
35362
+ return path5.join(path5.dirname(statePath()), "telemetry-disabled");
35363
+ }
35364
+ function isOptedOut() {
35365
+ try {
35366
+ return fs5.existsSync(optOutPath());
35367
+ } catch {
35368
+ return false;
35369
+ }
35370
+ }
35371
+
35372
+ // src/telemetry/consent.ts
35373
+ function isEnabled(flag) {
35374
+ if (isTruthy(process.env.DO_NOT_TRACK)) return false;
35375
+ if (isTruthy(process.env.PATTER_TELEMETRY_DISABLED)) return false;
35376
+ if (isOptedOut()) return false;
35377
+ if (flag === false) return false;
35378
+ if (isCi() || isTest()) return false;
35379
+ return true;
35380
+ }
35381
+
35382
+ // src/telemetry/events.ts
35383
+ init_cjs_shims();
35384
+ var os3 = __toESM(require("os"));
35385
+
35386
+ // src/telemetry/stack.ts
35387
+ init_cjs_shims();
35388
+ var STACK_VENDORS = /* @__PURE__ */ new Set([
35389
+ "openai",
35390
+ "anthropic",
35391
+ "google",
35392
+ "cerebras",
35393
+ "groq",
35394
+ "deepgram",
35395
+ "elevenlabs",
35396
+ "cartesia",
35397
+ "whisper",
35398
+ "soniox",
35399
+ "assemblyai",
35400
+ "speechmatics",
35401
+ "lmnt",
35402
+ "rime",
35403
+ "inworld",
35404
+ "telnyx",
35405
+ "other"
35406
+ ]);
35407
+ var VENDOR_ALIASES = {
35408
+ cartesia_stt: "cartesia",
35409
+ cartesia_tts: "cartesia",
35410
+ openai_tts: "openai",
35411
+ openai_transcribe: "openai",
35412
+ elevenlabs_ws: "elevenlabs",
35413
+ telnyx_stt: "telnyx",
35414
+ telnyx_tts: "telnyx"
35415
+ };
35416
+ var RAW_UNSAFE_RE = /[^a-z0-9._-]/;
35417
+ var DATE_SUFFIX_RE = /-\d{8}$/;
35418
+ function vendorOf(providerKey) {
35419
+ if (!providerKey) return "other";
35420
+ const v = VENDOR_ALIASES[providerKey] ?? providerKey;
35421
+ return STACK_VENDORS.has(v) ? v : "other";
35422
+ }
35423
+ function modelToken(vendor, rawModel) {
35424
+ if (!rawModel) return `${vendor}-other`;
35425
+ const m = rawModel.trim().toLowerCase();
35426
+ if (m.length > 40 || RAW_UNSAFE_RE.test(m)) return `${vendor}-other`;
35427
+ const token = m.replace(/_/g, "-").replace(DATE_SUFFIX_RE, "").replace(/^[-.]+|[-.]+$/g, "");
35428
+ return token ? `${vendor}-${token}` : `${vendor}-other`;
35429
+ }
35430
+ function readProviderKey(obj) {
35431
+ const ctor = obj?.constructor;
35432
+ const key = ctor?.providerKey;
35433
+ return typeof key === "string" && key ? key : null;
35434
+ }
35435
+ function readModel(obj) {
35436
+ const rec = obj;
35437
+ for (const attr of ["model", "modelId", "_model"]) {
35438
+ const v = rec?.[attr];
35439
+ if (typeof v === "string" && v) return v;
35440
+ }
35441
+ return "";
35442
+ }
35443
+ function layerDims(obj, providerField, modelField) {
35444
+ if (obj === null || obj === void 0) return {};
35445
+ const vendor = vendorOf(readProviderKey(obj));
35446
+ return { [providerField]: vendor, [modelField]: modelToken(vendor, readModel(obj)) };
35447
+ }
35448
+ function stackDimensions(stt, tts, llm) {
35449
+ return {
35450
+ ...layerDims(stt, "stt_provider", "stt_model"),
35451
+ ...layerDims(tts, "tts_provider", "tts_model"),
35452
+ ...layerDims(llm, "llm_provider", "llm_model")
35453
+ };
35454
+ }
35455
+
35456
+ // src/telemetry/events.ts
35457
+ var SCHEMA_VERSION2 = 5;
35458
+ var EVENT_SDK_INITIALIZED = "sdk_initialized";
35459
+ var EVENT_FIRST_RUN = "first_run";
35460
+ var EVENT_CLI_COMMAND = "cli_command";
35461
+ var EVENT_FEATURE_USED = "feature_used";
35462
+ var EVENT_AGENT_CONFIGURED = "agent_configured";
35463
+ var EVENT_CALL_STARTED = "call_started";
35464
+ var EVENT_CALL_COMPLETED = "call_completed";
35465
+ var ALLOWED_EVENTS = /* @__PURE__ */ new Set([
35466
+ EVENT_SDK_INITIALIZED,
35467
+ EVENT_FIRST_RUN,
35468
+ EVENT_CLI_COMMAND,
35469
+ EVENT_FEATURE_USED,
35470
+ EVENT_AGENT_CONFIGURED,
35471
+ EVENT_CALL_STARTED,
35472
+ EVENT_CALL_COMPLETED
35473
+ ]);
35474
+ var DIMENSION_VALUES = {
35475
+ carrier: /* @__PURE__ */ new Set(["twilio", "telnyx", "plivo", "none"]),
35476
+ tunnel: /* @__PURE__ */ new Set(["static", "configured", "none"]),
35477
+ engine: /* @__PURE__ */ new Set(["realtime", "convai", "pipeline"]),
35478
+ provider: /* @__PURE__ */ new Set([
35479
+ "openai",
35480
+ "elevenlabs",
35481
+ "deepgram",
35482
+ "cartesia",
35483
+ "cerebras",
35484
+ "anthropic",
35485
+ "google",
35486
+ "whisper",
35487
+ "other"
35488
+ ]),
35489
+ // agent_configured dimensions
35490
+ custom_tool_count_bucket: /* @__PURE__ */ new Set(["0", "1", "2_3", "4_6", "7_12", "13_plus"]),
35491
+ integration: /* @__PURE__ */ new Set(["openclaw", "mcp", "hermes", "other", "none"]),
35492
+ integration_kind: /* @__PURE__ */ new Set(["consult", "mcp", "none"]),
35493
+ mcp_server_count_bucket: /* @__PURE__ */ new Set(["0", "1", "2_3", "4_plus"]),
35494
+ // call_started / call_completed: inbound vs outbound — a core usage split.
35495
+ direction: /* @__PURE__ */ new Set(["inbound", "outbound", "none"]),
35496
+ // cli_command: which CLI subcommand was invoked (never args/flags values).
35497
+ cli_command: /* @__PURE__ */ new Set(["dashboard", "eval", "telemetry", "none", "other"]),
35498
+ // call_completed: the call's terminal outcome
35499
+ outcome: /* @__PURE__ */ new Set(["completed", "error", "no_answer", "busy", "failed"]),
35500
+ // call_completed: terminal error code (mirrors ErrorCode, plus "other"). Never
35501
+ // the error message.
35502
+ error_code: /* @__PURE__ */ new Set([
35503
+ "config",
35504
+ "connection",
35505
+ "auth",
35506
+ "timeout",
35507
+ "rate_limit",
35508
+ "webhook_verification",
35509
+ "input_validation",
35510
+ "provider_error",
35511
+ "provision",
35512
+ "internal",
35513
+ "other"
35514
+ ]),
35515
+ // feature_used (pipeline): per-layer vendor of the composed stack. A
35516
+ // providerKey not on the closed allowlist collapses to "other"; an absent layer
35517
+ // is omitted (the value set keeps "none" only as a safety token).
35518
+ stt_provider: /* @__PURE__ */ new Set([...STACK_VENDORS, "none"]),
35519
+ tts_provider: /* @__PURE__ */ new Set([...STACK_VENDORS, "none"]),
35520
+ llm_provider: /* @__PURE__ */ new Set([...STACK_VENDORS, "none"]),
35521
+ // sdk_initialized: anonymous deploy-shape (presence-only env/file probes).
35522
+ invoked_by_agent: /* @__PURE__ */ new Set(["claude", "cursor", "copilot", "gemini", "windsurf", "other", "none"]),
35523
+ serverless: /* @__PURE__ */ new Set(["lambda", "cloud_run", "vercel", "azure_functions", "none"]),
35524
+ cloud: /* @__PURE__ */ new Set(["aws", "gcp", "azure", "fly", "none"]),
35525
+ package_manager: /* @__PURE__ */ new Set(["npm", "pnpm", "yarn", "bun", "pip", "uv", "poetry", "pipenv", "conda", "none"]),
35526
+ days_since_install_bucket: /* @__PURE__ */ new Set(["0", "1_7", "8_30", "30_plus"]),
35527
+ // agent_configured: feature-adoption (Realtime tuning).
35528
+ noise_reduction: /* @__PURE__ */ new Set(["near_field", "far_field", "none"]),
35529
+ turn_detection: /* @__PURE__ */ new Set(["default", "custom", "none"]),
35530
+ // call_completed: how many conversational turns the call had.
35531
+ turn_count_bucket: /* @__PURE__ */ new Set(["0", "1", "2_3", "4_6", "7_12", "13_plus"])
35532
+ };
35533
+ var NUMERIC_DIMENSIONS = /* @__PURE__ */ new Set([
35534
+ "builtin_tool_count",
35535
+ "latency_ms",
35536
+ "duration_seconds",
35537
+ "cost_usd"
35538
+ ]);
35539
+ var STRING_DIMENSIONS = /* @__PURE__ */ new Set([
35540
+ "stt_model",
35541
+ "tts_model",
35542
+ "llm_model",
35543
+ "previous_sdk_version"
35544
+ ]);
35545
+ var MODEL_TOKEN_RE = /^[a-z0-9][a-z0-9.-]{0,40}$/;
35546
+ var BOOL_DIMENSIONS = /* @__PURE__ */ new Set([
35547
+ "container",
35548
+ "preambles_used",
35549
+ "per_tool_timeouts_set",
35550
+ "llm_fallback_configured"
35551
+ ]);
35552
+ var ALLOWED_DIMENSIONS = /* @__PURE__ */ new Set([
35553
+ ...Object.keys(DIMENSION_VALUES),
35554
+ ...NUMERIC_DIMENSIONS,
35555
+ ...STRING_DIMENSIONS,
35556
+ ...BOOL_DIMENSIONS
35557
+ ]);
35558
+ function osFamily() {
35559
+ const p = os3.platform();
35560
+ if (p === "win32") return "windows";
35561
+ return p || "unknown";
35562
+ }
35563
+ function arch2() {
35564
+ const a = os3.arch();
35565
+ if (a === "x64") return "x86_64";
35566
+ if (a === "arm64") return "arm64";
35567
+ return "other";
35568
+ }
35569
+ function runtimeVersion() {
35570
+ const parts = (process.versions.node ?? "0.0").split(".");
35571
+ return `${parts[0] ?? "0"}.${parts[1] ?? "0"}`;
35572
+ }
35573
+ function buildEvent(name, opts) {
35574
+ if (!ALLOWED_EVENTS.has(name)) {
35575
+ throw new Error(`unknown telemetry event: ${name}`);
35576
+ }
35577
+ const event = {
35578
+ event: name,
35579
+ schema_version: SCHEMA_VERSION2,
35580
+ run_id: runId(),
35581
+ install_id: installId(),
35582
+ sdk: "typescript",
35583
+ sdk_version: opts.sdkVersion,
35584
+ os: osFamily(),
35585
+ arch: arch2(),
35586
+ runtime: "node",
35587
+ runtime_version: runtimeVersion(),
35588
+ ci: isCi() || isTest()
35589
+ };
35590
+ for (const [key, raw] of Object.entries(opts.dimensions ?? {})) {
35591
+ if (!ALLOWED_DIMENSIONS.has(key) || raw === null || raw === void 0) {
35592
+ continue;
35593
+ }
35594
+ let value = raw;
35595
+ const allowed = DIMENSION_VALUES[key];
35596
+ if (allowed && !(typeof value === "string" && allowed.has(value))) {
35597
+ value = "other";
35598
+ } else if (STRING_DIMENSIONS.has(key)) {
35599
+ if (!(typeof value === "string" && MODEL_TOKEN_RE.test(value))) {
35600
+ continue;
35601
+ }
35602
+ } else if (BOOL_DIMENSIONS.has(key) && typeof value !== "boolean") {
35603
+ continue;
35604
+ }
35605
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
35606
+ event[key] = value;
35607
+ }
35608
+ }
35609
+ return event;
35610
+ }
35611
+
35612
+ // src/telemetry/client.ts
35613
+ var DEFAULT_ENDPOINT = "https://telemetry.getpatter.com/v1/ingest";
35614
+ var TIMEOUT_MS = 3e3;
35615
+ var BUFFER_MAX = 256;
35616
+ var noticeShown = false;
35617
+ var liveClients = /* @__PURE__ */ new Set();
35618
+ var exitHookRegistered = false;
35619
+ function showNoticeOnce() {
35620
+ if (noticeShown) return;
35621
+ noticeShown = true;
35622
+ getLogger().info(
35623
+ "Anonymous usage telemetry is on (no PII, no call content). Collected: a random anonymous install id, SDK version, language, OS family, runtime version, coarse feature flags, the composed stack (provider + model per layer), tool counts, integration category, and per-call duration, latency, cost, and error codes (no call content, no message text). Disable with PATTER_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1, or telemetry: false. Details: https://docs.getpatter.com/telemetry"
35624
+ );
35625
+ }
35626
+ function registerExitHook() {
35627
+ if (exitHookRegistered) return;
35628
+ exitHookRegistered = true;
35629
+ process.once("beforeExit", () => {
35630
+ for (const ref of [...liveClients]) {
35631
+ const client = ref.deref();
35632
+ if (client) void client.close();
35633
+ else liveClients.delete(ref);
35634
+ }
35635
+ });
35636
+ }
35637
+ var TelemetryClient = class {
35638
+ sdkVersion;
35639
+ enabledFlag;
35640
+ endpoint;
35641
+ debug;
35642
+ buffer = [];
35643
+ flushing = false;
35644
+ closed = false;
35645
+ selfRef = new WeakRef(this);
35646
+ constructor(options) {
35647
+ this.sdkVersion = options.sdkVersion;
35648
+ this.enabledFlag = isEnabled(options.flag);
35649
+ this.endpoint = options.endpoint ?? process.env.PATTER_TELEMETRY_ENDPOINT ?? DEFAULT_ENDPOINT;
35650
+ this.debug = isTruthy(process.env.PATTER_TELEMETRY_DEBUG);
35651
+ if (this.enabledFlag && !this.debug) {
35652
+ showNoticeOnce();
35653
+ registerExitHook();
35654
+ liveClients.add(this.selfRef);
35655
+ }
35656
+ }
35657
+ get enabled() {
35658
+ return this.enabledFlag;
35659
+ }
35660
+ /** Enqueue an event. Fire-and-forget; never throws, never blocks. */
35661
+ record(name, dimensions) {
35662
+ if (!this.enabledFlag || this.closed) return;
35663
+ let event;
35664
+ try {
35665
+ event = buildEvent(name, { sdkVersion: this.sdkVersion, dimensions });
35666
+ } catch (err) {
35667
+ getLogger().debug("telemetry buildEvent failed", err);
35668
+ return;
35669
+ }
35670
+ if (this.debug) {
35671
+ try {
35672
+ process.stderr.write(`[patter telemetry] ${JSON.stringify(event)}
35673
+ `);
35674
+ } catch {
35675
+ }
35676
+ return;
35677
+ }
35678
+ try {
35679
+ if (this.buffer.length >= BUFFER_MAX) this.buffer.shift();
35680
+ this.buffer.push(event);
35681
+ this.scheduleFlush();
35682
+ } catch (err) {
35683
+ getLogger().debug("telemetry enqueue failed", err);
35684
+ }
35685
+ }
35686
+ /**
35687
+ * Schedule a flush of any buffered events. Events recorded before the server
35688
+ * is running (e.g. at `new Patter(...)`) sit in the buffer; call this once the
35689
+ * server is up so they ship promptly. Cheap when disabled or buffer is empty.
35690
+ */
35691
+ flushPending() {
35692
+ if (!this.enabledFlag || this.debug) return;
35693
+ try {
35694
+ this.scheduleFlush();
35695
+ } catch (err) {
35696
+ getLogger().debug("telemetry flushPending failed", err);
35697
+ }
35698
+ }
35699
+ /** Flush remaining events (graceful shutdown). Never throws. */
35700
+ async close() {
35701
+ if (this.closed) return;
35702
+ this.closed = true;
35703
+ liveClients.delete(this.selfRef);
35704
+ if (!this.enabledFlag || this.debug) return;
35705
+ try {
35706
+ await this.flush();
35707
+ } catch (err) {
35708
+ getLogger().debug("telemetry close flush failed", err);
35709
+ }
35710
+ }
35711
+ scheduleFlush() {
35712
+ if (this.flushing) return;
35713
+ this.flushing = true;
35714
+ void this.flush().finally(() => {
35715
+ this.flushing = false;
35716
+ });
35717
+ }
35718
+ async flush() {
35719
+ if (this.buffer.length === 0) return;
35720
+ const events = this.buffer.splice(0, this.buffer.length);
35721
+ const controller = new AbortController();
35722
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
35723
+ timer.unref?.();
35724
+ try {
35725
+ await fetch(this.endpoint, {
35726
+ method: "POST",
35727
+ headers: { "content-type": "application/json" },
35728
+ body: JSON.stringify(events),
35729
+ signal: controller.signal
35730
+ });
35731
+ } catch (err) {
35732
+ getLogger().debug("telemetry flush failed", err);
35733
+ } finally {
35734
+ clearTimeout(timer);
35735
+ }
35736
+ }
35737
+ };
35738
+
35739
+ // src/telemetry/environment.ts
35740
+ init_cjs_shims();
35741
+ var fs6 = __toESM(require("fs"));
35742
+ var env = process.env;
35743
+ function invokedByAgent() {
35744
+ if ("CLAUDECODE" in env || "CLAUDE_CODE" in env || "CLAUDE_CODE_ENTRYPOINT" in env)
35745
+ return "claude";
35746
+ if ("CURSOR_TRACE_ID" in env || "CURSOR_AGENT" in env) return "cursor";
35747
+ if ("GITHUB_COPILOT_AGENT" in env || "COPILOT_AGENT_ID" in env) return "copilot";
35748
+ if ("GEMINI_CLI" in env || "GEMINI_AGENT" in env) return "gemini";
35749
+ if ("WINDSURF" in env || "WINDSURF_AGENT" in env) return "windsurf";
35750
+ if ("AIDER" in env || "OPENAI_AGENT" in env) return "other";
35751
+ return "none";
35752
+ }
35753
+ function inContainer() {
35754
+ try {
35755
+ if (fs6.existsSync("/.dockerenv")) return true;
35756
+ } catch {
35757
+ }
35758
+ if (env.KUBERNETES_SERVICE_HOST) return true;
35759
+ try {
35760
+ const blob = fs6.readFileSync("/proc/1/cgroup", "utf8");
35761
+ return blob.includes("docker") || blob.includes("containerd") || blob.includes("kubepods");
35762
+ } catch {
35763
+ return false;
35764
+ }
35765
+ }
35766
+ function serverless() {
35767
+ if (env.AWS_LAMBDA_FUNCTION_NAME) return "lambda";
35768
+ if (env.K_SERVICE) return "cloud_run";
35769
+ if (env.VERCEL) return "vercel";
35770
+ if (env.AZURE_FUNCTIONS_ENVIRONMENT || env.FUNCTIONS_WORKER_RUNTIME) return "azure_functions";
35771
+ return "none";
35772
+ }
35773
+ function cloud() {
35774
+ if (env.AWS_REGION || env.AWS_EXECUTION_ENV || env.AWS_LAMBDA_FUNCTION_NAME) return "aws";
35775
+ if (env.K_SERVICE || env.GOOGLE_CLOUD_PROJECT || env.GCP_PROJECT) return "gcp";
35776
+ if (env.WEBSITE_INSTANCE_ID || env.AZURE_FUNCTIONS_ENVIRONMENT) return "azure";
35777
+ if (env.FLY_APP_NAME) return "fly";
35778
+ return "none";
35779
+ }
35780
+ function packageManager() {
35781
+ const ua = env.npm_config_user_agent ?? "";
35782
+ if (ua.startsWith("pnpm")) return "pnpm";
35783
+ if (ua.startsWith("yarn")) return "yarn";
35784
+ if (ua.startsWith("bun")) return "bun";
35785
+ if (ua.startsWith("npm")) return "npm";
35786
+ return "none";
35787
+ }
35788
+
35789
+ // src/client.ts
35790
+ init_version();
35791
+
34593
35792
  // src/_speech-events.ts
34594
35793
  init_cjs_shims();
34595
35794
  init_logger();
@@ -34895,6 +36094,79 @@ function closeParkedConnections(slot) {
34895
36094
  }
34896
36095
  }
34897
36096
  }
36097
+ function carrierFamily2(carrier) {
36098
+ if (carrier instanceof Carrier2) return "twilio";
36099
+ if (carrier instanceof Carrier3) return "telnyx";
36100
+ if (carrier instanceof Carrier) return "plivo";
36101
+ return "none";
36102
+ }
36103
+ function telemetryEngineFamily(opts) {
36104
+ if (opts.engine) {
36105
+ return opts.engine.constructor.name.toLowerCase().includes("convai") ? "convai" : "realtime";
36106
+ }
36107
+ if (opts.provider === "elevenlabs_convai") return "convai";
36108
+ if (opts.provider === "pipeline") return "pipeline";
36109
+ if (opts.provider === "openai_realtime") return "realtime";
36110
+ if (opts.stt || opts.tts) return "pipeline";
36111
+ return "realtime";
36112
+ }
36113
+ function telemetryProviderFamily(family) {
36114
+ if (family === "realtime") return "openai";
36115
+ if (family === "convai") return "elevenlabs";
36116
+ return "other";
36117
+ }
36118
+ function telemetryBucketCustomTools(n) {
36119
+ if (n <= 0) return "0";
36120
+ if (n === 1) return "1";
36121
+ if (n <= 3) return "2_3";
36122
+ if (n <= 6) return "4_6";
36123
+ if (n <= 12) return "7_12";
36124
+ return "13_plus";
36125
+ }
36126
+ function telemetryBucketMcp(n) {
36127
+ if (n <= 0) return "0";
36128
+ if (n === 1) return "1";
36129
+ if (n <= 3) return "2_3";
36130
+ return "4_plus";
36131
+ }
36132
+ function telemetryIntegration(opts) {
36133
+ const nMcp = opts.mcpServers?.length ?? 0;
36134
+ if (nMcp > 0) {
36135
+ return { integration: "mcp", integrationKind: "mcp", mcpBucket: telemetryBucketMcp(nMcp) };
36136
+ }
36137
+ if (opts.consult) {
36138
+ let isOpenclaw = false;
36139
+ const oc = opts.consult.openaiCompatible;
36140
+ if (oc) {
36141
+ const model = oc.model ?? "";
36142
+ const baseUrl = oc.baseUrl ?? "";
36143
+ isOpenclaw = model.startsWith("openclaw/") || baseUrl.includes(":18789");
36144
+ }
36145
+ return {
36146
+ integration: isOpenclaw ? "openclaw" : "other",
36147
+ integrationKind: "consult",
36148
+ mcpBucket: "0"
36149
+ };
36150
+ }
36151
+ return { integration: "none", integrationKind: "none", mcpBucket: "0" };
36152
+ }
36153
+ function telemetryEnvironmentDims() {
36154
+ try {
36155
+ const dims = {
36156
+ invoked_by_agent: invokedByAgent(),
36157
+ container: inContainer(),
36158
+ serverless: serverless(),
36159
+ cloud: cloud(),
36160
+ package_manager: packageManager(),
36161
+ days_since_install_bucket: daysSinceInstallBucket()
36162
+ };
36163
+ const prev = previousVersion(VERSION);
36164
+ if (prev) dims.previous_sdk_version = prev;
36165
+ return dims;
36166
+ } catch {
36167
+ return {};
36168
+ }
36169
+ }
34898
36170
  var Patter = class {
34899
36171
  localConfig;
34900
36172
  embeddedServer = null;
@@ -34915,6 +36187,14 @@ var Patter = class {
34915
36187
  * ``Cannot use both tunnel: true and webhookUrl``.
34916
36188
  */
34917
36189
  tunnelOwnsWebhookUrl = false;
36190
+ /**
36191
+ * Anonymous usage telemetry (opt-out, default ON). Separate from
36192
+ * ``./observability`` (user-facing OTel). Fire-and-forget and fail-safe — it
36193
+ * can never block or break a call. See ``./telemetry``.
36194
+ */
36195
+ telemetry;
36196
+ telemetrySeenEngines = /* @__PURE__ */ new Set();
36197
+ telemetrySeenAgentShapes = /* @__PURE__ */ new Set();
34918
36198
  /**
34919
36199
  * Pre-rendered first-message TTS audio per outbound call_id. Populated
34920
36200
  * by :meth:`call` when ``agent.prewarmFirstMessage`` is true; consumed
@@ -35120,6 +36400,22 @@ var Patter = class {
35120
36400
  openaiKey: options.openaiKey,
35121
36401
  persistRoot: resolvePersistRoot(options.persist)
35122
36402
  };
36403
+ this.telemetry = new TelemetryClient({
36404
+ sdkVersion: VERSION,
36405
+ flag: options.telemetry
36406
+ });
36407
+ const initDims = {
36408
+ carrier: carrierFamily2(carrier),
36409
+ tunnel: tunnel instanceof Static ? "static" : options.tunnel ? "configured" : "none",
36410
+ ...telemetryEnvironmentDims()
36411
+ };
36412
+ if (this.telemetry.enabled) {
36413
+ try {
36414
+ if (isFirstRun()) this.telemetry.record("first_run", initDims);
36415
+ } catch {
36416
+ }
36417
+ }
36418
+ this.telemetry.record("sdk_initialized", initDims);
35123
36419
  this._tunnelReady = new Promise((resolve2, reject) => {
35124
36420
  this._tunnelReadyResolve = resolve2;
35125
36421
  this._tunnelReadyReject = reject;
@@ -35139,6 +36435,55 @@ var Patter = class {
35139
36435
  // === Agent definition ===
35140
36436
  /** Resolve user-supplied agent options against engine defaults and return the merged config. */
35141
36437
  agent(opts) {
36438
+ const family = telemetryEngineFamily(opts);
36439
+ const stack = stackDimensions(opts.stt, opts.tts, opts.llm);
36440
+ const featureKey = family + "|" + Object.entries(stack).sort().map(([k, v]) => `${k}=${v}`).join(",");
36441
+ if (!this.telemetrySeenEngines.has(featureKey)) {
36442
+ this.telemetrySeenEngines.add(featureKey);
36443
+ this.telemetry.record("feature_used", {
36444
+ engine: family,
36445
+ provider: telemetryProviderFamily(family),
36446
+ ...stack
36447
+ });
36448
+ }
36449
+ const builtin = opts.consult ? 1 : 0;
36450
+ const customBucket = telemetryBucketCustomTools(opts.tools?.length ?? 0);
36451
+ const { integration, integrationKind, mcpBucket } = telemetryIntegration(opts);
36452
+ const engineObj = opts.engine;
36453
+ const nr = opts.openaiRealtimeNoiseReduction ?? engineObj?.noiseReduction;
36454
+ const noiseReduction = nr === "near_field" || nr === "far_field" ? nr : "none";
36455
+ const td = opts.realtimeTurnDetection ?? engineObj?.turnDetection;
36456
+ const turnDetection = td != null ? "custom" : "none";
36457
+ const preamblesUsed = Boolean(opts.toolCallPreambles);
36458
+ const perToolTimeoutsSet = Array.isArray(opts.tools) && opts.tools.some((t) => t.timeoutMs !== void 0);
36459
+ const llmFallbackConfigured = typeof opts.llm?.getAvailability === "function";
36460
+ const shapeKey = [
36461
+ builtin,
36462
+ customBucket,
36463
+ integration,
36464
+ integrationKind,
36465
+ mcpBucket,
36466
+ noiseReduction,
36467
+ turnDetection,
36468
+ preamblesUsed,
36469
+ perToolTimeoutsSet,
36470
+ llmFallbackConfigured
36471
+ ].join("|");
36472
+ if (!this.telemetrySeenAgentShapes.has(shapeKey)) {
36473
+ this.telemetrySeenAgentShapes.add(shapeKey);
36474
+ this.telemetry.record("agent_configured", {
36475
+ builtin_tool_count: builtin,
36476
+ custom_tool_count_bucket: customBucket,
36477
+ integration,
36478
+ integration_kind: integrationKind,
36479
+ mcp_server_count_bucket: mcpBucket,
36480
+ noise_reduction: noiseReduction,
36481
+ turn_detection: turnDetection,
36482
+ preambles_used: preamblesUsed,
36483
+ per_tool_timeouts_set: perToolTimeoutsSet,
36484
+ llm_fallback_configured: llmFallbackConfigured
36485
+ });
36486
+ }
35142
36487
  let working = { ...opts };
35143
36488
  if (opts.engine) {
35144
36489
  if (opts.provider) {
@@ -35328,6 +36673,7 @@ var Patter = class {
35328
36673
  opts.dashboardToken ?? "",
35329
36674
  opts.allowInsecureDashboard ?? false
35330
36675
  );
36676
+ this.embeddedServer.telemetry = this.telemetry;
35331
36677
  this.embeddedServer.popPrewarmAudio = this.popPrewarmAudio;
35332
36678
  this.embeddedServer.popPrewarmedConnections = this.popPrewarmedConnections;
35333
36679
  this.embeddedServer.recordPrewarmWaste = this.recordPrewarmWaste;
@@ -35337,6 +36683,7 @@ var Patter = class {
35337
36683
  await waitForTunnelPubliclyReachable(webhookUrl);
35338
36684
  }
35339
36685
  this._readyResolve(webhookUrl);
36686
+ this.telemetry.flushPending();
35340
36687
  } catch (err) {
35341
36688
  const e = err instanceof Error ? err : new Error(String(err));
35342
36689
  this._readyReject(e);
@@ -36019,6 +37366,7 @@ var Patter = class {
36019
37366
  * entries leak across ``serve`` / ``disconnect`` cycles. See FIX #93.
36020
37367
  */
36021
37368
  async disconnect() {
37369
+ this.telemetry.flushPending();
36022
37370
  for (const handle of this.prewarmTtlTimers.values()) {
36023
37371
  clearTimeout(handle);
36024
37372
  }
@@ -36620,6 +37968,7 @@ var PatterTool = class {
36620
37968
  maxDurationSec;
36621
37969
  recording;
36622
37970
  started = false;
37971
+ hermesTelemetryEmitted = false;
36623
37972
  /** Cached in-progress (or completed) start promise so concurrent execute()
36624
37973
  * callers all await the same boot sequence instead of each racing into
36625
37974
  * phone.serve(). Reset to null on failure so callers can retry after a
@@ -36776,6 +38125,20 @@ var PatterTool = class {
36776
38125
  * the same wire contract.
36777
38126
  */
36778
38127
  hermesHandler() {
38128
+ if (!this.hermesTelemetryEmitted) {
38129
+ this.hermesTelemetryEmitted = true;
38130
+ try {
38131
+ const tel = this.phone.telemetry;
38132
+ tel?.record("agent_configured", {
38133
+ builtin_tool_count: 0,
38134
+ custom_tool_count_bucket: "0",
38135
+ integration: "hermes",
38136
+ integration_kind: "none",
38137
+ mcp_server_count_bucket: "0"
38138
+ });
38139
+ } catch {
38140
+ }
38141
+ }
36779
38142
  return async (args) => {
36780
38143
  try {
36781
38144
  const result = await this.execute(args);
@@ -41868,11 +43231,16 @@ var LLM5 = class extends GoogleLLMProvider {
41868
43231
 
41869
43232
  // src/llm/openai-compatible.ts
41870
43233
  init_cjs_shims();
43234
+ var import_node_crypto6 = require("crypto");
41871
43235
  init_llm_loop();
41872
43236
  init_errors();
41873
43237
  init_logger();
41874
43238
  init_version();
41875
43239
  var DEFAULT_TIMEOUT_S = 60;
43240
+ function hashCaller(caller) {
43241
+ if (!caller) return void 0;
43242
+ return (0, import_node_crypto6.createHash)("sha256").update(caller, "utf8").digest("hex").slice(0, 16);
43243
+ }
41876
43244
  var OpenAICompatibleLLMProvider = class {
41877
43245
  /**
41878
43246
  * Stable pricing/dashboard key — read by stream-handler/metrics. Typed as
@@ -41891,6 +43259,7 @@ var OpenAICompatibleLLMProvider = class {
41891
43259
  sessionIdPrefix;
41892
43260
  sessionKeyHeader;
41893
43261
  sessionKey;
43262
+ sessionKeyFactory;
41894
43263
  temperature;
41895
43264
  maxTokens;
41896
43265
  responseFormat;
@@ -41920,6 +43289,17 @@ var OpenAICompatibleLLMProvider = class {
41920
43289
  this.sessionIdPrefix = options.sessionIdPrefix;
41921
43290
  this.sessionKeyHeader = options.sessionKeyHeader;
41922
43291
  this.sessionKey = options.sessionKey;
43292
+ let sessionKeyFactory = options.sessionKeyFactory;
43293
+ if (!sessionKeyFactory && options.sessionKeyFrom === "caller_hash") {
43294
+ sessionKeyFactory = (ctx) => ctx.callerHash ? `patter-caller-${ctx.callerHash}` : void 0;
43295
+ } else if (options.sessionKeyFrom !== void 0 && options.sessionKeyFrom !== "caller_hash") {
43296
+ throw new Error(
43297
+ `sessionKeyFrom must be 'caller_hash' or undefined, got ${JSON.stringify(
43298
+ options.sessionKeyFrom
43299
+ )}`
43300
+ );
43301
+ }
43302
+ this.sessionKeyFactory = sessionKeyFactory;
41923
43303
  this.temperature = options.temperature;
41924
43304
  this.maxTokens = options.maxTokens;
41925
43305
  this.responseFormat = options.responseFormat;
@@ -41943,7 +43323,7 @@ var OpenAICompatibleLLMProvider = class {
41943
43323
  * - ``sessionKeyHeader`` (+ ``sessionKey``) → the static ``sessionKey`` value.
41944
43324
  * ``sessionKey`` is a credential-grade memory scope and is never logged.
41945
43325
  */
41946
- buildHeaders(callId) {
43326
+ buildHeaders(callId, caller, callee) {
41947
43327
  const headers = {
41948
43328
  "Content-Type": "application/json",
41949
43329
  "User-Agent": `getpatter/${VERSION}`,
@@ -41955,11 +43335,33 @@ var OpenAICompatibleLLMProvider = class {
41955
43335
  if (this.sessionIdHeader && callId) {
41956
43336
  headers[this.sessionIdHeader] = `${this.sessionIdPrefix ?? ""}${callId}`;
41957
43337
  }
41958
- if (this.sessionKeyHeader && this.sessionKey) {
41959
- headers[this.sessionKeyHeader] = this.sessionKey;
43338
+ if (this.sessionKeyHeader) {
43339
+ const sessionKeyValue = this.resolveSessionKey(callId, caller, callee);
43340
+ if (sessionKeyValue) {
43341
+ headers[this.sessionKeyHeader] = sessionKeyValue;
43342
+ }
41960
43343
  }
41961
43344
  return headers;
41962
43345
  }
43346
+ /**
43347
+ * Resolve the ``sessionKeyHeader`` VALUE for this call. When a
43348
+ * ``sessionKeyFactory`` is configured it is called with a
43349
+ * {@link SessionContext} (the raw ``caller`` plus its non-reversible
43350
+ * {@link hashCaller}) and its return value wins — a falsy return omits the
43351
+ * header. Otherwise the static ``sessionKey`` is used. Never logged.
43352
+ */
43353
+ resolveSessionKey(callId, caller, callee) {
43354
+ if (this.sessionKeyFactory) {
43355
+ const ctx = {
43356
+ callId,
43357
+ caller,
43358
+ callee,
43359
+ callerHash: hashCaller(caller)
43360
+ };
43361
+ return this.sessionKeyFactory(ctx);
43362
+ }
43363
+ return this.sessionKey;
43364
+ }
41963
43365
  /**
41964
43366
  * Pre-call DNS / TLS warmup for the configured endpoint. Best-effort:
41965
43367
  * 5 s timeout, all exceptions swallowed at debug level. The ``Authorization``
@@ -42013,10 +43415,12 @@ var OpenAICompatibleLLMProvider = class {
42013
43415
  /** Stream Patter-format LLM chunks from the configured chat completions API. */
42014
43416
  async *stream(messages, tools, opts) {
42015
43417
  const callId = opts?.callId;
43418
+ const caller = opts?.caller;
43419
+ const callee = opts?.callee;
42016
43420
  const body = this.buildBody(messages, tools, callId);
42017
43421
  const response = await fetch(`${this.baseUrl}/chat/completions`, {
42018
43422
  method: "POST",
42019
- headers: this.buildHeaders(callId),
43423
+ headers: this.buildHeaders(callId, caller, callee),
42020
43424
  body: JSON.stringify(body),
42021
43425
  signal: mergeAbortSignals(opts?.signal, AbortSignal.timeout(this.timeoutMs))
42022
43426
  });
@@ -42036,6 +43440,13 @@ var LLM6 = class extends OpenAICompatibleLLMProvider {
42036
43440
  static providerKey = "openai_compatible";
42037
43441
  };
42038
43442
 
43443
+ // src/llm/custom.ts
43444
+ init_cjs_shims();
43445
+ var LLM7 = class extends OpenAICompatibleLLMProvider {
43446
+ /** Stable pricing/dashboard key — read by stream-handler/metrics. */
43447
+ static providerKey = "custom";
43448
+ };
43449
+
42039
43450
  // src/llm/hermes.ts
42040
43451
  init_cjs_shims();
42041
43452
  var BASE_URL = "http://127.0.0.1:8642/v1";
@@ -42047,7 +43458,7 @@ var SESSION_ID_HEADER = "X-Hermes-Session-Id";
42047
43458
  var SESSION_ID_PREFIX = "patter-call-";
42048
43459
  var SESSION_KEY_HEADER = "X-Hermes-Session-Key";
42049
43460
  var DEFAULT_TIMEOUT_S2 = 120;
42050
- var LLM7 = class extends OpenAICompatibleLLMProvider {
43461
+ var LLM8 = class extends OpenAICompatibleLLMProvider {
42051
43462
  static providerKey = "hermes";
42052
43463
  constructor(opts = {}) {
42053
43464
  const model = opts.model ?? process.env[MODEL_ENV] ?? DEFAULT_MODEL5;
@@ -42062,6 +43473,8 @@ var LLM7 = class extends OpenAICompatibleLLMProvider {
42062
43473
  sessionIdPrefix: SESSION_ID_PREFIX,
42063
43474
  sessionKeyHeader: SESSION_KEY_HEADER,
42064
43475
  sessionKey: opts.sessionKey,
43476
+ sessionKeyFrom: opts.sessionKeyFrom,
43477
+ sessionKeyFactory: opts.sessionKeyFactory,
42065
43478
  extraHeaders: opts.extraHeaders,
42066
43479
  temperature: opts.temperature,
42067
43480
  maxTokens: opts.maxTokens,
@@ -42086,7 +43499,7 @@ var SESSION_HEADER = "x-openclaw-session-key";
42086
43499
  var SESSION_USER_PREFIX2 = "patter-call-";
42087
43500
  var DEFAULT_TIMEOUT_S3 = 120;
42088
43501
  var OPENCLAW_AGENT_RE2 = /^[A-Za-z0-9._:/-]+$/;
42089
- var LLM8 = class extends OpenAICompatibleLLMProvider {
43502
+ var LLM9 = class extends OpenAICompatibleLLMProvider {
42090
43503
  static providerKey = "openclaw";
42091
43504
  constructor(opts) {
42092
43505
  const agent = opts?.agent;
@@ -42360,57 +43773,6 @@ var KrispVivaFilter = class {
42360
43773
  }
42361
43774
  };
42362
43775
 
42363
- // src/telephony/twilio.ts
42364
- init_cjs_shims();
42365
- var Carrier2 = class {
42366
- kind = "twilio";
42367
- accountSid;
42368
- authToken;
42369
- constructor(opts = {}) {
42370
- const sid = opts.accountSid ?? process.env.TWILIO_ACCOUNT_SID;
42371
- const tok = opts.authToken ?? process.env.TWILIO_AUTH_TOKEN;
42372
- if (!sid) {
42373
- throw new Error(
42374
- "Twilio carrier requires accountSid. Pass { accountSid: 'AC...' } or set TWILIO_ACCOUNT_SID in the environment."
42375
- );
42376
- }
42377
- if (!tok) {
42378
- throw new Error(
42379
- "Twilio carrier requires authToken. Pass { authToken: '...' } or set TWILIO_AUTH_TOKEN in the environment."
42380
- );
42381
- }
42382
- this.accountSid = sid;
42383
- this.authToken = tok;
42384
- }
42385
- };
42386
-
42387
- // src/telephony/telnyx.ts
42388
- init_cjs_shims();
42389
- var Carrier3 = class {
42390
- kind = "telnyx";
42391
- apiKey;
42392
- connectionId;
42393
- publicKey;
42394
- constructor(opts = {}) {
42395
- const key = opts.apiKey ?? process.env.TELNYX_API_KEY;
42396
- const conn = opts.connectionId ?? process.env.TELNYX_CONNECTION_ID;
42397
- const pub = opts.publicKey ?? process.env.TELNYX_PUBLIC_KEY;
42398
- if (!key) {
42399
- throw new Error(
42400
- "Telnyx carrier requires apiKey. Pass { apiKey: '...' } or set TELNYX_API_KEY in the environment."
42401
- );
42402
- }
42403
- if (!conn) {
42404
- throw new Error(
42405
- "Telnyx carrier requires connectionId. Pass { connectionId: '...' } or set TELNYX_CONNECTION_ID in the environment."
42406
- );
42407
- }
42408
- this.apiKey = key;
42409
- this.connectionId = conn;
42410
- this.publicKey = pub;
42411
- }
42412
- };
42413
-
42414
43776
  // src/index.ts
42415
43777
  init_plivo();
42416
43778
  init_openai_realtime_2();
@@ -42485,9 +43847,9 @@ init_tunnel();
42485
43847
 
42486
43848
  // src/chat-context.ts
42487
43849
  init_cjs_shims();
42488
- var import_node_crypto5 = require("crypto");
43850
+ var import_node_crypto7 = require("crypto");
42489
43851
  function generateId() {
42490
- return (0, import_node_crypto5.randomUUID)().replace(/-/g, "").slice(0, 12);
43852
+ return (0, import_node_crypto7.randomUUID)().replace(/-/g, "").slice(0, 12);
42491
43853
  }
42492
43854
  function createMessage(role, content, options) {
42493
43855
  return Object.freeze({
@@ -43136,8 +44498,8 @@ var TwilioAdapter = class _TwilioAdapter {
43136
44498
  this.baseUrl = opts.region ? `https://api.${opts.region}.twilio.com/2010-04-01` : TWILIO_API_BASE2;
43137
44499
  this.authHeader = `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString("base64")}`;
43138
44500
  }
43139
- async request(method, path6, body) {
43140
- const url2 = `${this.baseUrl}/Accounts/${encodeURIComponent(this.accountSid)}${path6}`;
44501
+ async request(method, path7, body) {
44502
+ const url2 = `${this.baseUrl}/Accounts/${encodeURIComponent(this.accountSid)}${path7}`;
43141
44503
  const headers = { Authorization: this.authHeader };
43142
44504
  if (body) headers["Content-Type"] = "application/x-www-form-urlencoded";
43143
44505
  const response = await fetch(url2, {
@@ -43148,7 +44510,7 @@ var TwilioAdapter = class _TwilioAdapter {
43148
44510
  });
43149
44511
  const text = await response.text();
43150
44512
  if (!response.ok) {
43151
- throw new Error(`Twilio ${method} ${path6} failed: ${response.status} ${text}`);
44513
+ throw new Error(`Twilio ${method} ${path7} failed: ${response.status} ${text}`);
43152
44514
  }
43153
44515
  if (!text) return {};
43154
44516
  try {
@@ -43166,8 +44528,8 @@ var TwilioAdapter = class _TwilioAdapter {
43166
44528
  const country = encodeURIComponent(opts.countryCode);
43167
44529
  const queryParts = ["PageSize=1"];
43168
44530
  if (opts.areaCode) queryParts.push(`AreaCode=${encodeURIComponent(opts.areaCode)}`);
43169
- const path6 = `/AvailablePhoneNumbers/${country}/Local.json?${queryParts.join("&")}`;
43170
- const available = await this.request("GET", path6);
44531
+ const path7 = `/AvailablePhoneNumbers/${country}/Local.json?${queryParts.join("&")}`;
44532
+ const available = await this.request("GET", path7);
43171
44533
  const first = available.available_phone_numbers?.[0]?.phone_number;
43172
44534
  if (!first) {
43173
44535
  throw new Error(`TwilioAdapter: no numbers available for country ${opts.countryCode}`);
@@ -43267,7 +44629,7 @@ var TwilioAdapter = class _TwilioAdapter {
43267
44629
 
43268
44630
  // src/providers/telnyx-adapter.ts
43269
44631
  init_cjs_shims();
43270
- var import_node_crypto6 = require("crypto");
44632
+ var import_node_crypto8 = require("crypto");
43271
44633
  init_logger();
43272
44634
  var TELNYX_API_BASE2 = "https://api.telnyx.com/v2";
43273
44635
  var TelnyxAdapter = class {
@@ -43279,8 +44641,8 @@ var TelnyxAdapter = class {
43279
44641
  this.apiKey = apiKey;
43280
44642
  this.connectionId = connectionId;
43281
44643
  }
43282
- async request(method, path6, body) {
43283
- const url2 = `${this.baseUrl}${path6}`;
44644
+ async request(method, path7, body) {
44645
+ const url2 = `${this.baseUrl}${path7}`;
43284
44646
  const headers = {
43285
44647
  Authorization: `Bearer ${this.apiKey}`
43286
44648
  };
@@ -43293,7 +44655,7 @@ var TelnyxAdapter = class {
43293
44655
  });
43294
44656
  const text = await response.text();
43295
44657
  if (!response.ok) {
43296
- throw new Error(`Telnyx ${method} ${path6} failed: ${response.status} ${text}`);
44658
+ throw new Error(`Telnyx ${method} ${path7} failed: ${response.status} ${text}`);
43297
44659
  }
43298
44660
  if (!text) return {};
43299
44661
  try {
@@ -43377,7 +44739,7 @@ var TelnyxAdapter = class {
43377
44739
  if (!callControlId) throw new Error("TelnyxAdapter: callControlId is required");
43378
44740
  const encoded = encodeURIComponent(callControlId);
43379
44741
  const body = {
43380
- command_id: opts.commandId ?? (0, import_node_crypto6.randomUUID)()
44742
+ command_id: opts.commandId ?? (0, import_node_crypto8.randomUUID)()
43381
44743
  };
43382
44744
  try {
43383
44745
  await this.request(
@@ -43658,6 +45020,12 @@ var TelnyxTTS = class {
43658
45020
  init_cjs_shims();
43659
45021
  init_tracing();
43660
45022
  init_event_bus();
45023
+
45024
+ // src/index.ts
45025
+ var hermes = Object.freeze({ LLM: LLM8 });
45026
+ var openclaw = Object.freeze({ LLM: LLM9 });
45027
+ var openaiCompatible = Object.freeze({ LLM: LLM6 });
45028
+ var custom2 = Object.freeze({ LLM: LLM7 });
43661
45029
  // Annotate the CommonJS export names for ESM import in node:
43662
45030
  0 && (module.exports = {
43663
45031
  AllProvidersFailedError,
@@ -43674,6 +45042,7 @@ init_event_bus();
43674
45042
  CerebrasLLM,
43675
45043
  ChatContext,
43676
45044
  CloudflareTunnel,
45045
+ CustomLLM,
43677
45046
  DEFAULT_MIN_SENTENCE_LEN,
43678
45047
  DEFAULT_PRICING,
43679
45048
  DTMF_EVENTS,
@@ -43792,6 +45161,7 @@ init_event_bus();
43792
45161
  createResampler24kTo16k,
43793
45162
  createResampler24kTo8k,
43794
45163
  createResampler8kTo16k,
45164
+ custom,
43795
45165
  deepgram,
43796
45166
  defineTool,
43797
45167
  elevenlabs,
@@ -43803,6 +45173,8 @@ init_event_bus();
43803
45173
  geminiLive,
43804
45174
  getLogger,
43805
45175
  guardrail,
45176
+ hashCaller,
45177
+ hermes,
43806
45178
  initTracing,
43807
45179
  isRemoteUrl,
43808
45180
  isTracingEnabled,
@@ -43815,7 +45187,9 @@ init_event_bus();
43815
45187
  mountDashboard,
43816
45188
  mulawToPcm16,
43817
45189
  notifyDashboard,
45190
+ openaiCompatible,
43818
45191
  openaiTts,
45192
+ openclaw,
43819
45193
  openclawConsult,
43820
45194
  openclawPostCallNotifier,
43821
45195
  pcm16ToMulaw,