getpatter 0.6.0 → 0.6.2

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.
@@ -174,6 +174,11 @@ var OnnxModel = class {
174
174
  const data = out.data;
175
175
  return data[0] ?? 0;
176
176
  }
177
+ /** Reset the RNN hidden state + rolling context to a fresh inference. */
178
+ reset() {
179
+ this.context = new Float32Array(this.contextSize);
180
+ this.rnnState = new Float32Array(2 * 1 * 128);
181
+ }
177
182
  };
178
183
  var SileroVAD = class _SileroVAD {
179
184
  constructor(model, opts) {
@@ -213,7 +218,11 @@ var SileroVAD = class _SileroVAD {
213
218
  const model = new OnnxModel(runtime, session, sampleRate);
214
219
  return new _SileroVAD(model, {
215
220
  minSpeechDuration: options.minSpeechDuration ?? 0.25,
216
- minSilenceDuration: options.minSilenceDuration ?? 0.1,
221
+ // Bumped 0.1 -> 0.4s after round 10f confirmed VAD speech_end fired on
222
+ // natural inter-sentence pauses < 250ms, causing double-talk dispatch.
223
+ // 400ms is the industry default for telephony and matches the new
224
+ // inter_utterance_gap_ms debounce in stream-handler.ts.
225
+ minSilenceDuration: options.minSilenceDuration ?? 0.4,
217
226
  prefixPaddingDuration: options.prefixPaddingDuration ?? 0.03,
218
227
  activationThreshold,
219
228
  deactivationThreshold,
@@ -233,7 +242,10 @@ var SileroVAD = class _SileroVAD {
233
242
  * - `activationThreshold = 0.5` — upstream `threshold`
234
243
  * - `deactivationThreshold = 0.35` — upstream `neg_threshold = threshold - 0.15`
235
244
  * - `minSpeechDuration = 0.25` — upstream `min_speech_duration_ms = 250`
236
- * - `minSilenceDuration = 0.1` — upstream `min_silence_duration_ms = 100`
245
+ * - `minSilenceDuration = 0.4` — telephony default (was 0.1, bumped after
246
+ * round 10f found speech_end firing on inter-sentence pauses < 250 ms,
247
+ * causing double-talk dispatch). 400 ms matches the industry telephony
248
+ * default and the inter_utterance_gap_ms debounce in stream-handler.ts.
237
249
  * - `prefixPaddingDuration = 0.03` — upstream `speech_pad_ms = 30`
238
250
  *
239
251
  * Override any field by passing `options`. Deployments that experience
@@ -250,6 +262,19 @@ var SileroVAD = class _SileroVAD {
250
262
  static forPhoneCall(options = {}) {
251
263
  return _SileroVAD.load({
252
264
  sampleRate: 16e3,
265
+ // Telephony bumps the activation threshold from the upstream
266
+ // 0.5 → 0.8 (with deactivation 0.65) so background voices and
267
+ // low-volume audio in the caller's room don't trip barge-in.
268
+ // Near-mic speech typically scores 0.85-0.98 on Silero — above
269
+ // 0.8 — while a distant second speaker through a phone's noise-
270
+ // suppression pipeline lands around 0.4-0.6 and is now correctly
271
+ // ignored. Bumped twice during 2026-05-20 acceptance: first 0.5
272
+ // → 0.7 (still triggered on quiet voices), then 0.7 → 0.8.
273
+ // Trade-off: a whispered legitimate input may not trigger;
274
+ // typical phone-call speakers are unaffected. Pass an explicit
275
+ // ``activationThreshold`` to override per call site.
276
+ activationThreshold: 0.8,
277
+ deactivationThreshold: 0.65,
253
278
  ...options
254
279
  });
255
280
  }
@@ -356,6 +381,28 @@ var SileroVAD = class _SileroVAD {
356
381
  if (this.closed) return;
357
382
  this.closed = true;
358
383
  }
384
+ /**
385
+ * Reset all per-utterance state so the next ``processFrame`` starts from
386
+ * a clean SILENCE state.
387
+ *
388
+ * Called by the stream handler between agent turns to prevent a "stuck
389
+ * SPEECH" condition where PSTN echo / loopback kept the detector's
390
+ * probability above ``deactivationThreshold`` for the entire agent turn.
391
+ * Without this reset the next user utterance would never trigger a
392
+ * SILENCE→SPEECH transition and barge-in would feel "one-shot" (works
393
+ * once, then never again until the call ends).
394
+ *
395
+ * Safe to call any time including on a closed instance (no-op).
396
+ */
397
+ reset() {
398
+ if (this.closed) return;
399
+ this.pending = new Float32Array(0);
400
+ this.pubSpeaking = false;
401
+ this.speechThresholdDuration = 0;
402
+ this.silenceThresholdDuration = 0;
403
+ this.expFilter.reset();
404
+ this.model.reset();
405
+ }
359
406
  };
360
407
 
361
408
  export {
package/dist/cli.js CHANGED
@@ -29,8 +29,8 @@ var import_express = __toESM(require("express"));
29
29
 
30
30
  // src/dashboard/store.ts
31
31
  var import_events = require("events");
32
- var fs = __toESM(require("fs"));
33
- var path = __toESM(require("path"));
32
+ var fs2 = __toESM(require("fs"));
33
+ var path2 = __toESM(require("path"));
34
34
 
35
35
  // src/logger.ts
36
36
  var defaultLogger = {
@@ -45,11 +45,41 @@ function getLogger() {
45
45
  return currentLogger;
46
46
  }
47
47
 
48
+ // src/version.ts
49
+ var fs = __toESM(require("fs"));
50
+ var path = __toESM(require("path"));
51
+ function readVersion() {
52
+ try {
53
+ const pkgPath = path.resolve(__dirname, "..", "package.json");
54
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
55
+ return typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : "";
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ var VERSION = readVersion();
61
+
48
62
  // src/dashboard/store.ts
63
+ function sdkVersion() {
64
+ return VERSION;
65
+ }
49
66
  var MetricsStore = class extends import_events.EventEmitter {
50
67
  maxCalls;
51
68
  calls = [];
52
69
  activeCalls = /* @__PURE__ */ new Map();
70
+ /**
71
+ * User-driven soft delete: call_ids the operator removed from the
72
+ * dashboard view. The on-disk artefacts written by ``CallLogger``
73
+ * (``metadata.json``, ``transcript.jsonl``) are intentionally NOT
74
+ * touched — they serve as the durable backup. All read paths
75
+ * (``getCalls`` / ``getCall`` / ``getAggregates`` / ``getCallsInRange``
76
+ * / ``hydrate``) filter against this set so the call is invisible
77
+ * to the UI and excluded from rolling metrics. Populated from
78
+ * ``<logRoot>/.deleted_call_ids.json`` on hydrate so deletions
79
+ * survive a process restart. Parity with Python.
80
+ */
81
+ deletedCallIds = /* @__PURE__ */ new Set();
82
+ deletedIdsPath = null;
53
83
  /**
54
84
  * Accepts either a numeric ``maxCalls`` (legacy positional — matches the
55
85
  * original TS API) or an options object ``{ maxCalls }`` to align with the
@@ -142,6 +172,8 @@ var MetricsStore = class extends import_events.EventEmitter {
142
172
  ended_at: Date.now() / 1e3,
143
173
  status,
144
174
  metrics: null,
175
+ ...active.turns && active.turns.length > 0 ? { turns: active.turns } : {},
176
+ ...active.transcript && active.transcript.length > 0 ? { transcript: active.transcript } : {},
145
177
  ...extra
146
178
  };
147
179
  this.activeCalls.delete(callId);
@@ -170,6 +202,21 @@ var MetricsStore = class extends import_events.EventEmitter {
170
202
  if (active) {
171
203
  if (!active.turns) active.turns = [];
172
204
  active.turns.push(turn);
205
+ if (!active.transcript) active.transcript = [];
206
+ const turnRecord = turn;
207
+ const userText = typeof turnRecord.user_text === "string" ? turnRecord.user_text : "";
208
+ const agentText = typeof turnRecord.agent_text === "string" ? turnRecord.agent_text : "";
209
+ const ts = typeof turnRecord.timestamp === "number" ? turnRecord.timestamp : Date.now() / 1e3;
210
+ if (userText.length > 0) {
211
+ active.transcript.push({ role: "user", text: userText, timestamp: ts });
212
+ }
213
+ if (agentText.length > 0 && agentText !== "[interrupted]") {
214
+ active.transcript.push({
215
+ role: "assistant",
216
+ text: agentText,
217
+ timestamp: ts
218
+ });
219
+ }
173
220
  }
174
221
  this.publish("turn_complete", { call_id: callId, turn });
175
222
  }
@@ -179,40 +226,140 @@ var MetricsStore = class extends import_events.EventEmitter {
179
226
  if (!callId) return;
180
227
  const active = this.activeCalls.get(callId);
181
228
  this.activeCalls.delete(callId);
182
- const activeStatus = active?.status;
183
- const resolvedStatus = activeStatus && activeStatus !== "in-progress" ? activeStatus : "completed";
229
+ let existingIdx = -1;
230
+ if (active === void 0) {
231
+ for (let i = this.calls.length - 1; i >= 0; i--) {
232
+ if (this.calls[i].call_id === callId) {
233
+ existingIdx = i;
234
+ break;
235
+ }
236
+ }
237
+ }
238
+ const existing = existingIdx >= 0 ? this.calls[existingIdx] : void 0;
239
+ const priorStatus = active?.status ?? existing?.status;
240
+ const resolvedStatus = priorStatus && priorStatus !== "in-progress" ? priorStatus : "completed";
241
+ const dataTranscript = data.transcript;
242
+ const resolvedTranscript = dataTranscript && dataTranscript.length > 0 ? dataTranscript : active?.transcript && active.transcript.length > 0 ? active.transcript : existing?.transcript && existing.transcript.length > 0 ? existing.transcript : [];
243
+ const resolvedTurns = active?.turns && active.turns.length > 0 ? active.turns : existing?.turns && existing.turns.length > 0 ? existing.turns : void 0;
184
244
  const entry = {
185
245
  call_id: callId,
186
- caller: data.caller || active?.caller || "",
187
- callee: data.callee || active?.callee || "",
188
- direction: active?.direction || data.direction || "inbound",
189
- started_at: active?.started_at || 0,
246
+ caller: data.caller || active?.caller || existing?.caller || "",
247
+ callee: data.callee || active?.callee || existing?.callee || "",
248
+ direction: active?.direction || existing?.direction || data.direction || "inbound",
249
+ started_at: active?.started_at || existing?.started_at || 0,
190
250
  ended_at: Date.now() / 1e3,
191
- transcript: data.transcript || [],
251
+ transcript: resolvedTranscript,
252
+ ...resolvedTurns ? { turns: resolvedTurns } : {},
192
253
  status: resolvedStatus,
193
- metrics: metrics ?? null
254
+ metrics: metrics ?? existing?.metrics ?? null
194
255
  };
195
- this.calls.push(entry);
196
- if (this.calls.length > this.maxCalls) {
197
- this.calls = this.calls.slice(-this.maxCalls);
256
+ if (existingIdx >= 0) {
257
+ this.calls[existingIdx] = entry;
258
+ } else {
259
+ this.calls.push(entry);
260
+ if (this.calls.length > this.maxCalls) {
261
+ this.calls = this.calls.slice(-this.maxCalls);
262
+ }
198
263
  }
199
264
  this.publish("call_end", {
200
265
  call_id: callId,
201
266
  metrics: entry.metrics ?? null
202
267
  });
203
268
  }
204
- /** Return a window of completed calls in newest-first order. */
269
+ /**
270
+ * Return a window of completed calls in newest-first order.
271
+ *
272
+ * Soft-deleted call_ids (see ``deleteCalls``) are filtered out so the
273
+ * dashboard never re-shows a row the user removed. The on-disk
274
+ * artefacts are intentionally preserved as a backup.
275
+ */
205
276
  getCalls(limit = 50, offset = 0) {
206
- const ordered = [...this.calls].reverse();
277
+ const visible = this.calls.filter((c) => !this.deletedCallIds.has(c.call_id));
278
+ const ordered = visible.reverse();
207
279
  return ordered.slice(offset, offset + limit);
208
280
  }
209
- /** Look up a completed call by id (newest match wins). */
281
+ /**
282
+ * Look up a completed call by id (newest match wins).
283
+ *
284
+ * Soft-deleted call_ids resolve to ``null`` so the SPA's detail pane
285
+ * cannot render a row the user removed.
286
+ */
210
287
  getCall(callId) {
288
+ if (this.deletedCallIds.has(callId)) return null;
211
289
  for (let i = this.calls.length - 1; i >= 0; i--) {
212
290
  if (this.calls[i].call_id === callId) return this.calls[i];
213
291
  }
214
292
  return null;
215
293
  }
294
+ /**
295
+ * Soft-delete one or more calls from the dashboard view.
296
+ *
297
+ * Adds each ``call_id`` to an in-memory set. Subsequent reads via
298
+ * ``getCalls`` / ``getCall`` / ``getAggregates`` / ``getCallsInRange``
299
+ * exclude the deleted ids, so rolling metrics (avg latency, total
300
+ * spend) are recomputed without them. The on-disk
301
+ * ``metadata.json`` / ``transcript.jsonl`` files written by
302
+ * ``CallLogger`` are NOT touched — they serve as a durable backup
303
+ * the operator can audit outside the dashboard.
304
+ *
305
+ * Active calls are never deletable. A call_id that is currently
306
+ * in ``activeCalls`` is silently skipped so a mid-call delete
307
+ * from the UI cannot orphan the live transcript pane.
308
+ *
309
+ * Persisted to ``<logRoot>/.deleted_call_ids.json`` (best-effort)
310
+ * when ``hydrate()`` has been called with a log root. Parity with
311
+ * Python ``delete_calls``.
312
+ *
313
+ * @returns The list of call_ids actually accepted as deleted.
314
+ */
315
+ deleteCalls(callIds) {
316
+ const ids = /* @__PURE__ */ new Set();
317
+ for (const cid of callIds || []) {
318
+ if (typeof cid === "string" && cid && !this.activeCalls.has(cid)) {
319
+ ids.add(cid);
320
+ }
321
+ }
322
+ if (ids.size === 0) return [];
323
+ const accepted = [];
324
+ for (const cid of ids) {
325
+ if (!this.deletedCallIds.has(cid)) {
326
+ this.deletedCallIds.add(cid);
327
+ accepted.push(cid);
328
+ }
329
+ }
330
+ if (accepted.length === 0) return [];
331
+ accepted.sort();
332
+ this.persistDeletedIds();
333
+ this.publish("calls_deleted", { call_ids: accepted });
334
+ return accepted;
335
+ }
336
+ /** Whether ``callId`` was soft-deleted from the dashboard. */
337
+ isDeleted(callId) {
338
+ return this.deletedCallIds.has(callId);
339
+ }
340
+ /** Snapshot of soft-deleted call_ids (sorted). */
341
+ getDeletedCallIds() {
342
+ return Array.from(this.deletedCallIds).sort();
343
+ }
344
+ /** Atomically persist the deleted-ids set to disk. Best-effort. */
345
+ persistDeletedIds() {
346
+ if (this.deletedIdsPath === null) return;
347
+ try {
348
+ const dir = path2.dirname(this.deletedIdsPath);
349
+ fs2.mkdirSync(dir, { recursive: true });
350
+ const tmp = this.deletedIdsPath + ".tmp";
351
+ const payload = {
352
+ version: 1,
353
+ deleted_call_ids: Array.from(this.deletedCallIds).sort()
354
+ };
355
+ fs2.writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf8");
356
+ fs2.renameSync(tmp, this.deletedIdsPath);
357
+ } catch (err) {
358
+ getLogger().debug(
359
+ `MetricsStore.persistDeletedIds: ${String(err)}`
360
+ );
361
+ }
362
+ }
216
363
  /** Look up an active call by id (returns undefined if not active or unknown). */
217
364
  getActive(callId) {
218
365
  return this.activeCalls.get(callId);
@@ -221,9 +368,17 @@ var MetricsStore = class extends import_events.EventEmitter {
221
368
  getActiveCalls() {
222
369
  return Array.from(this.activeCalls.values());
223
370
  }
224
- /** Compute summary statistics across the buffered call history. */
371
+ /**
372
+ * Compute summary statistics across the buffered call history.
373
+ *
374
+ * Soft-deleted calls are excluded so rolling metrics (avg latency,
375
+ * total spend) match exactly what the operator sees in the call list.
376
+ */
225
377
  getAggregates() {
226
- const totalCalls = this.calls.length;
378
+ const visible = this.calls.filter(
379
+ (c) => !this.deletedCallIds.has(c.call_id)
380
+ );
381
+ const totalCalls = visible.length;
227
382
  if (totalCalls === 0) {
228
383
  return {
229
384
  total_calls: 0,
@@ -231,7 +386,8 @@ var MetricsStore = class extends import_events.EventEmitter {
231
386
  avg_duration: 0,
232
387
  avg_latency_ms: 0,
233
388
  cost_breakdown: { stt: 0, tts: 0, llm: 0, telephony: 0 },
234
- active_calls: this.activeCalls.size
389
+ active_calls: this.activeCalls.size,
390
+ sdk_version: sdkVersion()
235
391
  };
236
392
  }
237
393
  let totalCost = 0;
@@ -242,7 +398,7 @@ var MetricsStore = class extends import_events.EventEmitter {
242
398
  let costTts = 0;
243
399
  let costLlm = 0;
244
400
  let costTel = 0;
245
- for (const call of this.calls) {
401
+ for (const call of visible) {
246
402
  const m = call.metrics;
247
403
  if (!m) continue;
248
404
  const cost = m.cost || {};
@@ -253,7 +409,7 @@ var MetricsStore = class extends import_events.EventEmitter {
253
409
  costTel += cost.telephony || 0;
254
410
  totalDuration += m.duration_seconds || 0;
255
411
  const avgLat = m.latency_avg || {};
256
- const tMs = avgLat.total_ms || 0;
412
+ const tMs = avgLat.agent_response_ms || avgLat.total_ms || 0;
257
413
  if (tMs > 0) {
258
414
  totalLatency += tMs;
259
415
  latencyCount++;
@@ -270,21 +426,30 @@ var MetricsStore = class extends import_events.EventEmitter {
270
426
  llm: Math.round(costLlm * 1e6) / 1e6,
271
427
  telephony: Math.round(costTel * 1e6) / 1e6
272
428
  },
273
- active_calls: this.activeCalls.size
429
+ active_calls: this.activeCalls.size,
430
+ sdk_version: sdkVersion()
274
431
  };
275
432
  }
276
- /** Return calls whose `started_at` falls within `[fromTs, toTs]` (Unix seconds). */
433
+ /**
434
+ * Return calls whose `started_at` falls within `[fromTs, toTs]` (Unix
435
+ * seconds). Soft-deleted calls are filtered out.
436
+ */
277
437
  getCallsInRange(fromTs = 0, toTs = 0) {
278
438
  return this.calls.filter((call) => {
439
+ if (this.deletedCallIds.has(call.call_id)) return false;
279
440
  const started = call.started_at || 0;
280
441
  if (fromTs && started < fromTs) return false;
281
442
  if (toTs && started > toTs) return false;
282
443
  return true;
283
444
  });
284
445
  }
285
- /** Number of completed calls currently in the ring buffer. */
446
+ /** Number of completed (non-deleted) calls currently in the ring buffer. */
286
447
  get callCount() {
287
- return this.calls.length;
448
+ let n = 0;
449
+ for (const c of this.calls) {
450
+ if (!this.deletedCallIds.has(c.call_id)) n++;
451
+ }
452
+ return n;
288
453
  }
289
454
  /**
290
455
  * Rebuild the in-memory call list from `metadata.json` files written by
@@ -298,19 +463,37 @@ var MetricsStore = class extends import_events.EventEmitter {
298
463
  */
299
464
  hydrate(logRoot) {
300
465
  if (!logRoot) return 0;
301
- const callsRoot = path.join(logRoot, "calls");
302
- if (!fs.existsSync(callsRoot)) return 0;
466
+ const deletedIdsPath = path2.join(logRoot, ".deleted_call_ids.json");
467
+ this.deletedIdsPath = deletedIdsPath;
468
+ if (fs2.existsSync(deletedIdsPath)) {
469
+ try {
470
+ const raw = fs2.readFileSync(deletedIdsPath, "utf8");
471
+ const payload = JSON.parse(raw);
472
+ const arr = Array.isArray(payload.deleted_call_ids) ? payload.deleted_call_ids : [];
473
+ for (const cid of arr) {
474
+ if (typeof cid === "string" && cid.length > 0) {
475
+ this.deletedCallIds.add(cid);
476
+ }
477
+ }
478
+ } catch (err) {
479
+ getLogger().debug(
480
+ `MetricsStore.hydrate: skipping ${deletedIdsPath}: ${String(err)}`
481
+ );
482
+ }
483
+ }
484
+ const callsRoot = path2.join(logRoot, "calls");
485
+ if (!fs2.existsSync(callsRoot)) return 0;
303
486
  const collected = [];
304
487
  const seen = new Set(this.calls.map((c) => c.call_id));
305
488
  const walk = (dir, depth) => {
306
489
  let entries;
307
490
  try {
308
- entries = fs.readdirSync(dir, { withFileTypes: true });
491
+ entries = fs2.readdirSync(dir, { withFileTypes: true });
309
492
  } catch {
310
493
  return;
311
494
  }
312
495
  for (const entry of entries) {
313
- const childPath = path.join(dir, entry.name);
496
+ const childPath = path2.join(dir, entry.name);
314
497
  if (depth < 3) {
315
498
  if (entry.isDirectory() && /^\d+$/.test(entry.name)) {
316
499
  walk(childPath, depth + 1);
@@ -318,10 +501,10 @@ var MetricsStore = class extends import_events.EventEmitter {
318
501
  continue;
319
502
  }
320
503
  if (!entry.isDirectory()) continue;
321
- const metadataPath = path.join(childPath, "metadata.json");
322
- if (!fs.existsSync(metadataPath)) continue;
504
+ const metadataPath = path2.join(childPath, "metadata.json");
505
+ if (!fs2.existsSync(metadataPath)) continue;
323
506
  try {
324
- const raw = fs.readFileSync(metadataPath, "utf8");
507
+ const raw = fs2.readFileSync(metadataPath, "utf8");
325
508
  const meta = JSON.parse(raw);
326
509
  const callId = meta.call_id || entry.name;
327
510
  if (!callId || seen.has(callId)) continue;
@@ -332,6 +515,12 @@ var MetricsStore = class extends import_events.EventEmitter {
332
515
  );
333
516
  continue;
334
517
  }
518
+ if (!record.transcript || record.transcript.length === 0) {
519
+ const fromJsonl = loadTranscriptJsonl(
520
+ path2.join(childPath, "transcript.jsonl")
521
+ );
522
+ if (fromJsonl.length > 0) record.transcript = fromJsonl;
523
+ }
335
524
  collected.push(record);
336
525
  seen.add(callId);
337
526
  } catch (err) {
@@ -353,12 +542,45 @@ var MetricsStore = class extends import_events.EventEmitter {
353
542
  return collected.length;
354
543
  }
355
544
  };
545
+ function metricsFromTopLevel(meta) {
546
+ const cost = meta.cost && typeof meta.cost === "object" ? meta.cost : null;
547
+ const latency = meta.latency && typeof meta.latency === "object" ? meta.latency : null;
548
+ const durationMs = meta.duration_ms;
549
+ const telephony = meta.telephony_provider;
550
+ if (cost === null && latency === null && durationMs == null && !telephony) {
551
+ return null;
552
+ }
553
+ const out = {};
554
+ if (cost !== null) out.cost = cost;
555
+ if (latency !== null) {
556
+ const fullAvg = latency.avg && typeof latency.avg === "object" ? latency.avg : null;
557
+ const fullP50 = latency.p50 && typeof latency.p50 === "object" ? latency.p50 : null;
558
+ const fullP95 = latency.p95 && typeof latency.p95 === "object" ? latency.p95 : null;
559
+ const fullP99 = latency.p99 && typeof latency.p99 === "object" ? latency.p99 : null;
560
+ if (fullAvg) out.latency_avg = fullAvg;
561
+ if (fullP50) out.latency_p50 = fullP50;
562
+ if (fullP95) out.latency_p95 = fullP95;
563
+ if (fullP99) out.latency_p99 = fullP99;
564
+ if (!fullAvg && !fullP50 && !fullP95) {
565
+ const totalMs = typeof latency.p95_ms === "number" && latency.p95_ms || typeof latency.p50_ms === "number" && latency.p50_ms || 0;
566
+ out.latency_avg = { total_ms: totalMs };
567
+ }
568
+ out.latency = latency;
569
+ }
570
+ if (typeof durationMs === "number" && durationMs > 0) {
571
+ out.duration_seconds = durationMs / 1e3;
572
+ }
573
+ if (typeof telephony === "string" && telephony) {
574
+ out.telephony_provider = telephony;
575
+ }
576
+ return Object.keys(out).length > 0 ? out : null;
577
+ }
356
578
  function metadataToCallRecord(callId, meta) {
357
579
  const startedAt = parseTimestamp(meta.started_at);
358
580
  if (startedAt === null) return null;
359
581
  const endedAt = parseTimestamp(meta.ended_at);
360
582
  const status = meta.status || "completed";
361
- const metrics = meta.metrics && typeof meta.metrics === "object" ? meta.metrics : null;
583
+ const metrics = meta.metrics && typeof meta.metrics === "object" ? meta.metrics : metricsFromTopLevel(meta);
362
584
  const transcript = Array.isArray(meta.transcript) ? meta.transcript : [];
363
585
  return {
364
586
  call_id: callId,
@@ -372,6 +594,36 @@ function metadataToCallRecord(callId, meta) {
372
594
  transcript
373
595
  };
374
596
  }
597
+ function loadTranscriptJsonl(filePath) {
598
+ try {
599
+ if (!fs2.existsSync(filePath)) return [];
600
+ const raw = fs2.readFileSync(filePath, "utf8");
601
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
602
+ const out = [];
603
+ for (const line of lines) {
604
+ let row;
605
+ try {
606
+ row = JSON.parse(line);
607
+ } catch {
608
+ continue;
609
+ }
610
+ const tsIso = typeof row.ts === "string" ? Date.parse(row.ts) : NaN;
611
+ const tsNumeric = typeof row.timestamp === "number" ? row.timestamp * 1e3 : NaN;
612
+ const timestamp = Number.isFinite(tsIso) ? tsIso : Number.isFinite(tsNumeric) ? tsNumeric : 0;
613
+ const userText = typeof row.user_text === "string" ? row.user_text : "";
614
+ const agentText = typeof row.agent_text === "string" ? row.agent_text : "";
615
+ if (userText.length > 0) {
616
+ out.push({ role: "user", text: userText, timestamp });
617
+ }
618
+ if (agentText.length > 0 && agentText !== "[interrupted]") {
619
+ out.push({ role: "assistant", text: agentText, timestamp });
620
+ }
621
+ }
622
+ return out;
623
+ } catch {
624
+ return [];
625
+ }
626
+ }
375
627
  function parseTimestamp(raw) {
376
628
  if (typeof raw === "number") {
377
629
  return Number.isFinite(raw) ? raw : null;
@@ -490,9 +742,9 @@ function loadDashboardHtml() {
490
742
  (0, import_node_path.join)(here, "dashboard", "ui.html"),
491
743
  (0, import_node_path.join)(here, "..", "dashboard", "ui.html")
492
744
  ];
493
- for (const path2 of candidates) {
745
+ for (const path3 of candidates) {
494
746
  try {
495
- return (0, import_node_fs.readFileSync)(path2, "utf8");
747
+ return (0, import_node_fs.readFileSync)(path3, "utf8");
496
748
  } catch {
497
749
  }
498
750
  }
@@ -512,7 +764,8 @@ function mountDashboard(app, store, token = "") {
512
764
  res.json(store.getCalls(limit, offset));
513
765
  });
514
766
  app.get("/api/dashboard/calls/:callId", auth, (req, res) => {
515
- const call = store.getCall(String(req.params.callId));
767
+ const callId = String(req.params.callId);
768
+ const call = store.getCall(callId) ?? store.getActive(callId);
516
769
  if (!call) {
517
770
  res.status(404).json({ error: "Not found" });
518
771
  return;
@@ -525,6 +778,24 @@ function mountDashboard(app, store, token = "") {
525
778
  app.get("/api/dashboard/aggregates", auth, (_req, res) => {
526
779
  res.json(store.getAggregates());
527
780
  });
781
+ app.delete("/api/dashboard/calls/:callId", auth, (req, res) => {
782
+ const callId = String(req.params.callId);
783
+ const accepted = store.deleteCalls([callId]);
784
+ res.json({ deleted: accepted, count: accepted.length });
785
+ });
786
+ app.post("/api/dashboard/calls/delete", auth, (req, res) => {
787
+ const body = req.body ?? {};
788
+ const raw = body.call_ids;
789
+ if (!Array.isArray(raw)) {
790
+ res.status(400).json({ error: "Expected JSON body { 'call_ids': [...] }" });
791
+ return;
792
+ }
793
+ const ids = raw.filter(
794
+ (cid) => typeof cid === "string" && cid.length > 0
795
+ );
796
+ const accepted = store.deleteCalls(ids);
797
+ res.json({ deleted: accepted, count: accepted.length });
798
+ });
528
799
  app.get("/api/dashboard/events", auth, (req, res) => {
529
800
  res.writeHead(200, {
530
801
  "Content-Type": "text/event-stream",
@@ -597,7 +868,8 @@ function mountApi(app, store, token = "") {
597
868
  res.json({ data: active, count: active.length });
598
869
  });
599
870
  app.get("/api/v1/calls/:callId", auth, (req, res) => {
600
- const call = store.getCall(String(req.params.callId));
871
+ const callId = String(req.params.callId);
872
+ const call = store.getCall(callId) ?? store.getActive(callId);
601
873
  if (!call) {
602
874
  res.status(404).json({ error: "Call not found" });
603
875
  return;
@@ -725,6 +997,12 @@ async function main() {
725
997
  res.json({ ok: false, error: "missing call_id" });
726
998
  return;
727
999
  }
1000
+ const status = data.status;
1001
+ if (status === "initiated") {
1002
+ store.recordCallInitiated(data);
1003
+ res.json({ ok: true, call_id: callId, event: "initiated" });
1004
+ return;
1005
+ }
728
1006
  store.recordCallStart(data);
729
1007
  if (data.ended_at) {
730
1008
  store.recordCallEnd(data, data.metrics ?? null);