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.
- package/dist/barge-in-strategies-X6ARMGIQ.mjs +12 -0
- package/dist/chunk-CL2U3YET.mjs +1429 -0
- package/dist/chunk-D4424JZR.mjs +71 -0
- package/dist/{chunk-JUQ5WQTQ.mjs → chunk-LE63CSOB.mjs} +1424 -969
- package/dist/{chunk-X3364LSI.mjs → chunk-R2T4JABZ.mjs} +49 -2
- package/dist/cli.js +315 -37
- package/dist/dashboard/ui.html +13 -13
- package/dist/index.d.mts +2136 -709
- package/dist/index.d.ts +2136 -709
- package/dist/index.js +5674 -2233
- package/dist/index.mjs +2338 -915
- package/dist/openai-realtime-2-CNFARP25.mjs +8 -0
- package/dist/{silero-vad-YLCXT5GQ.mjs → silero-vad-LNDFGIY7.mjs} +1 -1
- package/dist/{test-mode-Y7YG5LFZ.mjs → test-mode-RS57BDM6.mjs} +2 -1
- package/package.json +1 -1
- package/src/dashboard/ui.html +13 -13
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
33
|
-
var
|
|
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
|
-
|
|
183
|
-
|
|
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:
|
|
251
|
+
transcript: resolvedTranscript,
|
|
252
|
+
...resolvedTurns ? { turns: resolvedTurns } : {},
|
|
192
253
|
status: resolvedStatus,
|
|
193
|
-
metrics: metrics ?? null
|
|
254
|
+
metrics: metrics ?? existing?.metrics ?? null
|
|
194
255
|
};
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
302
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
322
|
-
if (!
|
|
504
|
+
const metadataPath = path2.join(childPath, "metadata.json");
|
|
505
|
+
if (!fs2.existsSync(metadataPath)) continue;
|
|
323
506
|
try {
|
|
324
|
-
const raw =
|
|
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 :
|
|
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
|
|
745
|
+
for (const path3 of candidates) {
|
|
494
746
|
try {
|
|
495
|
-
return (0, import_node_fs.readFileSync)(
|
|
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
|
|
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
|
|
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);
|